-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support both Digest and API key authentication #25
Changes from all commits
70e4601
200c6b7
e95de5c
9fb566c
cd4c5e0
93932d9
72556eb
b53b9b2
82921a5
02e8c4c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
# PyPrusaLink | ||
|
||
Python library to interact with PrusaLink API v2. | ||
|
||
This fork adds support for Digest authentication - the method Prusa recommends for their resin printers (SL1, SL1S). | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,9 +3,27 @@ | |
|
||
from collections.abc import AsyncGenerator | ||
from contextlib import asynccontextmanager | ||
from typing import TypedDict | ||
|
||
from aiohttp import ClientResponse, ClientSession | ||
from httpx import AsyncClient, DigestAuth, Response | ||
|
||
from .const import ( | ||
API_KEY, | ||
API_KEY_AUTH, | ||
AUTH, | ||
AUTH_TYPE, | ||
DIGEST_AUTH, | ||
HOST, | ||
PASSWORD, | ||
USER, | ||
) | ||
from .types import ( | ||
FileInfo, | ||
FilesInfo, | ||
JobInfo, | ||
LinkConfiguration, | ||
PrinterInfo, | ||
VersionInfo, | ||
) | ||
|
||
|
||
class PrusaLinkError(Exception): | ||
|
@@ -20,57 +38,19 @@ class Conflict(PrusaLinkError): | |
"""Error to indicate the command hit a conflict.""" | ||
|
||
|
||
class VersionInfo(TypedDict): | ||
balloob marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Version data.""" | ||
|
||
api: str | ||
server: str | ||
text: str | ||
hostname: str | ||
|
||
|
||
class PrinterInfo(TypedDict): | ||
"""Printer data.""" | ||
|
||
telemetry: dict | ||
temperature: dict | ||
state: dict | ||
|
||
|
||
class JobInfo(TypedDict): | ||
"""Job data.""" | ||
|
||
state: str | ||
job: dict | None | ||
|
||
|
||
class FileInfo(TypedDict): | ||
"""File data.""" | ||
|
||
name: str | ||
origin: str | ||
size: int | ||
refs: dict | ||
|
||
|
||
class FilesInfo(TypedDict): | ||
"""Files data.""" | ||
|
||
files: list[FileInfo] | ||
|
||
|
||
class PrusaLink: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to split this into multiple classes, one for each version of the API? We can put the request function into a single base class. |
||
"""Wrapper for the Prusalink API. | ||
|
||
Data format can be found here: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update this doc string to also include other APIs |
||
https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/master/lib/WUI/link_content/basic_gets.cpp | ||
""" | ||
|
||
def __init__(self, session: ClientSession, host: str, api_key: str) -> None: | ||
def __init__(self, client: AsyncClient, config: LinkConfiguration) -> None: | ||
"""Initialize the PrusaLink class.""" | ||
self._session = session | ||
self.host = host | ||
self._api_key = api_key | ||
|
||
self.host = config[HOST] | ||
self.auth = config[AUTH] | ||
self.client = client | ||
|
||
async def cancel_job(self) -> None: | ||
"""Cancel the current job.""" | ||
|
@@ -94,53 +74,65 @@ async def pause_job(self) -> None: | |
async def get_version(self) -> VersionInfo: | ||
"""Get the version.""" | ||
async with self.request("GET", "api/version") as response: | ||
return await response.json() | ||
return response.json() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is very wrong, you're doing I/O in the event loop and hating Home Assistant. Use the async version of httpx. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or is httpx reading the whole response right away? |
||
|
||
async def get_printer(self) -> PrinterInfo: | ||
"""Get the printer.""" | ||
async with self.request("GET", "api/printer") as response: | ||
return await response.json() | ||
return response.json() | ||
|
||
async def get_job(self) -> JobInfo: | ||
"""Get current job.""" | ||
async with self.request("GET", "api/job") as response: | ||
return await response.json() | ||
return response.json() | ||
|
||
async def get_file(self, path: str) -> FileInfo: | ||
"""Get specific file info.""" | ||
async with self.request("GET", f"api/files{path}") as response: | ||
return await response.json() | ||
return response.json() | ||
|
||
async def get_files(self) -> FilesInfo: | ||
"""Get all files.""" | ||
async with self.request("GET", "api/files?recursive=true") as response: | ||
return await response.json() | ||
return response.json() | ||
|
||
async def get_small_thumbnail(self, path: str) -> bytes: | ||
"""Get a small thumbnail.""" | ||
async with self.request("GET", f"thumb/s{path}") as response: | ||
return await response.read() | ||
return response.read() | ||
|
||
async def get_large_thumbnail(self, path: str) -> bytes: | ||
"""Get a large thumbnail.""" | ||
async with self.request("GET", f"thumb/l{path}") as response: | ||
return await response.read() | ||
return response.read() | ||
|
||
@asynccontextmanager | ||
async def request( | ||
self, method: str, path: str, json: dict | None = None | ||
) -> AsyncGenerator[ClientResponse, None]: | ||
) -> AsyncGenerator[Response, None]: | ||
"""Make a request to the PrusaLink API.""" | ||
url = f"{self.host}/{path}" | ||
headers = {"X-Api-Key": self._api_key} | ||
|
||
async with self._session.request( | ||
method, url, headers=headers, json=json | ||
) as response: | ||
if response.status == 401: | ||
client = self.client | ||
|
||
async with client: | ||
if self.auth[AUTH_TYPE] == DIGEST_AUTH: | ||
auth = DigestAuth(self.auth[USER], self.auth[PASSWORD]) | ||
response = await client.request(method, url, json=json, auth=auth) | ||
|
||
elif self.auth[AUTH_TYPE] == API_KEY_AUTH: | ||
headers = {"X-Api-Key": self.auth[API_KEY]} | ||
response = await client.request( | ||
method, | ||
url, | ||
json=json, | ||
headers=headers, | ||
) | ||
|
||
if response.status_code == 401: | ||
raise InvalidAuth() | ||
if response.status == 409: | ||
elif response.status_code == 409: | ||
raise Conflict() | ||
|
||
response.raise_for_status() | ||
|
||
yield response |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
API_KEY = "apiKey" | ||
API_KEY_AUTH = "ApiKeyAuth" | ||
AUTH = "auth" | ||
AUTH_TYPE = "authType" | ||
DIGEST_AUTH = "DigestAuth" | ||
HOST = "host" | ||
PASSWORD = "password" | ||
USER = "user" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
from typing import TypedDict | ||
|
||
from .const import API_KEY_AUTH, DIGEST_AUTH | ||
|
||
|
||
class VersionInfo(TypedDict): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since there are multiple versions of the API that we all want to support, we should probably include a version number in the type names. |
||
"""Version data.""" | ||
|
||
api: str | ||
server: str | ||
text: str | ||
hostname: str | ||
|
||
|
||
class PrinterInfo(TypedDict): | ||
"""Printer data.""" | ||
|
||
telemetry: dict | ||
temperature: dict | ||
state: dict | ||
|
||
|
||
class JobInfo(TypedDict): | ||
"""Job data.""" | ||
|
||
state: str | ||
job: dict | None | ||
|
||
|
||
class FileInfo(TypedDict): | ||
"""File data.""" | ||
|
||
name: str | ||
origin: str | ||
size: int | ||
refs: dict | ||
|
||
|
||
class FilesInfo(TypedDict): | ||
"""Files data.""" | ||
|
||
files: list[FileInfo] | ||
|
||
|
||
class UserAuth(TypedDict): | ||
"""Authentication via `user` + `password` digest""" | ||
|
||
type: DIGEST_AUTH | ||
user: str | ||
password: str | ||
|
||
|
||
class ApiKeyAuth(TypedDict): | ||
"""Authentication via api key""" | ||
|
||
type: API_KEY_AUTH | ||
apiKey: str | ||
|
||
|
||
class LinkConfiguration(TypedDict): | ||
"""Configuration shape for PrusaLink""" | ||
|
||
host: str | ||
auth: UserAuth | ApiKeyAuth |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
{ pkgs ? import <nixpkgs> {} }: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should not include nix configuration in the package. |
||
|
||
pkgs.mkShell { | ||
packages = [ | ||
(pkgs.python3.withPackages (ps: [ | ||
ps.httpx | ||
ps.isort | ||
ps.flake8 | ||
ps.black | ||
])) | ||
]; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.