├── .editorconfig ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── dev-requirements.txt ├── examples ├── __init__.py ├── to_geojson_linestring.py ├── to_geojson_points.py ├── tocsv.py └── totable.py ├── notes.md ├── pyproject.toml ├── setup.cfg ├── setup.py ├── sllib ├── __init__.py ├── debug.py.old ├── definitions.py ├── errors.py ├── frame.py ├── header.py └── reader.py ├── tests ├── __init__.py ├── fixtures.py ├── test_frame.py ├── test_reader.py ├── test_reader_sl2.py ├── test_reader_sl3.py └── test_reader_slg.py ├── tox.ini └── utils └── offsets.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,py}] 8 | charset = utf-8 9 | 10 | [*.py] 11 | indent_style = space 12 | indent_size = 4 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.6, 3.7, 3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | submodules: recursive 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: List files in the repository 23 | run: | 24 | ls ${{ github.workspace }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi 29 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | - name: Test with pytest 37 | run: | 38 | pytest 39 | - name: Run some examples 40 | run: | 41 | pip install -e . 42 | python ./examples/totable.py ./tests/sample-data-lowrance/Elite_4_Chirp/small.sl2 43 | python ./examples/to_geojson_linestring.py ./tests/sample-data-lowrance/Elite_4_Chirp/small.sl2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # vscode project settings 98 | .vscode/ 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | 110 | # project specific 111 | *.csv 112 | .ipynb_checkpoints/ 113 | private/ 114 | *.json -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/sample-data-lowrance"] 2 | path = tests/sample-data-lowrance 3 | url = https://github.com/opensounder/sample-data-lowrance.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2021 Peter Magnusson 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SLLib 2 | A python library for reading SLG or SL2 files created by Lowrance fishfinders. 3 | Only tested with python 3.6 and 3.7 4 | 5 | Git Repostitory and homepage located at https://github.com/opensounder/python-sllib 6 | 7 | ![example workflow](https://github.com/opensounder/python-sllib/actions/workflows/python-package.yml/badge.svg) 8 | 9 | Until version 1.x.y every change to x will be a possible breaking change. 10 | Otherwise it should follow samever versioning principles. 11 | 12 | # Installation 13 | Using `pip` 14 | ```shell 15 | pip install sllib 16 | ``` 17 | 18 | Cloning from git 19 | ``` 20 | python3 setup.py install 21 | ``` 22 | 23 | # Usage 24 | ``` 25 | python3 26 | >>> import sllib 27 | >>> with open('somefile.sl2', 'rb) as f: 28 | ... reader = sllib.Reader(f) 29 | ... header = reader.header 30 | ... print(header.format) 31 | ... for frame in reader: 32 | ... print(frame.gps_speed) 33 | 34 | ``` 35 | Or have a look at https://github.com/opensounder/jupyter-notebooks 36 | 37 | ## Examples 38 | ```shell 39 | # this will create a file called `small.csv` in current directory 40 | python ./examples/tocsv.py ./tests/sample-data-lowrance/Elite_4_Chirp/small.sl2 41 | 42 | ``` 43 | 44 | 45 | # Development 46 | ```shell 47 | git clone https://github.com/opensounder/python-sllib 48 | 49 | cd python-sllib 50 | python3 -m venv venv 51 | . venv/bin/activate 52 | pip install -e . 53 | pip install -r dev-requirements.txt 54 | 55 | # then to test in for example python 3.9 56 | # change to what fits your installation 57 | tox -e py39 58 | 59 | # before committing please run lint and fix any issues 60 | tox -e lint 61 | ``` 62 | 63 | # SLG information 64 | Besides trial and error 65 | - https://www.geotech1.com/forums/showthread.php?11159-Lowrance-MCC-saved-data-structure 66 | - https://www.memotech.franken.de/FileFormats/Navico_SLG_Format.pdf -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | tox 2 | pytest 3 | coverage 4 | flake8 5 | build 6 | twine -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensounder/python-sllib/f54a27c76667c4eb83340a60b378e78b5e6dce71/examples/__init__.py -------------------------------------------------------------------------------- /examples/to_geojson_linestring.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from pathlib import Path 4 | 5 | from sllib import Reader 6 | 7 | 8 | def main(): 9 | filename = sys.argv[1] 10 | name = Path(filename).stem 11 | 12 | coords = list() 13 | 14 | with open(filename, 'rb') as f: 15 | reader = Reader(f) 16 | print(reader.header) 17 | last = None 18 | for frame in reader: 19 | c = (frame.longitude, frame.latitude) 20 | if c != last: 21 | coords.append(c) 22 | last = c 23 | 24 | line = dict(type='Feature', 25 | geometry=dict(type='LineString', coordinates=coords), 26 | properties=dict()) 27 | data = dict(type='FeatureCollection', features=[line]) 28 | with open(f'{name}.json', 'w') as outfile: 29 | json.dump(data, outfile, indent=4) 30 | 31 | 32 | if __name__ == "__main__": 33 | main() 34 | -------------------------------------------------------------------------------- /examples/to_geojson_points.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from pathlib import Path 4 | 5 | from sllib import Reader 6 | 7 | 8 | def main(): 9 | filename = sys.argv[1] 10 | name = Path(filename).stem 11 | 12 | features = list() 13 | 14 | with open(filename, 'rb') as f: 15 | reader = Reader(f) 16 | print(reader.header) 17 | last = None 18 | for frame in reader: 19 | c = (frame.longitude, frame.latitude) 20 | point = dict(type='Feature', 21 | geometry=dict(type='Point', coordinates=c), 22 | properties=dict(water_depth_m=frame.water_depth_m, 23 | speed_kph=frame.gps_speed_kph)) 24 | if c != last: 25 | features.append(point) 26 | last = c 27 | 28 | data = dict(type='FeatureCollection', features=features) 29 | with open(f'{name}.json', 'w') as outfile: 30 | json.dump(data, outfile, indent=4) 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /examples/tocsv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import glob 3 | import os 4 | from pathlib import Path 5 | import argparse 6 | import logging 7 | 8 | from sllib import Reader 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def create_csv_with_header(csvfile, fields): 14 | writer = csv.DictWriter( 15 | csvfile, fields, dialect='excel', 16 | extrasaction='ignore', delimiter='\t') 17 | writer.writeheader() 18 | return writer 19 | 20 | 21 | def process_file(filename, outpath, all, strict): 22 | name = Path(filename).stem 23 | outfile = os.path.join(outpath, f'{name}.csv.tab') 24 | with open(outfile, 'w', newline='') as csvfile: 25 | last = None 26 | with open(filename, 'rb') as f: 27 | reader = Reader(f, strict=strict) 28 | print(filename, reader.header) 29 | fields = reader.fields 30 | writer = create_csv_with_header(csvfile, fields) 31 | for frame in reader: 32 | point = (frame.longitude, frame.latitude) 33 | if all or point != last: 34 | dct = frame.to_dict(fields=fields, format=reader.header.format) 35 | writer.writerow(dct) 36 | last = point 37 | 38 | 39 | def main(path, all, strict): 40 | print(f'Testing {path} to see what it is') 41 | if os.path.isfile(path): 42 | print('You provided a file.') 43 | process_file(path, os.path.dirname(path), all, strict) 44 | elif os.path.isdir(path): 45 | pattern = os.path.join(path, '*.sl*') 46 | print('You provided a directory.') 47 | print("Will try to glob " + pattern) 48 | for filename in glob.iglob(pattern): 49 | print(filename) 50 | process_file(filename, path, all, strict) 51 | else: 52 | print(f'Error! You must provide a file or directory. "{path}" is neither.') 53 | 54 | 55 | if __name__ == "__main__": 56 | parser = argparse.ArgumentParser() 57 | parser.add_argument('path', help="path to file or directory to process.") 58 | parser.add_argument( 59 | '-a', '--all', 60 | help="store all records even if position is unchanged", 61 | action='store_true') 62 | parser.add_argument( 63 | '-s', '--strict', 64 | help="enable strict interpretation", 65 | action='store_true' 66 | ) 67 | parser.add_argument( 68 | '-v', '--verbose', 69 | help="show verbose debug logging", 70 | action='store_true' 71 | ) 72 | args = parser.parse_args() 73 | if args.verbose: 74 | logging.basicConfig(level=logging.DEBUG) 75 | # logging.setLevel(level=logging.DEBUG) 76 | main(args.path, args.all, args.strict) 77 | -------------------------------------------------------------------------------- /examples/totable.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | from sllib import Reader 7 | 8 | FIELDS = ['time1', 'gps_speed', 'gps_speed_kph', 'lon_enc', 'lat_enc', 9 | 'longitude', 'latitude', 'water_depth_m', 'packetsize'] 10 | 11 | fmt = "|{:>11}|{:>10}|{:>14}|{:>9}|{:>9}|{:>11}|{:>11}|{:>14}|{:>10}|\n" 12 | 13 | 14 | def fnum(value): 15 | if isinstance(value, float): 16 | return f'{value:f}' 17 | return value 18 | 19 | 20 | # def format_data(row): 21 | # return {k: fnum(v) for (k, v) in row.items()} 22 | 23 | 24 | def process_file(filename, outpath): 25 | name = Path(filename).stem 26 | outfile = os.path.join(outpath, f'{name}.txt') 27 | with open(outfile, 'w', newline='\n') as fil: 28 | header = fmt.format(*FIELDS) 29 | fil.write(header) 30 | fil.write(fmt.replace('>', '->').format(*map(lambda k: '-', FIELDS))) 31 | 32 | last = None 33 | with open(filename, 'rb') as f: 34 | reader = Reader(f) 35 | print(reader.header) 36 | 37 | for frame in reader: 38 | point = (frame.longitude, frame.latitude) 39 | values = frame.to_dict(fields=FIELDS) 40 | if point != last: 41 | fil.write(fmt.format(*map(lambda k: fnum(values[k]), FIELDS))) 42 | last = point 43 | 44 | 45 | def main(): 46 | filepath = sys.argv[1] 47 | print(f'Testing {filepath} to see what it is') 48 | if os.path.isfile(filepath): 49 | print('You provided a file.') 50 | process_file(filepath, os.path.dirname(filepath)) 51 | elif os.path.isdir(filepath): 52 | pattern = os.path.join(filepath, '*.sl*') 53 | print('Gou provided a directory.') 54 | print("Will try to glob " + pattern) 55 | for filename in glob.iglob(pattern): 56 | print(filename) 57 | process_file(filename, filepath) 58 | else: 59 | print(f'Error! You must provide a file or directory. "{filepath}" is neither.') 60 | 61 | 62 | if __name__ == "__main__": 63 | # print('asdfasdf') 64 | main() 65 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | # Publishing 2 | Based upon https://packaging.python.org/tutorials/packaging-projects/ 3 | ```shell 4 | pip install --upgrade setuptools wheel twine 5 | 6 | # generate archives 7 | python .\setup.py sdist bdist_wheel 8 | 9 | # keyring gets installed with twine 10 | keyring set https://test.pypi.org/legacy/ your-username 11 | 12 | # test upload 13 | twine upload --repository testpypi dist/* 14 | 15 | # in a fresh virtualenv test to install 16 | pip install --index-url https://test.pypi.org/simple/ --no-deps sllib-YOUR-USERNAME-HERE 17 | ``` 18 | 19 | 20 | ## Publish in Production 21 | ```shell 22 | keyring set https://upload.pypi.org/legacy/ your-username 23 | twine upload dist/* 24 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = sllib 3 | version = 0.2.3 4 | author = Peter Magnusson 5 | author_email = me@kmpm.se 6 | description = Library for reading SLG, SL2 and SL3 files from Lowrance fishfinders 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/opensounder/python-sllib 10 | project_urls = 11 | Bug Tracker = https://github.com/opensounder/python-sllib/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | 17 | [options] 18 | 19 | packages = 20 | sllib 21 | python_requires = >=3.6 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /sllib/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from .reader import Reader # noqa: F401 4 | from .frame import Frame 5 | from .header import Header 6 | 7 | __all__ = ['Frame', 'Reader', 'Header', 'create_reader', '__version__'] 8 | 9 | __version__ = '0.2.3' 10 | 11 | 12 | @contextmanager 13 | def create_reader(filename, strict=False): 14 | f = open(filename, 'rb') 15 | try: 16 | yield Reader(f, strict=strict) 17 | finally: 18 | f.close() 19 | -------------------------------------------------------------------------------- /sllib/debug.py.old: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | from inspect import getmembers 3 | from types import FunctionType 4 | 5 | 6 | def attributes(obj): 7 | disallowed_names = { 8 | name for name, value in getmembers(type(obj)) 9 | if isinstance(value, FunctionType) 10 | } 11 | return { 12 | name: getattr(obj, name) for name in dir(obj) 13 | if name[0] != '_' and name not in disallowed_names and hasattr(obj, name) 14 | } 15 | 16 | 17 | def print_attributes(obj): 18 | pprint(attributes(obj)) 19 | -------------------------------------------------------------------------------- /sllib/definitions.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | KNOTS_KMH = 1.85200 4 | EARTH_RADIUS = 6356752.3142 5 | RAD_CONVERSION = 180 / math.pi 6 | FEET_CONVERSION = 0.3048 7 | 8 | F0_FRAME = () 9 | # B=byte, H=ushort, h=short, I=uint, i=int, f=float 10 | F1_FRAME = ( 11 | # {'name': 'flags', 'type': 'H'}, 12 | # {'name': 'lower_limit', 'type': 'f'}, 13 | # {'name': 'water_depth', 'type': 'f'}, 14 | # {'name': 'temperature', 'type': 'f'}, 15 | # {'name': 'water_speed', 'type': 'f'}, 16 | # {'name': 'lon_enc', 'type': 'i'}, 17 | # {'name': 'lat_enc', 'type': 'i'}, 18 | # {'name': 'surface_depth', 'type': 'f'}, 19 | # {'name': 'top_of_bottom', 'type': 'f'}, 20 | # {'name': 'temperature2', 'type': 'f'}, 21 | # {'name': 'temperature3', 'type': 'f'}, 22 | # {'name': 'time1', 'type': 'I'}, 23 | # {'name': 'gps_speed', 'type': 'f'}, 24 | # {'name': 'heading', 'type': 'f'}, 25 | # {'name': 'altitude', 'type': 'f'}, 26 | # {'name': 'packetsize', 'type': 'H'}, 27 | ) 28 | F2_FRAME = ( 29 | {'name': 'offset', 'type': 'I'}, 30 | {'name': 'previous_primary_offset', 'type': 'I'}, 31 | {'name': 'previous_secondary_offset', 'type': 'I'}, 32 | {'name': 'previous_downscan_offset', 'type': 'I'}, 33 | {'name': 'previous_left_sidescan_offset', 'type': 'I'}, 34 | {'name': 'previous_right_sidescan_offset', 'type': 'I'}, 35 | {'name': 'previous_composite_sidescan_offset', 'type': 'I'}, 36 | {'name': 'framesize', 'type': 'H'}, 37 | {'name': 'previous_framesize', 'type': 'H'}, 38 | {'name': 'channel', 'type': 'H'}, 39 | {'name': 'packetsize', 'type': 'H'}, 40 | {'name': 'frame_index', 'type': 'I'}, 41 | {'name': 'upper_limit', 'type': 'f'}, 42 | {'name': 'lower_limit', 'type': 'f'}, 43 | {'name': '-', 'type': '2s'}, 44 | {'name': 'frequency', 'type': 'B'}, 45 | {'name': '-', 'type': '13s'}, 46 | {'name': 'water_depth', 'type': 'f'}, 47 | {'name': 'keel_depth', 'type': 'f'}, 48 | {'name': '-', 'type': '28s'}, 49 | {'name': 'gps_speed', 'type': 'f'}, 50 | {'name': 'temperature', 'type': 'f'}, 51 | {'name': 'lon_enc', 'type': 'i'}, 52 | {'name': 'lat_enc', 'type': 'i'}, 53 | {'name': 'water_speed', 'type': 'f'}, 54 | {'name': 'course', 'type': 'f'}, 55 | {'name': 'altitude', 'type': 'f'}, 56 | {'name': 'heading', 'type': 'f'}, 57 | {'name': 'flags', 'type': 'H'}, 58 | {'name': '-', 'type': '6s'}, 59 | {'name': 'time1', 'type': 'I'}, 60 | ) 61 | # H = ushort(2), I= uint(4) 62 | F3_FRAME = ( 63 | {'name': 'offset', 'type': 'I'}, 64 | {'name': '-', 'type': 'I'}, 65 | {'name': 'framesize', 'type': 'H'}, 66 | {'name': 'previous_framesize', 'type': 'H'}, 67 | {'name': 'channel', 'type': 'I'}, 68 | {'name': 'frame_index', 'type': 'I'}, 69 | {'name': 'upper_limit', 'type': 'f'}, 70 | {'name': 'lower_limit', 'type': 'f'}, 71 | {'name': '-', 'type': '12s'}, 72 | {'name': 'created_at', 'type': 'I'}, 73 | {'name': 'packetsize', 'type': 'I'}, 74 | {'name': 'water_depth', 'type': 'f'}, 75 | {'name': 'frequency', 'type': 'I'}, 76 | {'name': '-', 'type': '28s'}, 77 | {'name': 'gps_speed', 'type': 'f'}, 78 | {'name': 'temperature', 'type': 'f'}, 79 | {'name': 'lon_enc', 'type': 'i'}, 80 | {'name': 'lat_enc', 'type': 'i'}, 81 | {'name': 'water_speed', 'type': 'f'}, 82 | {'name': 'course', 'type': 'f'}, 83 | {'name': 'altitude', 'type': 'f'}, 84 | {'name': 'heading', 'type': 'f'}, 85 | {'name': 'flags', 'type': 'H'}, 86 | {'name': '-', 'type': '6s'}, 87 | {'name': 'time1', 'type': 'I'}, 88 | # {'name': '-', 'type': '40s'} 89 | ) 90 | 91 | F2_FLAGS = { 92 | 'has_altitude': 0x0200, 93 | 'has_heading': 0x0100, 94 | 'has_track': 0x0080, 95 | 'has_water_speed': 0x0040, 96 | 'has_position': 0x0010, 97 | 'has_packet': 0x0008, 98 | 'has_temperature': 0x0004, 99 | 'has_gps_speed': 0x0002, 100 | } 101 | 102 | F3_FLAGS = { 103 | 'has_altitude': 0x0200, 104 | 'has_heading': 0x0100, 105 | # 106 | 'has_track': 0x0080, 107 | 'has_water_speed': 0x0040, 108 | 'has_position': 0x0010, 109 | # 110 | 'has_tbd1': 0x0008, 111 | 'has_temperature': 0x0004, 112 | 'has_gps_speed': 0x0002, 113 | # 114 | } 115 | 116 | 117 | FRAME_DEFINITIONS = ( 118 | F0_FRAME, 119 | F1_FRAME, 120 | F2_FRAME, 121 | F3_FRAME, 122 | ) 123 | 124 | 125 | def build_pattern(fdef): 126 | return "<" + "".join(map(lambda x: x['type'], fdef)) 127 | 128 | 129 | def build_names(fdef): 130 | return list(map(lambda x: x['name'], 131 | filter(lambda x: x['name'] != '-', fdef))) 132 | 133 | 134 | """struct patterns for each format""" 135 | FRAME_FORMATS = ( 136 | build_pattern(FRAME_DEFINITIONS[0]), 137 | build_pattern(FRAME_DEFINITIONS[1]), 138 | build_pattern(FRAME_DEFINITIONS[2]), 139 | build_pattern(FRAME_DEFINITIONS[3]), 140 | ) 141 | 142 | FLAG_FORMATS = ( 143 | None, 144 | None, 145 | F2_FLAGS, 146 | F3_FLAGS, 147 | ) 148 | 149 | """fieldnames for each format""" 150 | FRAME_FIELDS = ( 151 | build_names(FRAME_DEFINITIONS[0]), 152 | build_names(FRAME_DEFINITIONS[1]), 153 | build_names(FRAME_DEFINITIONS[2]) + list(FLAG_FORMATS[2].keys()), 154 | build_names(FRAME_DEFINITIONS[3]) + list(FLAG_FORMATS[3].keys()), 155 | ) 156 | 157 | 158 | CALCULATED_FIELDS = ['gps_speed_kph', 'longitude', 'latitude', 'water_depth_m', 'headersize', 'heading_deg'] 159 | -------------------------------------------------------------------------------- /sllib/errors.py: -------------------------------------------------------------------------------- 1 | 2 | class NotEnoughDataError(Exception): 3 | pass 4 | 5 | class OffsetError(Exception): 6 | pass 7 | 8 | class FieldNotFoundError(Exception): 9 | pass -------------------------------------------------------------------------------- /sllib/frame.py: -------------------------------------------------------------------------------- 1 | from sllib.errors import NotEnoughDataError, OffsetError 2 | from typing import List 3 | import struct 4 | import math 5 | import io 6 | from typing import Tuple 7 | import logging 8 | 9 | from sllib.definitions import ( 10 | CALCULATED_FIELDS, EARTH_RADIUS, FEET_CONVERSION, FLAG_FORMATS, 11 | FRAME_DEFINITIONS, FRAME_FIELDS, FRAME_FORMATS, 12 | KNOTS_KMH, RAD_CONVERSION 13 | ) 14 | # from .debug import print_attributes 15 | 16 | FLAG_AS_BINARY = False # Format to show flags in. Binary is usefull for debugging 17 | 18 | __all__ = ['Frame'] 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class Frame(object): 24 | gps_speed: int = 0 25 | lat_enc: int = 0 26 | lon_enc: int = 0 27 | water_depth: int = 0 28 | time1: int = 0 29 | 30 | def __init__(self, *args, **kwargs): 31 | for key, value in kwargs.items(): 32 | setattr(self, key, value) 33 | 34 | @property 35 | def heading_deg(self): 36 | return self.heading * RAD_CONVERSION 37 | 38 | @property 39 | def gps_speed_kph(self): 40 | return self.gps_speed * KNOTS_KMH 41 | 42 | @property 43 | def longitude(self): 44 | return self.lon_enc / EARTH_RADIUS * RAD_CONVERSION 45 | 46 | @property 47 | def latitude(self): 48 | temp = math.exp(self.lat_enc / EARTH_RADIUS) 49 | temp = (2 * math.atan(temp)) - (math.pi / 2) 50 | return temp * RAD_CONVERSION 51 | 52 | @property 53 | def water_depth_m(self): 54 | return self.water_depth * FEET_CONVERSION 55 | 56 | def to_dict(self, format=2, fields=None): 57 | out = {} 58 | allfields = FRAME_FIELDS[format] + CALCULATED_FIELDS 59 | if fields is not None: 60 | allfields = list(filter(lambda x: x in allfields, fields)) 61 | for name in allfields: 62 | if hasattr(self, name): 63 | out[name] = getattr(self, name) 64 | else: 65 | out[name] = 0 66 | 67 | return out 68 | 69 | @staticmethod 70 | def read(stream: io.IOBase, formver: List[int], blocksize: int = 0, strict: bool = False): 71 | if not isinstance(formver, list): 72 | raise TypeError('format must be a list') 73 | 74 | if formver[0] == 1: 75 | # slg is a conditional format for each packet... yuck 76 | return _readSlg(stream, blocksize) 77 | if formver[0] == 2: 78 | return _readSl2(stream, blocksize, formver, strict=strict) 79 | if formver[0] == 3: 80 | return _readSlx(stream, blocksize, strict=strict, formver=formver) 81 | raise Exception('unkown format') 82 | 83 | 84 | def _readSl2(stream: io.IOBase, blocksize: int, formver: List[int], strict: bool) -> Frame: 85 | format = formver[0] 86 | f = FRAME_FORMATS[format] 87 | s = struct.calcsize(f) 88 | here = stream.tell() 89 | bad = 0 90 | while True: 91 | buf = stream.read(s) 92 | if buf == b'': 93 | # EOF 94 | return None 95 | if len(buf) < s: 96 | print(f'This is bad. Only got {len(buf)}/{s} bytes=', buf) 97 | raise NotEnoughDataError('got less bytes than expected during read') 98 | data = struct.unpack(f, buf) 99 | if data[0] == here: # offset is allways first 100 | if bad > 1: 101 | logger.warning('got back at offset: %s', here) 102 | break 103 | elif here > 0: 104 | bad += 1 105 | if bad == 1: 106 | logger.warning('unexpected offset at offset: %s. will try to find next frame', here) 107 | if strict: 108 | raise OffsetError('offset missmatch') 109 | # jump forward and try to catch next 110 | here += 1 111 | stream.seek(here) 112 | continue 113 | else: 114 | raise OffsetError('location does not match expected offset') 115 | 116 | kv = {'headersize': s} 117 | for i, d in enumerate(FRAME_DEFINITIONS[format]): 118 | name = d['name'] 119 | if not name == "-": 120 | kv[name] = data[i] 121 | if name == 'flags' and FLAG_FORMATS[format]: 122 | if FLAG_AS_BINARY: 123 | kv[name] = f'({kv[name]}) {kv[name]:016b}' 124 | flagform = FLAG_FORMATS[format] 125 | flags = data[i] 126 | for k, v in flagform.items(): 127 | kv[k] = flags & v == v 128 | b = Frame(**kv) 129 | b.packet = stream.read(b.packetsize) 130 | return b 131 | 132 | 133 | def _readSlx(stream: io.IOBase, blocksize: int, formver: List[int], strict: bool) -> Frame: 134 | format = formver[0] 135 | version = formver[1] 136 | f = FRAME_FORMATS[format] 137 | s = struct.calcsize(f) 138 | here = stream.tell() 139 | bad = 0 140 | while True: 141 | buf = stream.read(s) 142 | if buf == b'': 143 | # EOF 144 | return None 145 | if len(buf) < s: 146 | print(f'This is bad. Only got {len(buf)}/{s} bytes=', buf) 147 | raise NotEnoughDataError("this is bad") 148 | data = struct.unpack(f, buf) 149 | if data[0] == here: # offset is always first value 150 | if bad > 1: 151 | logger.warn('got back at offset: %s', here) 152 | break 153 | elif here > 0: 154 | bad += 1 155 | if bad == 1: 156 | logger.warn('unexpected offset %s at location: %s. will try to find next frame', data[0], here) 157 | if strict: 158 | raise OffsetError('offset missmatch') 159 | # jump forward and try to catch next 160 | here += 1 161 | stream.seek(here) 162 | continue 163 | else: 164 | raise OffsetError('location does not match expected offset') 165 | 166 | kv = {'headersize': s} 167 | for i, d in enumerate(FRAME_DEFINITIONS[format]): 168 | name = d['name'] 169 | if not name == "-": 170 | kv[name] = data[i] 171 | if name == 'flags' and FLAG_FORMATS[format]: 172 | if FLAG_AS_BINARY: 173 | kv[name] = f'({kv[name]}) {kv[name]:016b}' 174 | flagform = FLAG_FORMATS[format] 175 | flags = data[i] 176 | for k, v in flagform.items(): 177 | kv[k] = flags & v == v 178 | b = Frame(**kv) 179 | packetsize = b.packetsize 180 | if version == 1 and not b.has_tbd1: 181 | packetsize = b.framesize - 168 182 | 183 | if version == 1 or (version == 2 and b.channel <= 5): 184 | extra = 168-s 185 | stream.read(extra) 186 | b.packet = stream.read(packetsize) 187 | 188 | return b 189 | 190 | 191 | def _readSlg(fs: io.IOBase, blocksize: int) -> Frame: 192 | start = fs.tell() 193 | # show(fs, '--- start') 194 | # fs.read(1) # skip 1 195 | headerlen = 2 196 | try: 197 | flags = unreadpack(fs, ' {flags:016b}') 203 | # print_attributes(f) 204 | 205 | if flags > 0: 206 | try: 207 | # B=byte, H=ushort, h=short, I=uint, i=int, f=float 208 | if f.has_depth | f.has_surface_depth: 209 | kv['lower_limit'] = unreadpack(fs, ' Tuple: 268 | s = struct.calcsize(f) 269 | buf = fs.read(s) 270 | if buf == b'': 271 | raise EOFError 272 | if len(buf) != s: 273 | raise Exception('not enough data') 274 | return struct.unpack(f, buf) 275 | 276 | 277 | class _FlagsF1(object): 278 | value: int = 0 279 | 280 | def __init__(self, value: int) -> None: 281 | super().__init__() 282 | self.value = value 283 | 284 | def _is_set(self, mask: int) -> bool: 285 | return self.value & mask == mask 286 | 287 | @property 288 | def test_valid_alititude(self) -> bool: 289 | return not ((self.has_time or self.has_speed_track) and self.has_altitude) 290 | 291 | @property 292 | def has_altitude(self) -> bool: 293 | return self._is_set(0x0001) 294 | 295 | @property 296 | def has_temp(self) -> bool: 297 | return self._is_set(0x0010) 298 | 299 | @property 300 | def has_temp2(self) -> bool: 301 | return self._is_set(0x0020) 302 | 303 | @property 304 | def has_temp3(self) -> bool: 305 | return self._is_set(0x0040) 306 | 307 | @property 308 | def has_waterspeed(self) -> bool: 309 | return self._is_set(0x0080) 310 | 311 | @property 312 | def has_position(self): 313 | return self._is_set(0x0100) 314 | 315 | @property 316 | def has_depth(self) -> bool: 317 | return self._is_set(0x0200) 318 | 319 | @property 320 | def has_surface_depth(self) -> bool: 321 | return self._is_set(0x0400) 322 | 323 | @property 324 | def has_tob(self) -> bool: 325 | return self._is_set(0x0800) 326 | 327 | @property 328 | def has_time(self) -> bool: 329 | return self._is_set(0x2000) 330 | 331 | @property 332 | def has_speed_track(self) -> bool: 333 | return self._is_set(0x4000) 334 | -------------------------------------------------------------------------------- /sllib/header.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class Header(object): 8 | format: int = 0 9 | version: int = 0 10 | framesize: int = 0 11 | terminal: int = 0 # only used by format 1 12 | 13 | def __init__(self, format, version, framesize, debug, *args, **kwargs): 14 | self.format = format 15 | self.version = version 16 | self.framesize = framesize 17 | self.debug = debug 18 | 19 | def __str__(self): 20 | return (f'') 22 | 23 | @staticmethod 24 | def read(filestream): 25 | data = struct.unpack(" List[str]: 28 | """generate a list of fieldnames for current format""" 29 | return FRAME_FIELDS[self.header.format] + CALCULATED_FIELDS 30 | 31 | @property 32 | def formver(self) -> Tuple[int]: 33 | """return format and version as a tuple""" 34 | return (self.header.format, self.header.version) 35 | 36 | def close(self): 37 | self.fs.close() 38 | 39 | def read(self, size): 40 | self.fs.read(size) 41 | 42 | def tell(self) -> int: 43 | return self.fs.tell() 44 | 45 | def add_filter(self, **kwargs): 46 | if 'channels' in kwargs: 47 | channels = kwargs.pop('channels') 48 | if not isinstance(channels, (list, tuple)): 49 | raise Exception('channels must be a list or tuple') 50 | self._filter['channels'] += channels 51 | 52 | fields = self.fields 53 | for key, value in kwargs.items(): 54 | if key in fields: 55 | self._filter['fields'][key] = value 56 | else: 57 | raise FieldNotFoundError(f'{key} is not a valid field to filter on') 58 | return self 59 | 60 | def __iter__(self): 61 | return self 62 | 63 | def __next__(self) -> Frame: 64 | r"""Reads next frame. 65 | 66 | :returns: 67 | A read frame instance 68 | """ 69 | while True: 70 | frame = Frame.read(self.fs, self.format_version, self.header.framesize, strict=self.strict) 71 | if frame is None: 72 | raise StopIteration() 73 | if 'channels' in self._filter and self._filter['channels']: 74 | if frame.channel not in self._filter['channels']: 75 | continue 76 | if not fieldsAllMatch(self._filter['fields'], frame): 77 | continue 78 | break 79 | return frame 80 | 81 | 82 | def fieldsAllMatch(fields, frame) -> bool: 83 | result = True 84 | for key, value in fields.items(): 85 | x = getattr(frame, key) 86 | if x != value: 87 | result = False 88 | break 89 | return result 90 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opensounder/python-sllib/f54a27c76667c4eb83340a60b378e78b5e6dce71/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | BASEDIR = os.path.dirname(os.path.abspath(__file__)) 5 | 6 | 7 | SL2_SMALL = os.path.join(BASEDIR, 8 | 'sample-data-lowrance', 9 | 'Elite_4_Chirp', 'small.sl2') 10 | SL2_V1 = os.path.join(BASEDIR, 11 | 'sample-data-lowrance', 'Elite_4_Chirp', 12 | 'Chart 05_11_2018 [0].sl2') 13 | 14 | SL2_SOUTHERN1 = os.path.join(BASEDIR, 'sample-data-lowrance', 15 | 'HDS5', 'southern1.sl2') 16 | 17 | SL2_CORRUPT_PARTLY = os.path.join( 18 | BASEDIR, 'sample-data-lowrance', 19 | 'other', 'corrupt_partly.sl2') 20 | 21 | SL3_V1_A = os.path.join( 22 | BASEDIR, 'sample-data-lowrance', 'other', 'sonar-log-api-testdata.sl3') 23 | 24 | SL3_V2_A = os.path.join( 25 | BASEDIR, 'sample-data-lowrance', 'other', 'format3_version2.sl3') 26 | 27 | SL2 = ( 28 | SL2_SMALL, 29 | SL2_V1, 30 | SL2_SOUTHERN1, 31 | SL2_CORRUPT_PARTLY 32 | ) 33 | 34 | SL3 = ( 35 | SL3_V1_A, 36 | SL3_V2_A 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_frame.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from sllib import Frame 3 | 4 | 5 | class TestFrame(unittest.TestCase): 6 | 7 | def test_to_dict(self): 8 | f = Frame(lon_enc=1383678, lat_enc=8147302, gps_speed=2.5) 9 | d = f.to_dict() 10 | assert d 11 | self.assertIn('gps_speed_kph', d) 12 | self.assertIn('lat_enc', d) 13 | self.assertIn('lon_enc', d) 14 | self.assertIn('water_speed', d) 15 | self.assertIn('longitude', d) 16 | self.assertIn('latitude', d) 17 | 18 | d = f.to_dict(fields=['longitude', 'latitude']) 19 | self.assertIn('longitude', d) 20 | self.assertIn('latitude', d) 21 | self.assertNotIn('gps_speed_kph', d) 22 | self.assertNotIn('lon_enc', d) 23 | self.assertNotIn('lat_enc', d) 24 | 25 | def test_gps_speed_kph(self): 26 | f = Frame(gps_speed=2.5) 27 | assert hasattr(f, 'gps_speed_kph') 28 | self.assertEqual(f.gps_speed_kph, 4.63) 29 | 30 | def test_location(self): 31 | f = Frame(lon_enc=1383678, lat_enc=8147302) 32 | assert hasattr(f, 'longitude') 33 | self.assertEqual(f.longitude, 12.471605890323259) 34 | self.assertEqual(f.latitude, 58.97372610987078) 35 | -------------------------------------------------------------------------------- /tests/test_reader.py: -------------------------------------------------------------------------------- 1 | 2 | from sllib.errors import FieldNotFoundError, OffsetError 3 | import unittest 4 | from os import path 5 | from sllib import Reader, create_reader 6 | from . import fixtures 7 | 8 | BASE = path.join(path.dirname(path.abspath(__file__)), 'sample-data-lowrance') 9 | 10 | 11 | class TestReader(unittest.TestCase): 12 | def test_formver_reader(self): 13 | stream = open(fixtures.SL2_SMALL, 'rb') 14 | reader = Reader(stream) 15 | self.assertEqual(reader.formver, (2, 0)) 16 | reader.close() 17 | s = f'{reader.header}' 18 | self.assertIn(' 5 then header is 128 bytes instead of 168 24 | no change in flags 25 | """ 26 | 27 | 28 | def create_csv_with_header(csvfile, fields) -> csv.DictWriter: 29 | writer = csv.DictWriter( 30 | csvfile, fields, dialect='excel', 31 | extrasaction='ignore', delimiter='\t') 32 | writer.writeheader() 33 | return writer 34 | 35 | 36 | def readfile(stream: IOBase, writer: csv.DictWriter, formver: List[int], maxcount: int = 20): 37 | count = 0 38 | last = 0 39 | offset = 8 40 | last_end = 8 41 | while True: 42 | stream.seek(offset) 43 | buf = stream.read(4) 44 | if buf == b'' or len(buf) < 4: 45 | logger.info('no more data.') 46 | break 47 | # read data as if offset 48 | data = struct.unpack('= maxcount: 71 | break 72 | return count 73 | 74 | 75 | def main(filename, maxcount): 76 | outpath = os.path.dirname(filename) 77 | name = Path(filename).stem 78 | outfile = os.path.join(outpath, f'{name}.offsets.tab') 79 | with open(filename, 'rb') as stream: 80 | with open(outfile, 'w', newline='') as csvfile: 81 | reader = Reader(stream) 82 | formver = [reader.header.format, reader.header.version] 83 | print(reader.header) 84 | fields = ['start', 'end', 'offby', 'size', 'asdf'] + reader.fields 85 | writer = create_csv_with_header(csvfile, fields) 86 | count = readfile(stream, writer, formver, maxcount) 87 | print(f'wrote {count} records to {outfile}') 88 | 89 | 90 | if __name__ == "__main__": 91 | parser = argparse.ArgumentParser() 92 | parser.add_argument('path', help="path to file to test") 93 | parser.add_argument( 94 | '-v', '--verbosity', 95 | default=0, action='count', 96 | help="increase verbosity") 97 | parser.add_argument( 98 | '-n', '--number', 99 | type=int, default=20, 100 | help="number of records to read" 101 | ) 102 | args = parser.parse_args() 103 | 104 | if args.verbosity > 3: 105 | args.verbosity = 3 106 | 107 | level = logging.ERROR - (args.verbosity * 10) 108 | # root.setLevel(level) 109 | logging.basicConfig(level=level) 110 | main(args.path, args.number) 111 | --------------------------------------------------------------------------------