pax_global_header00006660000000000000000000000064146633631740014527gustar00rootroot0000000000000052 comment=92c40d54fd06488e0a8bff613d8528428435b555 pypoint-3.0.1/000077500000000000000000000000001466336317400132325ustar00rootroot00000000000000pypoint-3.0.1/.bumpversion.cfg000066400000000000000000000001261466336317400163410ustar00rootroot00000000000000[bumpversion] current_version = 3.0.1 commit = True tag = True files = pyproject.toml pypoint-3.0.1/.github/000077500000000000000000000000001466336317400145725ustar00rootroot00000000000000pypoint-3.0.1/.github/workflows/000077500000000000000000000000001466336317400166275ustar00rootroot00000000000000pypoint-3.0.1/.github/workflows/codeql-analysis.yml000066400000000000000000000050321466336317400224420ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. name: "CodeQL" on: push: branches: [master] pull_request: # The branches below must be a subset of the branches above branches: [master] schedule: - cron: '0 22 * * 5' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: # Override automatic language detection by changing the below list # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] language: ['python'] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository uses: actions/checkout@v2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 # If this run was triggered by a pull request event, then checkout # the head of the pull request instead of the merge commit. - run: git checkout HEAD^2 if: ${{ github.event_name == 'pull_request' }} # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 pypoint-3.0.1/.github/workflows/release.yml000066400000000000000000000074661466336317400210070ustar00rootroot00000000000000name: Create new release on: workflow_dispatch: inputs: part: description: 'Part to version-bump' required: true default: 'patch' type: choice options: - patch - minor - major env: PROJECT: pypoint jobs: bump_version: name: Bump Version runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.current_version }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version-file: 'pyproject.toml' # Read python version from a file pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip pip install bump2version --user - name: Configure Git run: | git config user.name "{{ $github.actor }}" git config user.email '{{ $github.actor }}@users.noreply.github.com' - name: Bump Version id: version run: | bump2version ${{ inputs.part }} # Sets current_version=v1.2.3 grep current_version .bumpversion.cfg| sed -e 's/ //g' -e 's/=/=v/' >> $GITHUB_OUTPUT - name: Commit version change to master run: | git push --follow-tags build: name: Build distribution đŸ“Ļ needs: - bump_version outputs: version: ${{ needs.bump_version.outputs.version }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version-file: 'pyproject.toml' # Read python version from a file pyproject.toml - name: Install dependencies run: | pip install build --user - name: Build a binary wheel and a source tarball run: | python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ github-release: name: >- Sign the Python 🐍 distribution đŸ“Ļ with Sigstore and upload them to GitHub Release needs: - build runs-on: ubuntu-latest permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases id-token: write # IMPORTANT: mandatory for sigstore steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v2.1.1 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Create GitHub Release env: GITHUB_TOKEN: ${{ github.token }} CURRENT_VERSION: ${{ needs.build.outputs.version }} run: >- gh release create "$CURRENT_VERSION" --repo '${{ github.repository }}' --generate-notes - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} CURRENT_VERSION: ${{ needs.build.outputs.version }} # Upload to GitHub Release using the `gh` CLI. # `dist/` contains the built packages, and the # sigstore-produced signatures and certificates. run: >- gh release upload "$CURRENT_VERSION" dist/** --repo '${{ github.repository }}' publish-to-testpypi: name: Publish Python 🐍 distribution đŸ“Ļ to PyPI needs: - build runs-on: ubuntu-latest environment: name: testpypi url: https://pypi.org/p/{{ $env.PROJECT }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish distribution đŸ“Ļ to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 pypoint-3.0.1/.gitignore000066400000000000000000000023051466336317400152220ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ #vscode .vscode/ pypoint-3.0.1/.pre-commit-config.yaml000066400000000000000000000006461466336317400175210ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.6.2 hooks: # Run the linter. - id: ruff args: [ --fix ] # Run the formatter. - id: ruff-format - repo: https://github.com/pycqa/pylint rev: v3.2.6 hooks: - id: pylint additional_dependencies: - aiohttp exclude: 'tests/' args: - --ignore=setup.py pypoint-3.0.1/.pylintrc000066400000000000000000000014511466336317400151000ustar00rootroot00000000000000 [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". pypoint-3.0.1/LICENSE000066400000000000000000000020631466336317400142400ustar00rootroot00000000000000MIT License Copyright (c) 2018 Fredrik Erlandsson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pypoint-3.0.1/Makefile000066400000000000000000000010401466336317400146650ustar00rootroot00000000000000ifndef version version = patch endif .PHONY: default format white black lint test check clean pypireg pypi release default: check check: pre-commit run --all clean: rm -f *.pyc rm -rf .tox rm -rf *.egg-info rm -rf __pycache__ pypoint/__pycache__ rm -f pip-selfcheck.json rm -rf pytype_output pypireg: python setup.py register -r pypi pypi: rm -f dist/*.tar.gz python3 setup.py sdist twine upload dist/*.tar.gz release: git diff-index --quiet HEAD -- make check bumpversion $(version) git push --tags git push make pypi pypoint-3.0.1/README.md000066400000000000000000000010651466336317400145130ustar00rootroot00000000000000# PyPoint Python package for the Minut Point ![licence](https://img.shields.io/pypi/l/pypoint) ![pypi-version](https://img.shields.io/pypi/v/pypoint) ![pypi-dl](https://img.shields.io/pypi/dw/pypoint) Used by [Home Assistant](https://www.home-assistant.io/integrations/point). The `pyPoint` library enables Python integration with the Minut Point. To connect with Point, you will have to sign up for a developer account and get a client_id and client_secret with the callback url configured as your `base_url + /api/minut`, e.g. http://localhost:8123/api/minut. pypoint-3.0.1/pypoint/000077500000000000000000000000001466336317400147345ustar00rootroot00000000000000pypoint-3.0.1/pypoint/__init__.py000066400000000000000000000201541466336317400170470ustar00rootroot00000000000000"""Minut Point API.""" import logging from threading import RLock from aiohttp import ClientResponse from aiohttp.client_exceptions import ClientResponseError from .auth import AbstractAuth from .const import ( EVENTS, MAP_SENSORS, MINUT_DEVICES_URL, MINUT_HOMES_URL, MINUT_USER_URL, MINUT_WEBHOOKS_URL, ) _LOGGER = logging.getLogger(__name__) class PointSession: # pylint: disable=too-many-instance-attributes """Point Session class used by the devices.""" def __init__( self, auth: AbstractAuth, ) -> None: """Initialize the Minut Point Session.""" self.auth = auth self._user = None self._webhook = {} self._device_state = {} self._homes = {} self._lock = RLock() async def _request_devices(self, url, _type): """Request list of devices.""" res = await self.auth.request(url) return res.get(_type) if res else {} async def read_sensor(self, device_id, sensor_uri): """Return sensor value based on sensor_uri.""" sensor_uri = MAP_SENSORS.get(sensor_uri, sensor_uri) if device_id in self._device_state and sensor_uri in self._device_state[ device_id ].get("latest_sensor_values", {}): _LOGGER.debug( "Cached sensor value for %s: %s", sensor_uri, self._device_state[device_id]["latest_sensor_values"][sensor_uri], ) return self._device_state[device_id]["latest_sensor_values"][sensor_uri][ "value" ] url = MINUT_DEVICES_URL + f"/{device_id}/{sensor_uri}" res = await self.auth.request(url, request_type="GET", data={"limit": 1}) if not res or not res.get("values"): return None return res.get("values")[-1].get("value") async def user(self): """Update and returns the user data.""" return await self.auth.request(MINUT_USER_URL) async def _register_webhook(self, webhook_url, events): """Register webhook.""" response = await self.auth.request( MINUT_WEBHOOKS_URL, request_type="POST", json={ "url": webhook_url, "events": events, }, ) return response async def remove_webhook(self): """Remove webhook.""" if self._webhook and self._webhook.get("hook_id"): await self.auth.request( f"{MINUT_WEBHOOKS_URL}/{self._webhook['hook_id']}", request_type="DELETE", ) async def update_webhook( self, webhook_url, webhook_id, events=None ) -> ClientResponse | None: """Register webhook (if it doesn't exit).""" hooks = (await self.auth.request(MINUT_WEBHOOKS_URL, request_type="GET"))[ "hooks" ] try: self._webhook = next(hook for hook in hooks if hook["url"] == webhook_url) _LOGGER.debug("Webhook: %s, %s", self._webhook, webhook_id) except StopIteration: # Not found if events is None: events = [e for v in EVENTS.values() for e in v if e] try: self._webhook = await self._register_webhook(webhook_url, events) _LOGGER.debug("Registered hook: %s", self._webhook) return self._webhook except ClientResponseError: return None @property def webhook(self): """Return the webhook id and secret.""" return self._webhook.get("hook_id") async def update(self): """Update all devices from server.""" with self._lock: devices = await self._request_devices(MINUT_DEVICES_URL, "devices") if devices: self._device_state = {device["device_id"]: device for device in devices} _LOGGER.debug( "Found devices: %s", [ {k: self._device_state[k]["description"]} for k in self._device_state ], ) homes = await self._request_devices(MINUT_HOMES_URL, "homes") if homes: self._homes = homes _LOGGER.debug( "Found homes: %s", [{home["home_id"]: home["name"]} for home in self._homes], ) return devices @property def homes(self): """Return all known homes.""" return { home["home_id"]: home for home in self._homes if "alarm_status" in home.keys() } async def _set_alarm(self, status, home_id): """Set alarm satus.""" response = await self.auth.request( f"{MINUT_HOMES_URL}/{home_id}", request_type="PUT", json={"alarm_status": status}, ) return response.get("alarm_status", "") == status async def alarm_arm(self, home_id): """Arm alarm.""" return await self._set_alarm("on", home_id) async def alarm_disarm(self, home_id): """Disarm alarm.""" return await self._set_alarm("off", home_id) @property def devices(self): """Request representations of all devices.""" return (self.device(device_id) for device_id in self.device_ids) def device(self, device_id): """Return a device object.""" if len(device_id) == 1: raise Exception("ERR FER") # pylint: disable=broad-exception-raised return Device(self, device_id) @property def device_ids(self): """List of known device ids.""" with self._lock: return self._device_state.keys() def device_raw(self, device_id): """Return the raw representaion of a device.""" with self._lock: return self._device_state.get(device_id) class Device: """Point device.""" def __init__(self, session, device_id): """Initialize the Minut Point Device object.""" self._session = session self._device_id = device_id def __str__(self): """Representaion of device.""" return f"Device #{self.device_id} {self.name or ''}" async def sensor(self, sensor_type): """Update and return sensor value.""" _LOGGER.debug("Reading %s sensor.", sensor_type) return await self._session.read_sensor(self.device_id, sensor_type) @property def device(self): """Return the raw representation of the device.""" return self._session.device_raw(self.device_id) @property def ongoing_events(self): """Return ongoing events of device.""" return self.device["ongoing_events"] @property def device_id(self): """Id of device.""" return self._device_id @property def last_update(self): """Last update from device.""" return self.device["last_heard_from_at"] @property def name(self): """Name of device.""" return self.device.get("description") @property def battery_level(self): """Battery level of device.""" return self.device["battery"]["percent"] @property def device_info(self): """Info about device.""" return { "connections": {("mac", self.device["device_mac"])}, "identifieres": self.device["device_id"], "manufacturer": "Minut", "model": f"Point v{self.device['hardware_version']}", "name": self.device["description"], "sw_version": self.device["firmware"]["installed"], } @property def device_status(self): """Status of device.""" return { "active": self.device["active"], "offline": self.device["offline"], "last_update": self.last_update, "battery_level": self.battery_level, } @property def webhook(self): """Return the webhook id and secret.""" return self._session.webhook async def remove_webhook(self): """Remove the session webhook.""" return await self._session.remove_webhook() pypoint-3.0.1/pypoint/auth.py000066400000000000000000000035311466336317400162510ustar00rootroot00000000000000"""Abstract class to make authenticated requests.""" from abc import ABC, abstractmethod import logging from aiohttp import ClientResponse, ClientSession from aiohttp.client_exceptions import ClientConnectionError from .const import TIMEOUT _LOGGER = logging.getLogger(__name__) class AbstractAuth(ABC): """Abstract class to make authenticated requests.""" def __init__(self, websession: ClientSession): """Initialize the auth.""" self.websession = websession @abstractmethod async def async_get_access_token(self) -> str: """Return a valid access token.""" async def request(self, url, request_type="GET", **kwargs) -> ClientResponse: """Send a request to the Minut Point API.""" headers = kwargs.get("headers") if headers is None: headers = {} else: headers = dict(headers) access_token = await self.async_get_access_token() headers["authorization"] = f"Bearer {access_token}" try: _LOGGER.debug("Request %s %s %s", url, kwargs, headers) response = await self.websession.request( request_type, url, **kwargs, timeout=TIMEOUT.seconds, headers=headers ) response.raise_for_status() resp = await response.json() _LOGGER.log( logging.NOTSET, "Response %s %s %s", response.status, response.headers["content-type"], resp.get("values")[-1] if kwargs.get("data") and resp.get("values") else response.text, ) if "error" in resp: _LOGGER.error("Error for url: %s, %s", url, resp["error"]) return resp except ClientConnectionError as error: _LOGGER.error("Client issue: %s", error) pypoint-3.0.1/pypoint/const.py000066400000000000000000000042401466336317400164340ustar00rootroot00000000000000"""Constants for Minut Point.""" from datetime import timedelta MINUT_API_URL = "https://api.minut.com/v8/" MINUT_AUTH_URL = MINUT_API_URL + "oauth/authorize" MINUT_DEVICES_URL = MINUT_API_URL + "devices" MINUT_USER_URL = MINUT_API_URL + "users/me" MINUT_TOKEN_URL = MINUT_API_URL + "oauth/token" MINUT_WEBHOOKS_URL = MINUT_API_URL + "webhooks" MINUT_HOMES_URL = MINUT_API_URL + "homes" MAP_SENSORS = { "sound_pressure": "sound", } TIMEOUT = timedelta(seconds=10) EVENTS = { "alarm": ( # On means alarm sound was recognised, Off means normal "alarm_heard", "alarm_silenced", ), "battery": ("battery_low", ""), # On means low, Off means normal "button_press": ( # On means the button was pressed, Off means normal "short_button_press", "", ), "cold": ( # On means cold, Off means normal "temperature_low", "temperature_risen_normal", ), "connectivity": ( # On means connected, Off means disconnected "device_online", "device_offline", ), "dry": ( # On means too dry, Off means normal "humidity_low", "humidity_risen_normal", ), "glass": ("glassbreak", ""), # The sound of glass break was detected "heat": ( # On means hot, Off means normal "temperature_high", "temperature_dropped_normal", ), "moisture": ( # On means wet, Off means dry "humidity_high", "humidity_dropped_normal", ), "motion": ( # On means motion detected, Off means no motion (clear) "pir_motion", "", ), "noise": ( "disturbance_first_notice", # The first alert of the noise monitoring "disturbance_ended", # Created when the noise levels have gone back to normal ), "sound": ( # On means sound detected, Off means no sound (clear) "avg_sound_high", "sound_level_dropped_normal", ), "tamper_old": ("tamper", ""), # On means the point was removed or attached "tamper": ( "tamper_removed", # Minut was mounted on the mounting plate (newer devices only) "tamper_mounted", # Minute was removed from the mounting plate (newer devices only) ), } pypoint-3.0.1/pyproject.toml000066400000000000000000000010221466336317400161410ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] description = "API for Minut Point" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = ["aiohttp"] name = "pypoint" version = "3.0.1" requires-python = ">= 3.11" readme = "README.md" authors = [{ name = "Fredrik Erlandsson", email = "fredrik.e@gmail.com" }] [project.urls] Repository = "https://github.com/fredrike/pypoint" pypoint-3.0.1/requirements.txt000066400000000000000000000000101466336317400165050ustar00rootroot00000000000000aiohttp