├── tests ├── __init__.py ├── test_sofar_api_device_endpoints.py ├── test_wave_query_class.py ├── test_spotter_class.py ├── test_utils.py ├── test_sofar_api_user_rest_cellular.py └── test_sofar_api_class.py ├── ex.sofar_api.env ├── requirements.txt ├── .gitignore ├── gitsetup.sh ├── src └── pysofar │ ├── wavefleet_exceptions.py │ ├── tools.py │ ├── __init__.py │ ├── spotter.py │ └── sofar.py ├── .travis.yml ├── setup.py ├── contributing.md ├── README.md └── LICENSE.txt /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ex.sofar_api.env: -------------------------------------------------------------------------------- 1 | WF_API_TOKEN='ABC123...TOKEN' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nose 2 | python-dotenv 3 | pytest 4 | requests 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .idea 3 | .vscode/ 4 | __pycache__/ 5 | *.pyc 6 | build/ 7 | .pytest_cache/ 8 | pysofar.egg-info/ -------------------------------------------------------------------------------- /gitsetup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | git remote remove origin 3 | git remote add -f origin https://$GIT_SECRET:x-oauth-basic@github.com/wavespotter/sofar-api-client-python.git 4 | git checkout master 5 | git merge --no-edit staging 6 | git config --local user.name $GIT_NAME 7 | git config --local user.email $GIT_EMAIL -------------------------------------------------------------------------------- /src/pysofar/wavefleet_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Contents: Exception classes 5 | 6 | Copyright 2019-2024 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa et al. 10 | """ 11 | 12 | 13 | class QueryError(Exception): 14 | """ 15 | Exception raised when a query to the wavefleet api fails. 16 | """ 17 | pass 18 | 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - '3.7' 5 | branches: 6 | except: 7 | - master 8 | install: 9 | - pip install -r requirements.txt 10 | - pip install setuptools wheel 11 | jobs: 12 | include: 13 | - stage: deploy 14 | script: 15 | - bash gitsetup.sh 16 | - bash build.sh 17 | - pytest 18 | - stage: test 19 | script: 20 | - python setup.py bdist_wheel 21 | - pip install dist/*.whl 22 | - pytest 23 | stages: 24 | - name: build 25 | if: "(branch = staging) AND (NOT (type IN (pull_request)))" 26 | - name: test 27 | if: "type IN (pull_request)" 28 | 29 | after_success: 30 | - test $TRAVIS_BRANCH = "staging" && test $TRAVIS_PULL_REQUEST = "false" && bash deploy.sh 31 | after_failure: 32 | - echo "Oh no" 33 | # TODO: Notifications -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | import setuptools 3 | 4 | with open('README.md', 'r') as file: 5 | readme_contents = file.read() 6 | 7 | setuptools.setup( 8 | name='pysofar', 9 | version='0.1.16', 10 | license='Apache 2 License', 11 | install_requires=[ 12 | 'requests', 13 | 'python-dotenv' 14 | ], 15 | description='Python client for interfacing with the Sofar Wavefleet API to access Spotter Data', 16 | long_description=readme_contents, 17 | long_description_content_type='text/markdown', 18 | author='Rufus Sofar', 19 | author_email='sofaroceangithubbot@gmail.com', 20 | url='https://github.com/wavespotter/wavefleet-client-python', 21 | 22 | package_dir={'': 'src'}, 23 | packages=setuptools.find_packages('src'), 24 | 25 | classifiers=[ 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.7", 28 | "License :: OSI Approved :: Apache Software License", 29 | "Operating System :: OS Independent" 30 | ], 31 | project_urls={ 32 | 'Sofar Ocean Site': 'https://www.sofarocean.com', 33 | 'Spotter About': 'https://www.sofarocean.com/products/spotter', 34 | 'Spotter Data FAQ': 'https://www.sofarocean.com/posts/spotter-data-subscription-and-storage', 35 | 'Sofar Dashboard': 'https://spotter.sofarocean.com/', 36 | 'Sofar Api FAQ': 'https://spotter.sofarocean.com/api' 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /tests/test_sofar_api_device_endpoints.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Contents: Tests for device endpoints 5 | 6 | Copyright (C) 2019 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa 10 | """ 11 | from pysofar.sofar import SofarApi 12 | 13 | api = SofarApi() 14 | dat = api.get_device_location_data() 15 | devices = api.devices 16 | device_ids = api.device_ids 17 | 18 | 19 | def test_get_device(): 20 | # test that the sofar api can correctly grab its related devices 21 | assert devices is not None 22 | assert isinstance(devices, list) 23 | assert all(map(lambda x: isinstance(x, dict), devices)) 24 | 25 | 26 | def test_get_device_ids(): 27 | # test that the device ids are returned correctly 28 | assert api.device_ids is not None 29 | assert isinstance(api.device_ids, list) 30 | 31 | 32 | def test_get_device_location_data(): 33 | # test that the api can retrieve device location data 34 | d = dat 35 | assert dat is not None 36 | assert len(dat) != 0 37 | assert all(map(lambda x: isinstance(x, dict), dat)) 38 | 39 | 40 | def test_valid_location_data(): 41 | # test that the location data returned is formatted correctly 42 | spotter = dat.pop() 43 | assert 'spotterId' in spotter 44 | assert 'location' in spotter 45 | 46 | loc = spotter['location'] 47 | 48 | assert 'lat' in loc 49 | assert 'lon' in loc 50 | assert 'timestamp' in loc 51 | -------------------------------------------------------------------------------- /src/pysofar/tools.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Contents: Functions useful for date/time related parsing and formatting 5 | 6 | Copyright 2019-2024 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa et al. 10 | """ 11 | import time 12 | import calendar 13 | import datetime 14 | 15 | 16 | def time_stamp_to_epoch(date_string): 17 | """ 18 | 19 | :param date_string: Date string formatted as iso 20 | :return: 21 | """ 22 | return calendar.timegm(time.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%f%z')) 23 | 24 | 25 | def parse_date(date_object): 26 | """ 27 | 28 | :param date_object: Give in utc format, either epoch, string, or datetime object 29 | :return: String date formatted in ISO 8601 format 30 | """ 31 | _date = None 32 | 33 | if isinstance(date_object, (int, float)): 34 | _date = datetime.datetime.utcfromtimestamp(date_object) 35 | elif isinstance(date_object, str): 36 | # time includes microseconds 37 | formatting = "%Y-%m-%dT%H:%M:%S.%f%z" 38 | 39 | if "Z" not in date_object and "+" not in date_object: 40 | formatting = "%Y-%m-%dT%H:%M:%S.%f" 41 | 42 | if "." not in date_object: 43 | formatting = "%Y-%m-%dT%H:%M:%S" 44 | 45 | if "T" not in date_object: 46 | formatting = "%Y-%m-%d" 47 | 48 | _date = datetime.datetime.strptime(date_object, formatting) 49 | elif isinstance(date_object, datetime.datetime): 50 | _date = date_object 51 | else: 52 | raise Exception('Invalid Date Format') 53 | 54 | # make zone unaware 55 | f_string = _date.replace(tzinfo=None).isoformat(timespec="milliseconds") 56 | return f"{f_string}Z" 57 | -------------------------------------------------------------------------------- /src/pysofar/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Copyright 2019-2024 5 | Sofar Ocean Technologies 6 | 7 | Authors: Mike Sosa et al. 8 | """ 9 | import os 10 | import dotenv 11 | import requests 12 | import json 13 | 14 | def get_token(): 15 | # config values 16 | userpath = os.path.expanduser("~") 17 | environmentFile = os.path.join(userpath, 'sofar_api.env') 18 | dotenv.load_dotenv(environmentFile) 19 | token = os.getenv('WF_API_TOKEN') 20 | _wavefleet_token = token 21 | 22 | return _wavefleet_token 23 | 24 | 25 | def get_endpoint(): 26 | _endpoint = os.getenv('WF_URL') 27 | if _endpoint is None: 28 | _endpoint = 'https://api.sofarocean.com/api' 29 | return _endpoint 30 | 31 | class SofarConnection: 32 | """ 33 | Base Parent class for connections to the API 34 | Use SofarApi in sofar.py in practice 35 | """ 36 | def __init__(self, custom_token=None): 37 | self._token = custom_token or get_token() 38 | self.endpoint = get_endpoint() 39 | self.header = {'token': self._token, 'Content-Type': 'application/json'} 40 | 41 | # Helper methods 42 | def _get(self, endpoint_suffix, params: dict = None): 43 | url = f"{self.endpoint}/{endpoint_suffix}" 44 | if params is None: 45 | response = requests.get(url, headers=self.header) 46 | else: 47 | response = requests.get(url, headers=self.header, params=params) 48 | 49 | status = response.status_code 50 | data = response.json() 51 | 52 | return status, data 53 | 54 | def _post(self, endpoint_suffix, json_data): 55 | response = requests.get(f"{self.endpoint}/{endpoint_suffix}", 56 | json=json_data, 57 | headers=self.header) 58 | status = response.status_code 59 | data = response.json() 60 | 61 | return status, data 62 | 63 | def set_token(self, new_token): 64 | self._token = new_token 65 | self.header.update({'token': new_token}) 66 | -------------------------------------------------------------------------------- /tests/test_wave_query_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Oceans Spotter API 3 | 4 | Contents: Tests for device endpoints 5 | 6 | Copyright (C) 2019 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa 10 | """ 11 | from pysofar.sofar import WaveDataQuery 12 | 13 | st = '2019-05-02' 14 | end = '2019-05-10' 15 | q = WaveDataQuery('SPOT-0350', limit=100, start_date=st, end_date=end) 16 | q.wind(True) 17 | 18 | 19 | def test_query_execute(): 20 | # basic query 21 | # returned earliest data first 22 | response = q.execute() 23 | assert response is not None 24 | assert isinstance(response, dict) 25 | 26 | assert 'waves' in response 27 | assert 'wind' in response 28 | 29 | 30 | def test_query_no_dates(): 31 | # resturned latest data first 32 | q.limit(10) 33 | q.clear_start_date() 34 | q.clear_end_date() 35 | 36 | response = q.execute() 37 | assert response is not None 38 | assert isinstance(response, dict) 39 | 40 | assert 'waves' in response 41 | assert 'wind' in response 42 | 43 | 44 | def test_query_no_start(): 45 | # resturned 46 | q.limit(10) 47 | 48 | q.clear_start_date() 49 | q.set_end_date(end) 50 | 51 | response = q.execute() 52 | assert response is not None 53 | assert isinstance(response, dict) 54 | 55 | assert 'waves' in response 56 | assert 'wind' in response 57 | 58 | 59 | def test_query_no_end(): 60 | # returned 61 | q.limit(10) 62 | 63 | q.clear_end_date() 64 | q.set_start_date(st) 65 | 66 | response = q.execute() 67 | assert response is not None 68 | assert isinstance(response, dict) 69 | 70 | assert 'waves' in response 71 | assert 'wind' in response 72 | 73 | # TODO: need to get a viable spotter for testing 74 | # def test_query_surface_temp(): 75 | # q.surface_temp(True) 76 | # q.limit = 10 77 | # 78 | # q.clear_end_date() 79 | # response = q.execute() 80 | # q.set_start_date(st) 81 | # 82 | # assert response is not None 83 | # assert isinstance(response, dict) 84 | # 85 | # assert 'waves' in response 86 | # assert 'wind' in response 87 | # assert 'surfaceTemp' in response 88 | -------------------------------------------------------------------------------- /tests/test_spotter_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Oceans Spotter API 3 | 4 | Contents: Tests for device endpoints 5 | 6 | Copyright (C) 2019 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa 10 | """ 11 | from pysofar.sofar import SofarApi 12 | from pysofar.spotter import Spotter 13 | 14 | api = SofarApi() 15 | 16 | device = Spotter('SPOT-0350', '') 17 | 18 | 19 | def test_spotter_update(): 20 | # tests the spotter updates correctly 21 | device.update() 22 | 23 | assert device.mode is not None 24 | assert device.battery_voltage is not None 25 | assert device.battery_power is not None 26 | assert device.solar_voltage is not None 27 | assert device.lat is not None 28 | assert device.lon is not None 29 | assert device.humidity is not None 30 | 31 | 32 | def test_spotter_latest_data(): 33 | # test spotter is properly able to grab the latest data 34 | dat = device.latest_data() 35 | 36 | assert isinstance(dat, dict) 37 | assert 'wave' in dat 38 | assert 'tracking' in dat 39 | assert 'frequency' in dat 40 | 41 | if device.mode == 'tracking': 42 | assert dat['tracking'] is not None 43 | else: 44 | assert dat['wave'] is not None 45 | 46 | if device.mode == 'waves_full': 47 | assert dat['frequency'] is not None 48 | 49 | 50 | def test_spotter_edit(): 51 | # test properties are set and read correctly 52 | device.mode = 'waves' 53 | device.battery_power = -1 54 | device.battery_voltage = 0 55 | device.solar_voltage = 1 56 | device.lat = 2 57 | device.lon = 3 58 | device.humidity = 5 59 | 60 | assert device.mode == 'waves_standard' 61 | assert device.battery_power == -1 62 | assert device.battery_voltage == 0 63 | assert device.solar_voltage == 1 64 | assert device.lat == 2 65 | assert device.lon == 3 66 | assert device.humidity == 5 67 | 68 | 69 | def test_spotter_mode(): 70 | # test the spotter mode property is set correctly 71 | device.mode = 'track' 72 | assert device.mode == 'tracking' 73 | 74 | device.mode = 'full' 75 | assert device.mode == 'waves_spectrum' 76 | 77 | 78 | def test_spotter_grab_data(): 79 | # test spotter can correctly grab data 80 | dat = device.grab_data(limit=20) 81 | print(dat) 82 | 83 | assert 'waves' in dat 84 | assert len(dat['waves']) <= 20 85 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Oceans Spotter API 3 | 4 | Contents: Tests for testing utility functions 5 | 6 | Copyright (C) 2019 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa 10 | """ 11 | from datetime import datetime 12 | from pysofar.tools import parse_date, time_stamp_to_epoch 13 | 14 | 15 | def test_time_stamp_to_epoch(): 16 | ts = '1985-11-15T23:56:12.000Z' 17 | epoch = time_stamp_to_epoch(ts) 18 | 19 | assert isinstance(epoch, int) 20 | dt = parse_date(epoch) 21 | 22 | assert dt == ts 23 | 24 | 25 | def test_parse_date_string(): 26 | # test iso representation parses correctly 27 | ts = '1985-11-15T12:34:56.000Z' 28 | dt = parse_date(ts) 29 | 30 | assert dt is not None 31 | assert dt == '1985-11-15T12:34:56.000Z' 32 | 33 | 34 | def test_parse_date_string_only_days(): 35 | # test Y-M-D parses 36 | ts = '1985-11-15' 37 | dt = parse_date(ts) 38 | 39 | assert dt is not None 40 | assert dt == '1985-11-15T00:00:00.000Z' 41 | 42 | 43 | def test_parse_date_string_no_milliseconds(): 44 | # test Y-M-DTH:M:S parses 45 | ts = '1985-11-15T12:34:56' 46 | dt = parse_date(ts) 47 | 48 | assert dt is not None 49 | assert dt == '1985-11-15T12:34:56.000Z' 50 | 51 | 52 | def test_parse_date_string_no_utc(): 53 | # test Y-M-DTH:M:S parses 54 | ts = '1985-11-15T12:34:56.000' 55 | dt = parse_date(ts) 56 | 57 | assert dt is not None 58 | assert dt == '1985-11-15T12:34:56.000Z' 59 | 60 | 61 | def test_parse_date_string_utc_offset(): 62 | # test Y-M-DTH:M:S parses 63 | ts = '1985-11-15T12:34:56.000+00:00' 64 | dt = parse_date(ts) 65 | 66 | assert dt is not None 67 | assert dt == '1985-11-15T12:34:56.000Z' 68 | 69 | 70 | def test_parse_date_datetime(): 71 | # test passing in a datetime works 72 | ts = datetime(1997, 2, 16, 5, 25) 73 | dt = parse_date(ts) 74 | 75 | assert dt is not None 76 | assert dt == '1997-02-16T05:25:00.000Z' 77 | 78 | 79 | def test_parse_date_datetime_aware(): 80 | # test passing in aware datetime 81 | from datetime import timezone 82 | 83 | ts = datetime.now(tz=timezone.utc) 84 | dt = parse_date(ts) 85 | 86 | assert dt is not None 87 | 88 | 89 | def test_parse_date_datetime_unaware(): 90 | # passing in another datetime func 91 | ts = datetime.utcnow() 92 | dt = parse_date(ts) 93 | 94 | assert dt is not None 95 | -------------------------------------------------------------------------------- /tests/test_sofar_api_user_rest_cellular.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Contents: Tests for user-rest/cellular-signal-metrics endpoint 5 | 6 | Copyright (C) 2024 7 | Sofar Ocean Technologies 8 | 9 | Authors: Tim Johnson 10 | """ 11 | import json 12 | import unittest 13 | 14 | from pysofar.sofar import SofarApi, CellularSignalMetricsQuery 15 | from pysofar.spotter import Spotter 16 | 17 | class UserRestDevicesTest(unittest.TestCase): 18 | def testCellularSignalMetricsFromSpotter(self): 19 | spot = Spotter(self._cellular_id, self._cellular_id) 20 | data = spot.grab_cellular_signal_metrics() 21 | self.assertTrue(data) 22 | 23 | def testCellularSignalMetricsParameters(self): 24 | if not self._cellular_id: 25 | self.skipTest("could not find a likely cellular Spotter") 26 | my_limit = 30 27 | my_start_epoch_ms=1727488168 28 | my_end_epoch_ms=1727995013 29 | my_order = True 30 | 31 | query = CellularSignalMetricsQuery( 32 | self._cellular_id, 33 | limit=my_limit, 34 | order_ascending=my_order, 35 | start_epoch_ms=my_start_epoch_ms, 36 | end_epoch_ms=my_end_epoch_ms, 37 | ) 38 | data = query.execute(return_raw=True) 39 | self.assertIn('options', data) 40 | # the API response feeds us back the options we gave it, 41 | # but in the case of this API, the response keys are different 42 | # from the request parameter keys. 43 | # sc-208038 44 | options = data['options'] 45 | self.assertEqual(options['spotterId'], self._cellular_id) 46 | self.assertEqual(options['limit'], my_limit) 47 | self.assertNotIn('order_ascending', options) 48 | self.assertIn('orderAscending', options) 49 | self.assertEqual(options['orderAscending'], True) 50 | self.assertIn('startEpochMs', options) # !! unexpected 51 | self.assertIn('endEpochMs', options) # !! unexpected 52 | self.assertIsNone(options['startEpochMs']) 53 | self.assertIsNone(options['endEpochMs']) 54 | self.assertIn('sinceEpochMs', options) 55 | self.assertIn('beforeEpochMs', options) 56 | self.assertEqual(options['sinceEpochMs'], my_start_epoch_ms) 57 | self.assertEqual(options['beforeEpochMs'], my_end_epoch_ms) 58 | 59 | def testCellularSignalMetricsRequest(self): 60 | if not self._cellular_id: 61 | self.skipTest("could not find a likely cellular Spotter") 62 | 63 | query = CellularSignalMetricsQuery(self._cellular_id) 64 | data = query.execute() 65 | 66 | def setUp(self): 67 | self._api = SofarApi() 68 | self._devices = self._api.devices 69 | self._device_ids = self._api.device_ids 70 | self._cellular_id = self._findPossibleCellularDevice() 71 | 72 | def _findPossibleCellularDevice(self): 73 | """ 74 | Guess that a Spotter ending in C might be a cellular Spotter 75 | 76 | This is /not/ the best way of doing this. 77 | """ 78 | return next((spot_id for spot_id in self._device_ids if spot_id.endswith('C')), None) 79 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Steps for Contributing 2 | 3 | 4 | ### Contribution info 5 | - If you see a bug or want to request a feature open an issue on github 6 | and label it with either `feature` or `bug` appropriately. We will try 7 | to respond as quickly as possible to either. 8 | 9 | - If you wish to contribute to an existing bug please comment to let us know 10 | that you are working on the issue and for updates on progress. 11 | 12 | - Similarly if you want to work on a open `feature` request, comment on the issue 13 | to let us know. 14 | 15 | ### Development Setup 16 | 1. Fork this repository to your local account. You will not be allowed to push straight to 17 | master, so forking will allow you to push your changes. 18 | 19 | 2. Git clone the forked repository to a local location 20 | `git clone ` 21 | 22 | 2. Make sure you have Python3 (3.7 Preferred) as well as pip 23 | installed and working correctly. If you don't have pip because you are using 24 | a python version 3 < x < 3.4 then you should install it according to [this](https://pip.pypa.io/en/stable/installing/). 25 | This project may work on Python2 but we will not be supporting any development towards Python2 related issues 26 | since Python2 is being depreciated. 27 | 28 | 3. CD to where you downloaded the repository 29 | 30 | Note: Its recommended to use a virtual environment to help manage your python projects and 31 | dependencies. You can set one up by first installing virtualenv with `pip install virtualenv` 32 | and then `python3 -m venv `. Usually people will run `python3 -m venv venv` which 33 | creates a virtual environment named `venv` to keep things simple. Activate the virtual environment 34 | with `source venv/bin/activate`. To exit the virtual environment run `deactivate`. While inside the 35 | virtual environment your python and all of its packages are associated with that specific virtualenvironment. 36 | Any `pip install `. You can create the branch locally with 46 | `git checkout -b branch_name` and then push it to github with `git push --set-upstream origin branch_name`. 47 | 48 | 7. For smaller contributions like updating a readme, 49 | or fixing a small bug that is already covered by the test suite then you are most likely find with not adding any. 50 | Otherwise when you finish your work, add tests to the `tests` folder. Test your code by running `pytest` in the main 51 | repo directory. 52 | 53 | 8. If everything passes feel free to open a pull request to the staging branch and we will review the code. If you are stuck on a certain issue 54 | feel free to add more comments and questions to the issue thread and we will do our best to help you out! 55 | 56 | 9. Thank you for contributing!! 57 | 58 | ### Versioning [for internal contributors only] 59 | - Bump the [semver](https://semver.org/) version number in *./setup.py* 60 | - Run `build.sh` 61 | -------------------------------------------------------------------------------- /tests/test_sofar_api_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Oceans Spotter API 3 | 4 | Contents: Tests for device endpoints 5 | 6 | Copyright (C) 2019 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa 10 | """ 11 | from pysofar import wavefleet_exceptions 12 | from pysofar.sofar import SofarApi 13 | from unittest.mock import patch 14 | 15 | 16 | # The custom token will fail to authenticate so use a mock to bypass the `_sync step` 17 | with patch.object(SofarApi, '_sync', return_value=None) as mock_method: 18 | custom_api = SofarApi(custom_token='custom_api_token_here') 19 | 20 | def test_custom_api(): 21 | # test that custom api token is set 22 | assert custom_api.token == 'custom_api_token_here' 23 | 24 | 25 | api = SofarApi() 26 | latest_dat = api.get_latest_data('SPOT-0350', include_wind_data=True) 27 | 28 | 29 | def test_get_latest_data(): 30 | # test basic that latest_data is able to be queried 31 | assert latest_dat is not None 32 | assert isinstance(latest_dat, dict) 33 | assert 'waves' in latest_dat 34 | assert 'wind' in latest_dat 35 | assert 'track' in latest_dat 36 | assert 'frequencyData' in latest_dat 37 | 38 | 39 | def test_get_and_update_spotters(): 40 | # Test that spotter objects are able to be created and updated 41 | from pysofar.spotter import Spotter 42 | from pysofar.sofar import get_and_update_spotters 43 | 44 | sptrs = get_and_update_spotters(_api=api) 45 | 46 | assert sptrs is not None 47 | assert all(map(lambda x: isinstance(x, Spotter), sptrs)) 48 | 49 | 50 | def test_get_all_wave_data(): 51 | # Test that all wave data is able to be queried in a time range 52 | st = '2019-05-02' 53 | end = '2019-07-10' 54 | dat = api.get_wave_data(start_date=st, end_date=end) 55 | 56 | assert dat is not None 57 | assert isinstance(dat, dict) 58 | assert 'waves' in dat 59 | assert len(dat['waves']) > 0 60 | 61 | 62 | def test_get_all_wind_data(): 63 | # Test that all wind data over all time is able to be queried 64 | st = '2021-05-02' 65 | end = '2021-07-10' 66 | dat = api.get_wind_data(start_date=st, end_date=end) 67 | 68 | assert dat is not None 69 | assert isinstance(dat, dict) 70 | assert 'wind' in dat 71 | assert len(dat['wind']) > 0 72 | 73 | 74 | def test_get_all_tracking_data(): 75 | # Test that all tracking data is able to be queried in a time range 76 | st = '2021-05-02' 77 | end = '2021-06-10' 78 | dat = api.get_track_data(start_date=st, end_date=end) 79 | 80 | assert dat is not None 81 | assert isinstance(dat, dict) 82 | assert 'track' in dat 83 | assert len(dat['track']) > 0 84 | 85 | 86 | def test_get_all_frequency_data(): 87 | # Test that all frequency data is able to be queried in a time range 88 | st = '2021-06-08' 89 | end = '2021-06-10' 90 | dat = api.get_frequency_data(start_date=st, end_date=end) 91 | 92 | assert dat is not None 93 | assert isinstance(dat, dict) 94 | assert 'frequency' in dat 95 | assert len(dat['frequency']) > 0 96 | 97 | 98 | def test_get_all_data(): 99 | # Test that grabbing data from all spotters from all data types works 100 | st = '2019-01-18' 101 | end = '2019-01-25' 102 | 103 | dat = api.get_all_data(start_date=st, end_date=end) 104 | 105 | assert dat is not None 106 | assert isinstance(dat, dict) 107 | assert len(dat.keys()) == 4 108 | assert 'waves' in dat 109 | assert 'wind' in dat 110 | assert 'track' in dat 111 | assert 'frequency' in dat 112 | 113 | def test_get_sensor_data(): 114 | # Test that getting sensor data in a time range works 115 | spotter_id = 'SPOT-9999' 116 | st = '2021-07-18' 117 | end = '2021-07-19' 118 | 119 | dat = api.get_sensor_data(spotter_id, start_date=st, end_date=end) 120 | 121 | assert dat is not None 122 | assert 'sensorPosition' in dat[-1] 123 | 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sofar Ocean API Python Client 2 | Python Client for the Sofar Ocean Spotter API 3 | 4 | ### Requirements 5 | - Python3 (Preferably 3.7) and pip 6 | - python-dotenv 7 | - requests 8 | - Pytest (If developing/Contributing) 9 | - Setuptools (If developing/Contributing) 10 | 11 | ### Installation 12 | 1. Make sure that you have the requirements listed above 13 | 2. `pip install pysofar` to your desired python environment 14 | 3. Create a file in your home directory (on unix `~/`) called `sofar_api.env`. 15 | Put your API token and key inside in the format `WF_API_TOKEN=` 16 | We have included an example file named [ex.sofar_api.env](ex.sofar_api.env) in the 17 | repository which you can copy (Just make sure to change the name and update the token 18 | to match yours). If you do not currently have an API token, log into your sofar account 19 | [here](https://spotter.sofarocean.com). Open the sidebar and click `api`. You should 20 | see a section called `Authentication` which will have a button to generate a token. 21 | You can also use this to update your token to a new one, should you desire. 22 | 3. Test with `python3 -c 'import pysofar'`. If this runs successfully, chances are everything worked. 23 | 4. If you wish to develop/contribute to this project see [contributing](contributing.md). 24 | 25 | ### Basic Classes 26 | Included here are basic descriptions of some of the classes. Further documentation is provided 27 | within each function itself. 28 | 29 | ## Sofar.py 30 | 1. SofarApi: Initialize to get access most of the api endpoints 31 | - Properties: 32 | - Devices (Spotters that belong to this account). List of Dictionaries of Id and Name 33 | - Device Ids. List of the id's of the devices 34 | - Methods 35 | - get_device_location_data: Most recent location data of the devices 36 | - get_latest_data: Use to grab the latest data from a specific spotter 37 | - get_sensor_data: Gets smart mooring sensor data for a specific spotter in a date range 38 | - update_spotter_name: Update the name of a specific spotter 39 | - get_wave_data: Gets all of the wave data for all of the spotters in a date range 40 | - get_wind_data: Same as above but for wind 41 | - get_frequency_data: Same as above but for frequency 42 | - get_track_data: Same as above but for tracking data 43 | - get_all_data: Returns all of wave, wind, frequency, track for all spotters in a date range 44 | - get_spotters: Returns Spotter objects updated with data values 45 | 46 | 2. WaveDataQuery: Use for more fine tuned querying for a specific spotter 47 | - Methods: 48 | - execute: Runs the query with the set parameters 49 | - limit: Limit of how many results to return 50 | - waves: Input True to include wave data in results 51 | - wind: ^ but for winds 52 | - track: ^ but for track 53 | - frequency: ^ but for frequency 54 | - directional_moments: Input true to include directional moments if frequency data is also included 55 | - set_start_date: Set the start date of the data to be queried 56 | - clear_start_date: No lower bound on the dates for the spotter data requested 57 | - set_end_date: Sets the end date of data to be queried 58 | - clear_end_date: No upper bound on the dates for the spotter data requested 59 | 60 | 3. Miscellaneous Functions 61 | - get_and_update_spotters: Same as SofarApi.get_spotters but can be used standalone 62 | 63 | ## Spotter.py 64 | 1. Spotter: Class representing a spotter and its properties 65 | - Properties: 66 | - id 67 | - name 68 | - mode 69 | - lat 70 | - lon 71 | - battery_power 72 | - battery_voltage 73 | - solar_voltage 74 | - humidity 75 | - timestamp 76 | 77 | - Methods: 78 | - change_name: Updates the spotters name 79 | - update: Updates the spotters attributes with the latest data values 80 | - latest_data: Gets latest_data from this spotter 81 | - grab_data: More fine tuned data querying for this spotter 82 | 83 | 84 | ### A small example 85 | ```python 86 | from pysofar.sofar import SofarApi 87 | from pysofar.spotter import Spotter 88 | 89 | # init the api 90 | api = SofarApi() 91 | # get the devices belonging to you 92 | devices = api.devices 93 | print(devices) 94 | 95 | # grab spotter objects for the devices 96 | spotter_grid = api.get_spotters() 97 | # each array value is a spotter object 98 | spt_0 = spotter_grid[0] 99 | print(spt_0.mode) 100 | print(spt_0.lat) 101 | print(spt_0.lon) 102 | print(spt_0.timestamp) 103 | 104 | # Get most recent data from the above spotter with waves 105 | spt_0_dat = spt_0.latest_data() 106 | print(spt_0_dat) 107 | 108 | # what if we want frequency data with directional moments as well 109 | spt_0_dat_freq = spt_0.latest_data(include_directional_moments=True) 110 | print(spt_0_dat_freq) 111 | 112 | # What about a specific range of time with waves and track data 113 | spt_0_query = spt_0.grab_data( 114 | limit=100, 115 | start_date='2019-01-01', 116 | end_date='2019-06-30', 117 | include_track=True 118 | ) 119 | print(spt_0_query) 120 | 121 | # What if we want all data from all spotters over all time 122 | # this will take a few seconds 123 | all_dat = api.get_all_data() 124 | print(all_dat.keys()) 125 | print(all_dat) 126 | ``` 127 | 128 | 129 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/pysofar/spotter.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Contents: Classes for representing devices and data grabbed from the API 5 | 6 | Copyright 2019-2024 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa et al. 10 | """ 11 | from pysofar.sofar import SofarApi, WaveDataQuery, CellularSignalMetricsQuery 12 | 13 | 14 | # --------------------- Devices ----------------------------------------------# 15 | class Spotter: 16 | """ 17 | Class to represent a Spotter object 18 | """ 19 | def __init__(self, spotter_id: str, name: str, session: SofarApi=None): 20 | """ 21 | 22 | :param spotter_id: The Spotter id as a string 23 | :param name: The name of the Spotter 24 | """ 25 | self.id = spotter_id 26 | self.name = name 27 | 28 | # cached Spotter data 29 | self._data = None 30 | 31 | # Spotter parameters 32 | self._mode = None 33 | self._latitude = None 34 | self._longitude = None 35 | self._battery_power = None 36 | self._battery_voltage = None 37 | self._solar_voltage = None 38 | self._humidity = None 39 | self._timestamp = None 40 | 41 | if session is None: 42 | session = SofarApi() 43 | self._session = session 44 | 45 | # -------------------------- Properties -------------------------------------- # 46 | @property 47 | def mode(self): 48 | """ 49 | The tracking type of the Spotter. 50 | 3 Modes are possible: 51 | - waves_standard 52 | - waves_spectrum (Includes spectrum data) 53 | - tracking 54 | 55 | :return: The current mode of the Spotter 56 | """ 57 | return self._mode 58 | 59 | @mode.setter 60 | def mode(self, value): 61 | """ 62 | Sets the mode of the Spotter 63 | 64 | :param value: Either 'full , 'waves', or 'track' else throws exception 65 | """ 66 | if value == 'full': 67 | self._mode = 'waves_spectrum' 68 | elif value == 'waves': 69 | self._mode = 'waves_standard' 70 | elif value == 'track': 71 | self._mode = 'tracking' 72 | else: 73 | raise Exception('Invalid Mode') 74 | 75 | @property 76 | def lat(self): 77 | """ 78 | 79 | :return: The most recent latitude value (since updating) 80 | """ 81 | return self._latitude 82 | 83 | @lat.setter 84 | def lat(self, value): self._latitude = value 85 | 86 | @property 87 | def lon(self): 88 | """ 89 | 90 | :return: The most recent longitude value (since updating) 91 | """ 92 | return self._longitude 93 | 94 | @lon.setter 95 | def lon(self, value): self._longitude = value 96 | 97 | @property 98 | def battery_voltage(self): 99 | """ 100 | 101 | :return: Battery voltage of the Spotter 102 | """ 103 | return self._battery_voltage 104 | 105 | @battery_voltage.setter 106 | def battery_voltage(self, value): self._battery_voltage = value 107 | 108 | @property 109 | def battery_power(self): 110 | """ 111 | 112 | :return: The most recent battery_power value (since updating) 113 | """ 114 | return self._battery_power 115 | 116 | @battery_power.setter 117 | def battery_power(self, value): self._battery_power = value 118 | 119 | @property 120 | def solar_voltage(self): 121 | """ 122 | 123 | :return: The most recent solar voltage level (since updating) 124 | """ 125 | return self._solar_voltage 126 | 127 | @solar_voltage.setter 128 | def solar_voltage(self, value): self._solar_voltage = value 129 | 130 | @property 131 | def humidity(self): 132 | """ 133 | 134 | :return: The most recent humidity value (since updating) 135 | """ 136 | return self._humidity 137 | 138 | @humidity.setter 139 | def humidity(self, value): self._humidity = value 140 | 141 | @property 142 | def timestamp(self): 143 | """ 144 | The time value at which the current Spotter last recorded data 145 | 146 | :return: ISO8601 formatted string 147 | """ 148 | return self._timestamp 149 | 150 | @timestamp.setter 151 | def timestamp(self, value): self._timestamp = value 152 | 153 | @property 154 | def data(self): 155 | """ 156 | 157 | :return: Cached data from the latest update 158 | """ 159 | return self._data 160 | 161 | @data.setter 162 | def data(self, value): self._data = value 163 | 164 | # -------------------------- API METHODS -------------------------------------- # 165 | def change_name(self, new_name: str): 166 | """ 167 | Updates the Spotter's name in the Sofar database 168 | 169 | :param new_name: The new desired Spotter name 170 | """ 171 | self.name = self._session.update_spotter_name(self.id, new_name) 172 | 173 | def update(self): 174 | """ 175 | Updates this Spotter's attribute values. 176 | 177 | :return: The data last recorded by the current Spotter 178 | """ 179 | # TODO: also add the latest data for this (Since it does return it) 180 | # TODO: disambiguate & de-duplicate update() vs latest_data() 181 | _data = self._session.get_latest_data(self.id) 182 | 183 | self.name = _data['spotterName'] 184 | self._mode = _data['payloadType'] 185 | 186 | self._battery_power = _data['batteryPower'] 187 | self._battery_voltage = _data['batteryVoltage'] 188 | self._solar_voltage = _data['solarVoltage'] 189 | self._humidity = _data['humidity'] 190 | 191 | wave_data = _data['waves'] 192 | track_data = _data['track'] 193 | freq_data = _data['frequencyData'] 194 | 195 | if len(track_data): 196 | self._latitude = _data['track'][-1]['latitude'] 197 | self._longitude = _data['track'][-1]['longitude'] 198 | self._timestamp = _data['track'][-1]['timestamp'] 199 | else: 200 | self._latitude = None 201 | self._longitude = None 202 | self._timestamp = None 203 | 204 | results = { 205 | 'wave': wave_data[-1] if len(wave_data) > 0 else None, 206 | 'tracking': track_data[-1] if len(track_data) > 0 else None, 207 | 'frequency': freq_data[-1] if len(freq_data) > 0 else None 208 | } 209 | self._data = results 210 | 211 | def latest_data(self, 212 | include_wind: bool = False, 213 | include_directional_moments: bool = False, 214 | include_barometer_data: bool = False, 215 | include_partition_data: bool = False, 216 | include_surface_temp_data: bool = False): 217 | """ 218 | Updates and returns the latest data for this Spotter. 219 | 220 | :param include_wind: Defaults to False. Set to True if you want the latest data to include wind data 221 | :param include_directional_moments: Defaults to False. Only applies if the Spotter is in 'full_waves' mode. 222 | Set to True if you want the latest data to include directional moments 223 | :param include_barometer_data: Defaults to False. Only applies to barometer-equipped Spotters. 224 | :param include_partition_data: Defaulse to False. Only applies to Spotters in Waves:Partition mode. 225 | :param include_surface_temp_data: Defaults to False. Only applies to SST sensor-equipped Spotters. 226 | 227 | :return: The latest data values based on the given parameters from this Spotter 228 | """ 229 | _data = self._session.get_latest_data(self.id, 230 | include_wind_data=include_wind, 231 | include_directional_moments=include_directional_moments, 232 | include_barometer_data=include_barometer_data, 233 | include_partition_data=include_partition_data, 234 | include_surface_temp_data=include_surface_temp_data) 235 | 236 | wave_data = _data['waves'] 237 | track_data = _data['track'] 238 | freq_data = _data['frequencyData'] 239 | # the following fields are not included when not requested, so default to empty list 240 | wind_data = _data.get('wind', []) 241 | baro_data = _data.get('barometerData', []) 242 | partition_data = _data.get('partitionData', []) 243 | sst_data = _data.get('surfaceTemp', []) 244 | 245 | results = { 246 | 'wave': wave_data[-1] if len(wave_data) > 0 else None, 247 | 'tracking': track_data[-1] if len(track_data) > 0 else None, 248 | 'frequency': freq_data[-1] if len(freq_data) > 0 else None, 249 | 'wind': wind_data[-1] if len(wind_data) > 0 else None, 250 | 'barometer': baro_data[-1] if len(baro_data) > 0 else None, 251 | 'partition': partition_data[-1] if len(partition_data) > 0 else None, 252 | 'surfaceTemp': sst_data[-1] if len(sst_data) > 0 else None 253 | } 254 | 255 | return results 256 | 257 | def grab_data(self, limit: int = 20, 258 | start_date: str = None, end_date: str = None, 259 | include_waves: bool = True, include_wind: bool = False, 260 | include_track: bool = False, include_frequency_data: bool = False, 261 | include_directional_moments: bool = False, 262 | include_surface_temp_data: bool = False, 263 | include_spikes: bool = False, 264 | include_barometer_data: bool = False, 265 | include_microphone_data: bool = False, 266 | processing_sources: str = 'embedded', 267 | smooth_wave_data: bool = False, 268 | smooth_sg_window: int = 135, 269 | smooth_sg_order: int = 4, 270 | interpolate_utc: bool = False, 271 | interpolate_period_seconds: int = 3600): 272 | """ 273 | Grabs the requested data for this Spotter based on the given keyword arguments 274 | 275 | :param limit: The limit for data to grab. Defaults to 20, For frequency data max of 100 samples at a time, 276 | else, 500 samples. If you send values over the limit, it will automatically limit for you 277 | :param start_date: ISO 8601 formatted date string. If not included defaults to beginning of Spotter's history 278 | :param end_date: ISO 8601 formatted date string. If not included defaults to end of Spotter history 279 | :param include_waves: Defaults to True. Set to False if you do not want the wave data in the returned response 280 | :param include_wind: Defaults to False. Set to True if you want wind data in the returned response 281 | :param include_track: Defaults to False. Set to True if you want tracking data in the returned response 282 | :param include_frequency_data: Defaults to False. Only applies if the Spotter is in 'Full Waves mode' Set to 283 | True if you want frequency data in the returned response 284 | :param include_directional_moments: Defaults to False. Only applies if the Spotter is in 'Full Waves mode' and 285 | 'include_frequency_data' is True. Set True if you want the frequency data 286 | returned to also include directional moments 287 | :param include_surface_temp_data: Defaults to False. Set to True if your device is a v2 model or newer with the 288 | SST sensor installed 289 | :param include_barometer_data: Defaults to False. Set to True if your device is a v3 model or newer with the 290 | barometer installed 291 | :param include_spikes: Defaults to False. Set to True if you wish to include data points that our system has 292 | identified as a potentially unwanted spike. 293 | :param processing_sources: Optional string for which processingSources to include (embedded, hdr, all) 294 | 295 | :return: Data as a json based on the given query parameters 296 | """ 297 | _query = WaveDataQuery(self.id, limit, start_date, end_date) 298 | _query.waves(include_waves) 299 | _query.wind(include_wind) 300 | _query.track(include_track) 301 | _query.frequency(include_frequency_data) 302 | _query.directional_moments(include_directional_moments) 303 | _query.surface_temp(include_surface_temp_data) 304 | _query.spikes(include_spikes) 305 | _query.processing_sources(processing_sources) 306 | _query.barometer(include_barometer_data) 307 | _query.microphone(include_microphone_data) 308 | _query.smooth_wave_data(smooth_wave_data) 309 | _query.smooth_sg_window(smooth_sg_window) 310 | _query.smooth_sg_order(smooth_sg_order) 311 | _query.interpolate_utc(interpolate_utc) 312 | _query.interpolate_period_seconds(interpolate_period_seconds) 313 | 314 | _data = _query.execute() 315 | 316 | return _data 317 | 318 | def grab_cellular_signal_metrics(self, 319 | limit: int = 20, 320 | order_ascending: bool = False, 321 | start_epoch_ms: int = None, 322 | end_epoch_ms: int = None): 323 | """ 324 | Grabs and returns the cellular signal metrics (if available) for the given Spotter 325 | 326 | :param limit: The limit for data to grab. Defaults to 20. 327 | :param order_ascending: Return results in ascending order? 328 | :param start_epoch_ms: Optional UTC epoch time (integer) at which to begin finding results 329 | :param end_epoch_ms: Optional UTC epoch time (integer) at which to end finding results 330 | 331 | :return: Data as a json based on the given query parameters 332 | """ 333 | 334 | _query = CellularSignalMetricsQuery( 335 | self.id, 336 | limit=limit, 337 | order_ascending=order_ascending, 338 | start_epoch_ms=start_epoch_ms, 339 | end_epoch_ms=end_epoch_ms, 340 | ) 341 | _data = _query.execute() 342 | 343 | return _data 344 | 345 | -------------------------------------------------------------------------------- /src/pysofar/sofar.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of pysofar: A client for interfacing with Sofar Ocean's Spotter API 3 | 4 | Contents: Classes used to connect to the Sofar API and return data 5 | 6 | Copyright 2019-2024 7 | Sofar Ocean Technologies 8 | 9 | Authors: Mike Sosa et al. 10 | """ 11 | from datetime import datetime 12 | from itertools import chain 13 | from multiprocessing.pool import ThreadPool 14 | from pysofar import SofarConnection 15 | from pysofar.tools import parse_date 16 | from pysofar.wavefleet_exceptions import QueryError 17 | from typing import List, Tuple, Dict 18 | 19 | import os 20 | import warnings 21 | 22 | class SofarApi(SofarConnection): 23 | """ 24 | Class for interfacing with the Sofar Wavefleet API 25 | """ 26 | def __init__(self, custom_token=None): 27 | if custom_token is not None: 28 | super().__init__(custom_token) 29 | else: 30 | super().__init__() 31 | 32 | self.devices = [] 33 | self.device_ids = [] 34 | self._sync() 35 | 36 | # ---------------------------------- Simple Device Endpoints -------------------------------------- # 37 | def get_device_location_data(self): 38 | """ 39 | 40 | :return: The most recent locations of all Spotters belonging to this account 41 | """ 42 | return self._device_radius() 43 | 44 | # ---------------------------------- Single Spotter Endpoints -------------------------------------- # 45 | def get_latest_data(self, spotter_id: str, 46 | include_wind_data: bool = False, 47 | include_directional_moments: bool = False, 48 | include_barometer_data: bool = False, 49 | include_partition_data: bool = False, 50 | include_surface_temp_data: bool = False 51 | ): 52 | """ 53 | 54 | :param spotter_id: The string id of the Spotter 55 | :param include_wind_data: Defaults to False. Set to True if you want the latest data to include wind data 56 | :param include_directional_moments: Defaults to False. Only applies if the Spotter is in 'full_waves' mode. 57 | Set to True if you want the latest data to include directional moments 58 | :param include_barometer_data: Defaults to False. Only applies to barometer-equipped Spotters. 59 | :param include_partition_data: Defaulse to False. Only applies to Spotters in Waves:Partition mode. 60 | :param include_surface_temp_data: Defaults to False. Only applies to SST sensor-equipped Spotters. 61 | 62 | :return: The latest data values based on the given parameters from the requested Spotter 63 | """ 64 | params = {'spotterId': spotter_id} 65 | 66 | if include_directional_moments: 67 | params['includeDirectionalMoments'] = 'true' 68 | 69 | if include_wind_data: 70 | params['includeWindData'] = 'true' 71 | 72 | if include_barometer_data: 73 | params['includeBarometerData'] = 'true' 74 | 75 | if include_partition_data: 76 | params['includePartitionData'] = 'true' 77 | 78 | if include_surface_temp_data: 79 | params['includeSurfaceTempData'] = 'true' 80 | 81 | scode, results = self._get('/latest-data', params=params) 82 | 83 | if scode != 200: 84 | raise QueryError(results['message']) 85 | 86 | data = results['data'] 87 | 88 | return data 89 | 90 | def get_sensor_data(self, spotter_id: str, start_date: str, end_date: str): 91 | """ 92 | 93 | :param spotter_id: The string id of the Spotter 94 | :param start_date: ISO8601 formatted start date of the data 95 | :param end_date: ISO8601 formatted end date of the data 96 | 97 | :return: Data as a json from the requested Spotter 98 | """ 99 | 100 | params = { 101 | "spotterId": spotter_id, 102 | "startDate": start_date, 103 | "endDate": end_date 104 | } 105 | 106 | scode, results = self._get('/sensor-data', params=params) 107 | 108 | if scode != 200: 109 | raise QueryError(results['message']) 110 | 111 | data = results['data'] 112 | 113 | return data 114 | 115 | def update_spotter_name(self, spotter_id, new_spotter_name): 116 | """ 117 | Update the name of a Spotter 118 | 119 | :param spotter_id: The string id of the Spotter whose name you want to change 120 | :param new_spotter_name: The new name to give to the requested Spotter 121 | 122 | :return: The new name if the query succeeds else throws an error 123 | """ 124 | body = { 125 | "spotterId": spotter_id, 126 | "name": new_spotter_name 127 | } 128 | 129 | # request name update 130 | scode, response = self._post("change-name", body) 131 | message = response['message'] 132 | 133 | if scode != 200: 134 | raise QueryError(f"{message}") 135 | 136 | print(f"{spotter_id} updated with name: {response['data']['name']}") 137 | 138 | return new_spotter_name 139 | 140 | # ---------------------------------- Multi Spotter Endpoints -------------------------------------- # 141 | def get_wave_data(self, start_date: str = None, end_date: str = None, params: dict = None): 142 | """ 143 | Get all wave data for related Spotters 144 | 145 | :param start_date: ISO8601 start date of data period 146 | :param end_date: ISO8601 end date of data period 147 | :param params: dict of additional query parameters to write beyond default values 148 | 149 | :return: Wave data as a list 150 | """ 151 | return self._get_all_data(['waves'], start_date, end_date, params) 152 | 153 | def get_wind_data(self, start_date: str = None, end_date: str = None, params: dict = None): 154 | """ 155 | Get all wind data for related Spotters 156 | 157 | :param start_date: ISO8601 start date of data period 158 | :param end_date: ISO8601 end date of data period 159 | :param params: dict of additional query parameters to write beyond default values 160 | 161 | :return: Wind data as a list 162 | """ 163 | return self._get_all_data(['wind'], start_date, end_date, params) 164 | 165 | def get_frequency_data(self, start_date: str = None, end_date: str = None, params: dict = None): 166 | """ 167 | Get all Frequency data for related Spotters 168 | 169 | :param start_date: ISO8601 start date of data period 170 | :param end_date: ISO8601 end date of data period 171 | :param params: dict of additional query parameters to write beyond default values 172 | 173 | :return: Frequency data as a list 174 | """ 175 | return self._get_all_data(['frequency'], start_date, end_date, params) 176 | 177 | def get_track_data(self, start_date: str = None, end_date: str = None, params: dict = None): 178 | """ 179 | Get all track data for related Spotters 180 | 181 | :param start_date: ISO8601 start date of data period 182 | :param end_date: ISO8601 end date of data period 183 | :param params: dict of additional query parameters to write beyond default values 184 | 185 | :return: track data as a list 186 | """ 187 | return self._get_all_data(['track'], start_date, end_date, params) 188 | 189 | def get_all_data(self, start_date: str = None, end_date: str = None, params: dict = None): 190 | """ 191 | Get all data for related Spotters 192 | 193 | :param start_date: ISO8601 start date of data period 194 | :param end_date: ISO8601 end date of data period 195 | :param params: dict of additional query parameters to write beyond default values 196 | 197 | :return: Data as a list 198 | """ 199 | return self._get_all_data(['waves', 'wind', 'frequency', 'track'], start_date, end_date, params) 200 | 201 | def get_spotters(self): return get_and_update_spotters(_api=self) 202 | 203 | def search(self, shape:str, shape_params:List[Tuple], start_date:str, end_date:str, 204 | radius=None, page_size=100,return_generator=False): 205 | 206 | if shape not in ('circle','envelope'): 207 | raise TypeError('Shape needs to be one of type Circle or Envelope') 208 | 209 | if page_size > 500: 210 | warnings.warn('Maximum page size is 500') 211 | page_size=500 212 | 213 | if shape == 'circle' and radius is None: 214 | raise ValueError('Radius needs to be set when shape is circle') 215 | 216 | # flatten 217 | if shape == 'envelope': 218 | vertices = [] 219 | for point in shape_params: 220 | vertices += point 221 | elif shape == 'circle': 222 | vertices = shape_params 223 | 224 | params = { 225 | 'shape':shape, 226 | # convert list to a comma seperated string of values. Requests does not 227 | # like iterators as argument. 228 | 'shapeParams':','.join([str(x) for x in vertices]), 229 | 'startDate':start_date, 230 | 'endDate':end_date, 231 | 'pageSize':page_size, 232 | 'radius':radius 233 | } 234 | def get_function(endpoint_suffix,params ): 235 | scode, data = self._get(endpoint_suffix, params=params) 236 | if scode != 200: 237 | raise QueryError(data['message']) 238 | return data 239 | 240 | if return_generator: 241 | return unpaginate(get_function,'search',params) 242 | else: 243 | return list(unpaginate(get_function,'search',params)) 244 | 245 | # ---------------------------------- Helper Functions -------------------------------------- # 246 | @property 247 | def token(self): 248 | return self._token 249 | 250 | @token.setter 251 | def token(self, value): 252 | temp = self.token 253 | self.set_token(value) 254 | 255 | try: 256 | self._sync() 257 | except QueryError: 258 | print('Authentication failed. Please check the key') 259 | print('Reverting to old key') 260 | self.set_token(temp) 261 | 262 | def _sync(self): 263 | self.devices = self._devices() 264 | self.device_ids = [device['spotterId'] for device in self.devices] 265 | 266 | def _devices(self): 267 | # Helper function to access the devices endpoint 268 | scode, data = self._get('/devices') 269 | 270 | if scode != 200: 271 | raise QueryError(data['message']) 272 | 273 | _spotters = data['data']['devices'] 274 | 275 | return _spotters 276 | 277 | def _device_radius(self): 278 | # helper function to access the device radius endpoint 279 | status_code, data = self._get('device-radius') 280 | 281 | if status_code != 200: 282 | raise QueryError(data['message']) 283 | 284 | spot_data = data['data']['devices'] 285 | 286 | return spot_data 287 | 288 | def _get_all_data(self, worker_names: list, start_date: str = None, end_date: str = None, params: dict = None): 289 | # helper function to return another function used for grabbing all data from Spotters in a period 290 | def helper(_name): 291 | _ids = self.device_ids 292 | 293 | # default to bound values if not included 294 | st = start_date or '2000-01-01T00:00:00.000Z' 295 | end = end_date or datetime.utcnow() 296 | 297 | _wrker = worker_wrapper((_name, _ids, st, end, params)) 298 | return _wrker 299 | 300 | # processing the data_types in parallel 301 | pool = ThreadPool(processes=len(worker_names)) 302 | all_data = pool.map(helper, worker_names) 303 | pool.close() 304 | 305 | all_data = {name: l for name, l in zip(worker_names, all_data)} 306 | 307 | # if len(all_data) > 0: 308 | # all_data.sort(key=lambda x: x['timestamp']) 309 | 310 | return all_data 311 | 312 | 313 | class WaveDataQuery(SofarConnection): 314 | """ 315 | General Query class 316 | """ 317 | _MISSING = object() 318 | 319 | def __init__(self, spotter_id: str, limit: int = 20, start_date=_MISSING, end_date=_MISSING, params=None): 320 | """ 321 | Query the Sofar API for Spotter data 322 | 323 | :param spotter_id: String id of the Spotter to query for 324 | :param limit: The limit of data to query. Defaults to 20, max of 100 for frequency data, max of 500 otherwise 325 | :param start_date: ISO8601 formatted string for start date, otherwise if not included, defaults to 326 | a date arbitrarily far back to include all Spotter data 327 | :param end_date: ISO8601 formatted string for end date, otherwise if not included defaults to present 328 | :param params: Defaults to None. Parameters to overwrite/add to the default query parameter set 329 | """ 330 | super().__init__() 331 | self.spotter_id = spotter_id 332 | self._limit = limit 333 | 334 | if start_date is self._MISSING or start_date is None: 335 | self.start_date = None 336 | else: 337 | self.start_date = parse_date(start_date) 338 | 339 | if end_date is self._MISSING or end_date is None: 340 | self.end_date = None 341 | else: 342 | self.end_date = parse_date(end_date) 343 | 344 | self._params = { 345 | 'spotterId': spotter_id, 346 | 'limit': limit, 347 | 'includeWaves': 'true', 348 | 'includeWindData': 'false', 349 | 'includeTrack': 'false', 350 | 'includeFrequencyData': 'false', 351 | 'includeDirectionalMoments': 'false', 352 | 'includeSurfaceTempData': 'false', 353 | 'includeSpikes': 'false', 354 | 'processingSources': 'embedded', 355 | 'includeNonObs': 'false', 356 | 'includeMicrophoneData': 'false', 357 | 'includeBarometerData': 'false' 358 | } 359 | if params is not None: 360 | self._params.update(params) 361 | 362 | if self.start_date is not None: 363 | self._params.update({'startDate': self.start_date}) 364 | 365 | if self.end_date is not None: 366 | self._params.update({'endDate': self.end_date}) 367 | 368 | def execute(self): 369 | """ 370 | Calls the api wave-data endpoint. 371 | If successful, returns the queried data with the set query parameters 372 | 373 | :return: Data as a dictionary 374 | """ 375 | scode, data = self._get('wave-data', params=self._params) 376 | 377 | if scode != 200: 378 | raise QueryError(data['message']) 379 | 380 | return data['data'] 381 | 382 | def limit(self, value: int): 383 | """ 384 | Sets the limit on how many query results to return 385 | 386 | Defaults to 20 387 | Max of 500 if tracking or waves-standard 388 | Max of 100 if frequency data is included 389 | """ 390 | self._limit = value 391 | self._params.update({'limit': value}) 392 | 393 | def barometer(self, include: bool): 394 | """ 395 | 396 | :param include: True if you want the query to include barometer data 397 | """ 398 | self._params.update({'includeBarometerData': str(include).lower()}) 399 | 400 | def microphone(self, include: bool): 401 | """ 402 | 403 | :param include: True if you want the query to include microphone data 404 | """ 405 | self._params.update({'includeMicrophoneData': str(include).lower()}) 406 | 407 | def waves(self, include: bool): 408 | """ 409 | 410 | :param include: True if you want the query to include waves 411 | """ 412 | self._params.update({'includeWaves': str(include).lower()}) 413 | 414 | def wind(self, include: bool): 415 | """ 416 | 417 | :param include: True if you want the query to include wind data 418 | """ 419 | self._params.update({'includeWindData': str(include).lower()}) 420 | 421 | def track(self, include: bool): 422 | """ 423 | 424 | :param include: True if you want the query to include tracking data 425 | """ 426 | self._params.update({'includeTrack': str(include).lower()}) 427 | 428 | def frequency(self, include: bool): 429 | """ 430 | 431 | :param include: True if you want the query to include frequency data 432 | """ 433 | self._params.update({'includeFrequencyData': str(include).lower()}) 434 | 435 | def directional_moments(self, include: bool): 436 | """ 437 | 438 | :param include: True if you want the query to include directional moment data 439 | """ 440 | if include and not self._params['includeFrequencyData']: 441 | print("""Warning: You have currently selected the query to include directional moment data however 442 | frequency data is not currently included. \n 443 | Directional moment data only applies if the Spotter is in full waves/waves spectrum mode. \n 444 | Since the query does not include frequency data (of which directional moments are a subset) 445 | the data you have requested will not be included. \n 446 | Please set includeFrequencyData to true with .frequency(True) if desired. \n""") 447 | self._params.update({'includeDirectionalMoments': str(include).lower()}) 448 | 449 | def surface_temp(self, include: bool): 450 | """ 451 | 452 | :param include: True if you want the query to include surface temp data 453 | """ 454 | self._params.update({'includeSurfaceTempData': str(include).lower()}) 455 | 456 | def spikes(self, include: bool): 457 | """ 458 | 459 | :param include: True if you want the query to include data points exceeding our spike filter 460 | """ 461 | self._params.update({'includeSpikes': str(include).lower()}) 462 | 463 | def processing_sources(self, value: str): 464 | """ 465 | 466 | :param value: string (embedded, hdr, all) to include HDR data (default: embedded only) 467 | """ 468 | self._params.update({'processingSources': str(value).lower()}) 469 | 470 | def smooth_wave_data(self, include: bool): 471 | """ 472 | 473 | :param include: True if you want the query to smooth wave data 474 | """ 475 | self._params.update({'smoothWaveData': str(include).lower()}) 476 | 477 | def smooth_sg_window(self, value: int): 478 | """ 479 | 480 | :param value: Window size of the SG smoothing filter. Must be odd positive int. 481 | """ 482 | self._params.update({'smoothSGWindow': value}) 483 | 484 | def smooth_sg_order(self, value: int): 485 | """ 486 | 487 | :param value: Polynomial order of SG smoothing filter. Positive int > 0. 488 | """ 489 | self._params.update({'smoothSGOrder': value}) 490 | 491 | def interpolate_utc(self, include: bool): 492 | """ 493 | 494 | :param include: True if you want the query to interpolate data to UTC hours time base. 495 | """ 496 | self._params.update({'interpolateUTC': str(include).lower()}) 497 | 498 | def interpolate_period_seconds(self, value: int): 499 | """ 500 | 501 | :param value: Period in seconds of samples after smoothing and/or interpolation. 502 | """ 503 | self._params.update({'interpolatePeriodSeconds': value}) 504 | 505 | def set_start_date(self, new_date: str): 506 | self.start_date = parse_date(new_date) 507 | self._params.update({'startDate': self.start_date}) 508 | 509 | def clear_start_date(self): 510 | self.start_date = None 511 | if 'startDate' in self._params: 512 | del self._params['startDate'] 513 | 514 | def set_end_date(self, new_date: str): 515 | self.end_date = parse_date(new_date) 516 | self._params.update({'endDate': self.end_date}) 517 | 518 | def clear_end_date(self): 519 | if 'endDate' in self._params: 520 | del self._params['endDate'] 521 | 522 | def __str__(self): 523 | s = f"Query for {self.spotter_id} \n" + \ 524 | f" Start: {self.start_date or 'From Beginning'} \n" + \ 525 | f" End: {self.end_date or 'Til Present'} \n" + \ 526 | " Params:\n" + \ 527 | f" id: {self._params['spotterId']}\n" + \ 528 | f" limit: {self._params['limit']} \n" + \ 529 | f" waves: {self._params['includeWaves']} \n" + \ 530 | f" wind: {self._params['includeWindData']} \n" + \ 531 | f" barometer: {self._params['includeBarometerData']} \n" + \ 532 | f" sst: {self._params['includeSurfaceTempData']} \n" + \ 533 | f" microphone: {self._params['includeMicrophoneData']} \n" + \ 534 | f" track: {self._params['includeTrack']} \n" + \ 535 | f" frequency: {self._params['includeFrequencyData']} \n" + \ 536 | f" directional_moments: {self._params['includeDirectionalMoments']} \n" + \ 537 | f" processing_sources: {self._params['processingSources']} \n" 538 | 539 | return s 540 | 541 | class SofarUserRestQuery(SofarConnection): 542 | """ 543 | I represent a query against the /user-rest endpoint 544 | """ 545 | @classmethod 546 | def get_user_rest_endpoint(cls): 547 | _endpoint = os.getenv('WF_USER_REST_URL') 548 | if _endpoint is None: 549 | _endpoint = 'https://api.sofarocean.com/user-rest' 550 | return _endpoint 551 | 552 | def __init__(self, custom_token=None): 553 | super().__init__(custom_token) 554 | self.endpoint = self.get_user_rest_endpoint() 555 | 556 | class CellularSignalMetricsQuery(SofarUserRestQuery): 557 | """ 558 | I represent a query against the cellular-signal-metrics endpoint 559 | """ 560 | _MISSING = object() 561 | 562 | def __init__(self, 563 | spotter_id: str, 564 | limit: int = 20, 565 | order_ascending: bool = False, 566 | start_epoch_ms=_MISSING, 567 | end_epoch_ms=_MISSING, 568 | params=None): 569 | super().__init__() 570 | self.spotter_id = spotter_id 571 | self._limit = limit 572 | self._params = { 573 | 'spotterId' : spotter_id, 574 | 'limit' : limit, 575 | 'order_ascending': str(order_ascending).lower(), 576 | } 577 | if params: 578 | self._params.update(params) 579 | 580 | if start_epoch_ms and start_epoch_ms is not self._MISSING: 581 | self._params.update({'since_epoch_ms': str(start_epoch_ms)}) 582 | 583 | if end_epoch_ms and end_epoch_ms is not self._MISSING: 584 | self._params.update({'before_epoch_ms': str(end_epoch_ms)}) 585 | 586 | def execute(self, return_raw = False): 587 | """ 588 | Calls the cellular-signal-metrics endpoint. 589 | If successful, returns the queried data with the set query parameters. 590 | 591 | if return_raw is True, return the outer metadata structure of the response 592 | 593 | :return: Data as a dictionary 594 | """ 595 | scode, data = self._get(f"devices/{self.spotter_id}/cellular-signal-metrics", params=self._params) 596 | 597 | if scode != 200: 598 | raise QueryError(data['message']) 599 | 600 | return data if return_raw else data['data'] 601 | 602 | # ---------------------------------- Util Functions -------------------------------------- # 603 | def get_and_update_spotters(_api=None): 604 | """ 605 | :return: A list of the Spotter objects associated with this account 606 | """ 607 | from itertools import repeat 608 | 609 | api = _api or SofarApi() 610 | 611 | # grab device id's and query for device data 612 | # initialize Spotter objects 613 | spot_data = api.devices 614 | 615 | pool = ThreadPool(processes=16) 616 | spotters = pool.starmap(_spot_worker, zip(spot_data, repeat(api))) 617 | pool.close() 618 | 619 | return spotters 620 | 621 | 622 | # ---------------------------------- Workers -------------------------------------- # 623 | def _spot_worker(device: dict, api: SofarApi): 624 | """ 625 | Worker to grab Spotter data 626 | 627 | :param device: Dictionary containing the Spotter id and name 628 | 629 | :return: Spotter object updated from the Sofar api with its latest data values 630 | """ 631 | from pysofar.spotter import Spotter 632 | 633 | _id = device['spotterId'] 634 | _name = device['name'] 635 | _api = api 636 | 637 | sptr = Spotter(_id, _name, _api) 638 | sptr.update() 639 | 640 | return sptr 641 | 642 | 643 | def worker_wrapper(args): 644 | """ 645 | Wrapper for creating workers to grab lots of data 646 | 647 | :param args: Tuple of the worker_type: str (ex. 'wind', 'waves', 'frequency', 'track') 648 | _ids: list of str, which are the Spotter ids 649 | st_date: str, iso 8601 formatted start date of period to query 650 | end_date: str, iso 8601 formatted end date of period to query 651 | params: dict, query parameters to set 652 | 653 | :return: All data for that type for all Spotters in the queried period 654 | """ 655 | worker_type, _ids, st_date, end_date, params = args 656 | queries = [WaveDataQuery(_id, limit=500, start_date=st_date, end_date=end_date, params=params) for _id in _ids] 657 | 658 | # grabbing data from all of the Spotters in parallel 659 | pool = ThreadPool(processes=16) 660 | _wrkr = _worker(worker_type) 661 | worker_data = pool.map(_wrkr, queries) 662 | pool.close() 663 | 664 | # unwrap list of lists 665 | worker_data = list(chain(*worker_data)) 666 | 667 | if len(worker_data) > 0: 668 | worker_data.sort(key=lambda x: x['timestamp']) 669 | 670 | return worker_data 671 | 672 | 673 | def _worker(data_type): 674 | """ 675 | Worker to grab data from certain data type for a specific query 676 | 677 | :param data_type: The desired data type 678 | 679 | :return: A helper function able to process a query for that specific data type 680 | """ 681 | def _helper(data_query): 682 | st = data_query.start_date 683 | end = data_query.end_date 684 | 685 | # setup the query 686 | data_query.waves(False) 687 | getattr(data_query, data_type)(True) 688 | 689 | if data_type == 'frequency': 690 | dkey = 'frequencyData' 691 | data_query.directional_moments(True) 692 | elif data_type == 'surface_temp': 693 | dkey = 'surfaceTemp' 694 | data_query.surface_temp(True) 695 | elif data_type == 'barometer': 696 | dkey = 'barometerData' 697 | data_query.barometer(True) 698 | elif data_type == 'microphone': 699 | dkey = 'microphoneData' 700 | data_query.microphone(True) 701 | else: 702 | dkey = data_type 703 | 704 | query_data = [] 705 | 706 | while st < end: 707 | _query = data_query.execute() 708 | 709 | results = _query[dkey] 710 | 711 | for dt in results: 712 | dt.update({'spotterId': _query['spotterId']}) 713 | 714 | query_data.extend(results) 715 | 716 | # break if no results are returned 717 | if len(results) == 0: 718 | break 719 | 720 | st = results[-1]['timestamp'] 721 | data_query.set_start_date(st) 722 | 723 | # break if start and end dates are the same to avoid potential infinite loop for samples 724 | # at end time 725 | if st == end: 726 | break 727 | 728 | # here query data is a list of dictionaries 729 | return query_data 730 | 731 | return _helper 732 | 733 | 734 | def unpaginate( get_function, endpoint_suffix , params )->Dict: 735 | """ 736 | Generator function to unpaginate a paginated request. 737 | 738 | Note: 739 | It is a little ugly now with the removing of the endpoint prefix so that 740 | the _get function can append it again. Right now it looks like the paginated 741 | server returns http instead of https in the url so this may actually be a 742 | good thing. 743 | 744 | :param get_function: the _get fuction that takes an endpoint suffic and params as arguments 745 | :param endpoint_suffix: endpoint to hit from the Sofar Api 746 | :param params: dict of additional query parameters to write beyond default values 747 | 748 | :return: track data as a list 749 | """ 750 | suffix = endpoint_suffix 751 | while True: 752 | page = get_function( suffix, params) 753 | 754 | for item in page['data']: 755 | yield item 756 | 757 | if page['metadata']['page']['hasMoreData']: 758 | url = page['metadata']['page']['nextPage'] 759 | # here we remove the prefix, but keep everything else in the url 760 | # returned by wavefleet. 761 | suffix = endpoint_suffix + url.split(endpoint_suffix)[1] 762 | 763 | # parameters are no longer needed as these are already encoded 764 | # in the given url. 765 | params = None 766 | else: 767 | break 768 | --------------------------------------------------------------------------------