first commit
This commit is contained in:
commit
41c244e903
105 changed files with 26962 additions and 0 deletions
168
.gitignore
vendored
Normal file
168
.gitignore
vendored
Normal file
|
@ -0,0 +1,168 @@
|
|||
# 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/
|
||||
share/python-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/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# 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/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
674
LICENSE
Normal file
674
LICENSE
Normal file
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
924
bleak/__init__.py
Normal file
924
bleak/__init__.py
Normal file
|
@ -0,0 +1,924 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Top-level package for bleak."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__author__ = """Henrik Blidh"""
|
||||
__email__ = "henrik.blidh@gmail.com"
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from types import TracebackType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
AsyncGenerator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypedDict,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
from warnings import warn
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
from typing_extensions import Buffer
|
||||
else:
|
||||
from collections.abc import Buffer
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from async_timeout import timeout as async_timeout
|
||||
from typing_extensions import Unpack
|
||||
else:
|
||||
from asyncio import timeout as async_timeout
|
||||
from typing import Unpack
|
||||
|
||||
from .backends.characteristic import BleakGATTCharacteristic
|
||||
from .backends.client import BaseBleakClient, get_platform_client_backend_type
|
||||
from .backends.device import BLEDevice
|
||||
from .backends.scanner import (
|
||||
AdvertisementData,
|
||||
AdvertisementDataCallback,
|
||||
AdvertisementDataFilter,
|
||||
BaseBleakScanner,
|
||||
get_platform_scanner_backend_type,
|
||||
)
|
||||
from .backends.service import BleakGATTServiceCollection
|
||||
from .exc import BleakCharacteristicNotFoundError, BleakError
|
||||
from .uuids import normalize_uuid_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .backends.bluezdbus.scanner import BlueZScannerArgs
|
||||
from .backends.corebluetooth.scanner import CBScannerArgs
|
||||
from .backends.winrt.client import WinRTClientArgs
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.addHandler(logging.NullHandler())
|
||||
if bool(os.environ.get("BLEAK_LOGGING", False)):
|
||||
FORMAT = "%(asctime)-15s %(name)-8s %(threadName)s %(levelname)s: %(message)s"
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.setFormatter(logging.Formatter(fmt=FORMAT))
|
||||
_logger.addHandler(handler)
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
# prevent tasks from being garbage collected
|
||||
_background_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
|
||||
class BleakScanner:
|
||||
"""
|
||||
Interface for Bleak Bluetooth LE Scanners.
|
||||
|
||||
The scanner will listen for BLE advertisements, optionally filtering on advertised services or
|
||||
other conditions, and collect a list of :class:`BLEDevice` objects. These can subsequently be used to
|
||||
connect to the corresponding BLE server.
|
||||
|
||||
A :class:`BleakScanner` can be used as an asynchronous context manager in which case it automatically
|
||||
starts and stops scanning.
|
||||
|
||||
Args:
|
||||
detection_callback:
|
||||
Optional function that will be called each time a device is
|
||||
discovered or advertising data has changed.
|
||||
service_uuids:
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received. Required on
|
||||
macOS >= 12.0, < 12.3 (unless you create an app with ``py2app``).
|
||||
scanning_mode:
|
||||
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
|
||||
Passive scanning is not supported on macOS! Will raise
|
||||
:class:`BleakError` if set to ``"passive"`` on macOS.
|
||||
bluez:
|
||||
Dictionary of arguments specific to the BlueZ backend.
|
||||
cb:
|
||||
Dictionary of arguments specific to the CoreBluetooth backend.
|
||||
backend:
|
||||
Used to override the automatically selected backend (i.e. for a
|
||||
custom backend).
|
||||
**kwargs:
|
||||
Additional args for backwards compatibility.
|
||||
|
||||
.. tip:: The first received advertisement in ``detection_callback`` may or
|
||||
may not include scan response data if the remote device supports it.
|
||||
Be sure to take this into account when handing the callback. For example,
|
||||
the scan response often contains the local name of the device so if you
|
||||
are matching a device based on other data but want to display the local
|
||||
name to the user, be sure to wait for ``adv_data.local_name is not None``.
|
||||
|
||||
.. versionchanged:: 0.15
|
||||
``detection_callback``, ``service_uuids`` and ``scanning_mode`` are no longer keyword-only.
|
||||
Added ``bluez`` parameter.
|
||||
|
||||
.. versionchanged:: 0.18
|
||||
No longer is alias for backend type and no longer inherits from :class:`BaseBleakScanner`.
|
||||
Added ``backend`` parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detection_callback: Optional[AdvertisementDataCallback] = None,
|
||||
service_uuids: Optional[List[str]] = None,
|
||||
scanning_mode: Literal["active", "passive"] = "active",
|
||||
*,
|
||||
bluez: BlueZScannerArgs = {},
|
||||
cb: CBScannerArgs = {},
|
||||
backend: Optional[Type[BaseBleakScanner]] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
PlatformBleakScanner = (
|
||||
get_platform_scanner_backend_type() if backend is None else backend
|
||||
)
|
||||
|
||||
self._backend = PlatformBleakScanner(
|
||||
detection_callback,
|
||||
service_uuids,
|
||||
scanning_mode,
|
||||
bluez=bluez,
|
||||
cb=cb,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> BleakScanner:
|
||||
await self._backend.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Type[BaseException],
|
||||
exc_val: BaseException,
|
||||
exc_tb: TracebackType,
|
||||
) -> None:
|
||||
await self._backend.stop()
|
||||
|
||||
def register_detection_callback(
|
||||
self, callback: Optional[AdvertisementDataCallback]
|
||||
) -> None:
|
||||
"""
|
||||
Register a callback that is called when a device is discovered or has a property changed.
|
||||
|
||||
.. deprecated:: 0.17.0
|
||||
This method will be removed in a future version of Bleak. Pass
|
||||
the callback directly to the :class:`BleakScanner` constructor instead.
|
||||
|
||||
Args:
|
||||
callback: A function, coroutine or ``None``.
|
||||
|
||||
|
||||
"""
|
||||
warn(
|
||||
"This method will be removed in a future version of Bleak. Use the detection_callback of the BleakScanner constructor instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
try:
|
||||
unregister = getattr(self, "_unregister_")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
unregister()
|
||||
|
||||
if callback is not None:
|
||||
unregister = self._backend.register_detection_callback(callback)
|
||||
setattr(self, "_unregister_", unregister)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start scanning for devices"""
|
||||
await self._backend.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop scanning for devices"""
|
||||
await self._backend.stop()
|
||||
|
||||
def set_scanning_filter(self, **kwargs) -> None:
|
||||
"""
|
||||
Set scanning filter for the BleakScanner.
|
||||
|
||||
.. deprecated:: 0.17.0
|
||||
This method will be removed in a future version of Bleak. Pass
|
||||
arguments directly to the :class:`BleakScanner` constructor instead.
|
||||
|
||||
Args:
|
||||
**kwargs: The filter details.
|
||||
|
||||
"""
|
||||
warn(
|
||||
"This method will be removed in a future version of Bleak. Use BleakScanner constructor args instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._backend.set_scanning_filter(**kwargs)
|
||||
|
||||
async def advertisement_data(
|
||||
self,
|
||||
) -> AsyncGenerator[Tuple[BLEDevice, AdvertisementData], None]:
|
||||
"""
|
||||
Yields devices and associated advertising data packets as they are discovered.
|
||||
|
||||
.. note::
|
||||
Ensure that scanning is started before calling this method.
|
||||
|
||||
Returns:
|
||||
An async iterator that yields tuples (:class:`BLEDevice`, :class:`AdvertisementData`).
|
||||
|
||||
.. versionadded:: 0.21
|
||||
"""
|
||||
devices = asyncio.Queue()
|
||||
|
||||
unregister_callback = self._backend.register_detection_callback(
|
||||
lambda bd, ad: devices.put_nowait((bd, ad))
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
yield await devices.get()
|
||||
finally:
|
||||
unregister_callback()
|
||||
|
||||
class ExtraArgs(TypedDict, total=False):
|
||||
"""
|
||||
Keyword args from :class:`~bleak.BleakScanner` that can be passed to
|
||||
other convenience methods.
|
||||
"""
|
||||
|
||||
service_uuids: List[str]
|
||||
"""
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received. Required on
|
||||
macOS >= 12.0, < 12.3 (unless you create an app with ``py2app``).
|
||||
"""
|
||||
scanning_mode: Literal["active", "passive"]
|
||||
"""
|
||||
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
|
||||
Passive scanning is not supported on macOS! Will raise
|
||||
:class:`BleakError` if set to ``"passive"`` on macOS.
|
||||
"""
|
||||
bluez: BlueZScannerArgs
|
||||
"""
|
||||
Dictionary of arguments specific to the BlueZ backend.
|
||||
"""
|
||||
cb: CBScannerArgs
|
||||
"""
|
||||
Dictionary of arguments specific to the CoreBluetooth backend.
|
||||
"""
|
||||
backend: Type[BaseBleakScanner]
|
||||
"""
|
||||
Used to override the automatically selected backend (i.e. for a
|
||||
custom backend).
|
||||
"""
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def discover(
|
||||
cls, timeout: float = 5.0, *, return_adv: Literal[False] = False, **kwargs
|
||||
) -> List[BLEDevice]: ...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
async def discover(
|
||||
cls, timeout: float = 5.0, *, return_adv: Literal[True], **kwargs
|
||||
) -> Dict[str, Tuple[BLEDevice, AdvertisementData]]: ...
|
||||
|
||||
@classmethod
|
||||
async def discover(
|
||||
cls, timeout=5.0, *, return_adv=False, **kwargs: Unpack[ExtraArgs]
|
||||
):
|
||||
"""
|
||||
Scan continuously for ``timeout`` seconds and return discovered devices.
|
||||
|
||||
Args:
|
||||
timeout:
|
||||
Time, in seconds, to scan for.
|
||||
return_adv:
|
||||
If ``True``, the return value will include advertising data.
|
||||
**kwargs:
|
||||
Additional arguments will be passed to the :class:`BleakScanner`
|
||||
constructor.
|
||||
|
||||
Returns:
|
||||
The value of :attr:`discovered_devices_and_advertisement_data` if
|
||||
``return_adv`` is ``True``, otherwise the value of :attr:`discovered_devices`.
|
||||
|
||||
.. versionchanged:: 0.19
|
||||
Added ``return_adv`` parameter.
|
||||
"""
|
||||
async with cls(**kwargs) as scanner:
|
||||
await asyncio.sleep(timeout)
|
||||
|
||||
if return_adv:
|
||||
return scanner.discovered_devices_and_advertisement_data
|
||||
|
||||
return scanner.discovered_devices
|
||||
|
||||
@property
|
||||
def discovered_devices(self) -> List[BLEDevice]:
|
||||
"""
|
||||
Gets list of the devices that the scanner has discovered during the scanning.
|
||||
|
||||
If you also need advertisement data, use :attr:`discovered_devices_and_advertisement_data` instead.
|
||||
"""
|
||||
return [d for d, _ in self._backend.seen_devices.values()]
|
||||
|
||||
@property
|
||||
def discovered_devices_and_advertisement_data(
|
||||
self,
|
||||
) -> Dict[str, Tuple[BLEDevice, AdvertisementData]]:
|
||||
"""
|
||||
Gets a map of device address to tuples of devices and the most recently
|
||||
received advertisement data for that device.
|
||||
|
||||
The address keys are useful to compare the discovered devices to a set
|
||||
of known devices. If you don't need to do that, consider using
|
||||
``discovered_devices_and_advertisement_data.values()`` to just get the
|
||||
values instead.
|
||||
|
||||
.. versionadded:: 0.19
|
||||
"""
|
||||
return self._backend.seen_devices
|
||||
|
||||
async def get_discovered_devices(self) -> List[BLEDevice]:
|
||||
"""Gets the devices registered by the BleakScanner.
|
||||
|
||||
.. deprecated:: 0.11.0
|
||||
This method will be removed in a future version of Bleak. Use the
|
||||
:attr:`.discovered_devices` property instead.
|
||||
|
||||
Returns:
|
||||
A list of the devices that the scanner has discovered during the scanning.
|
||||
|
||||
"""
|
||||
warn(
|
||||
"This method will be removed in a future version of Bleak. Use the `discovered_devices` property instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.discovered_devices
|
||||
|
||||
@classmethod
|
||||
async def find_device_by_address(
|
||||
cls, device_identifier: str, timeout: float = 10.0, **kwargs: Unpack[ExtraArgs]
|
||||
) -> Optional[BLEDevice]:
|
||||
"""Obtain a ``BLEDevice`` for a BLE server specified by Bluetooth address or (macOS) UUID address.
|
||||
|
||||
Args:
|
||||
device_identifier: The Bluetooth/UUID address of the Bluetooth peripheral sought.
|
||||
timeout: Optional timeout to wait for detection of specified peripheral before giving up. Defaults to 10.0 seconds.
|
||||
**kwargs: additional args passed to the :class:`BleakScanner` constructor.
|
||||
|
||||
Returns:
|
||||
The ``BLEDevice`` sought or ``None`` if not detected.
|
||||
|
||||
"""
|
||||
device_identifier = device_identifier.lower()
|
||||
return await cls.find_device_by_filter(
|
||||
lambda d, ad: d.address.lower() == device_identifier,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def find_device_by_name(
|
||||
cls, name: str, timeout: float = 10.0, **kwargs: Unpack[ExtraArgs]
|
||||
) -> Optional[BLEDevice]:
|
||||
"""Obtain a ``BLEDevice`` for a BLE server specified by the local name in the advertising data.
|
||||
|
||||
Args:
|
||||
name: The name sought.
|
||||
timeout: Optional timeout to wait for detection of specified peripheral before giving up. Defaults to 10.0 seconds.
|
||||
**kwargs: additional args passed to the :class:`BleakScanner` constructor.
|
||||
|
||||
Returns:
|
||||
The ``BLEDevice`` sought or ``None`` if not detected.
|
||||
|
||||
.. versionadded:: 0.20
|
||||
"""
|
||||
return await cls.find_device_by_filter(
|
||||
lambda d, ad: ad.local_name == name,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def find_device_by_filter(
|
||||
cls,
|
||||
filterfunc: AdvertisementDataFilter,
|
||||
timeout: float = 10.0,
|
||||
**kwargs: Unpack[ExtraArgs],
|
||||
) -> Optional[BLEDevice]:
|
||||
"""Obtain a ``BLEDevice`` for a BLE server that matches a given filter function.
|
||||
|
||||
This can be used to find a BLE server by other identifying information than its address,
|
||||
for example its name.
|
||||
|
||||
Args:
|
||||
filterfunc:
|
||||
A function that is called for every BLEDevice found. It should
|
||||
return ``True`` only for the wanted device.
|
||||
timeout:
|
||||
Optional timeout to wait for detection of specified peripheral
|
||||
before giving up. Defaults to 10.0 seconds.
|
||||
**kwargs:
|
||||
Additional arguments to be passed to the :class:`BleakScanner`
|
||||
constructor.
|
||||
|
||||
Returns:
|
||||
The :class:`BLEDevice` sought or ``None`` if not detected before
|
||||
the timeout.
|
||||
|
||||
"""
|
||||
async with cls(**kwargs) as scanner:
|
||||
try:
|
||||
async with async_timeout(timeout):
|
||||
async for bd, ad in scanner.advertisement_data():
|
||||
if filterfunc(bd, ad):
|
||||
return bd
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
|
||||
class BleakClient:
|
||||
"""The Client interface for connecting to a specific BLE GATT server and communicating with it.
|
||||
|
||||
A BleakClient can be used as an asynchronous context manager in which case it automatically
|
||||
connects and disconnects.
|
||||
|
||||
How many BLE connections can be active simultaneously, and whether connections can be active while
|
||||
scanning depends on the Bluetooth adapter hardware.
|
||||
|
||||
Args:
|
||||
address_or_ble_device:
|
||||
A :class:`BLEDevice` received from a :class:`BleakScanner` or a
|
||||
Bluetooth address (device UUID on macOS).
|
||||
disconnected_callback:
|
||||
Callback that will be scheduled in the event loop when the client is
|
||||
disconnected. The callable must take one argument, which will be
|
||||
this client object.
|
||||
services:
|
||||
Optional list of services to filter. If provided, only these services
|
||||
will be resolved. This may or may not reduce the time needed to
|
||||
enumerate the services depending on if the OS supports such filtering
|
||||
in the Bluetooth stack or not (should affect Windows and Mac).
|
||||
These can be 16-bit or 128-bit UUIDs.
|
||||
timeout:
|
||||
Timeout in seconds passed to the implicit ``discover`` call when
|
||||
``address_or_ble_device`` is not a :class:`BLEDevice`. Defaults to 10.0.
|
||||
winrt:
|
||||
Dictionary of WinRT/Windows platform-specific options.
|
||||
backend:
|
||||
Used to override the automatically selected backend (i.e. for a
|
||||
custom backend).
|
||||
**kwargs:
|
||||
Additional keyword arguments for backwards compatibility.
|
||||
|
||||
.. warning:: Although example code frequently initializes :class:`BleakClient`
|
||||
with a Bluetooth address for simplicity, it is not recommended to do so
|
||||
for more complex use cases. There are several known issues with providing
|
||||
a Bluetooth address as the ``address_or_ble_device`` argument.
|
||||
|
||||
1. macOS does not provide access to the Bluetooth address for privacy/
|
||||
security reasons. Instead it creates a UUID for each Bluetooth
|
||||
device which is used in place of the address on this platform.
|
||||
2. Providing an address or UUID instead of a :class:`BLEDevice` causes
|
||||
the :meth:`connect` method to implicitly call :meth:`BleakScanner.discover`.
|
||||
This is known to cause problems when trying to connect to multiple
|
||||
devices at the same time.
|
||||
|
||||
.. versionchanged:: 0.15
|
||||
``disconnected_callback`` is no longer keyword-only. Added ``winrt`` parameter.
|
||||
|
||||
.. versionchanged:: 0.18
|
||||
No longer is alias for backend type and no longer inherits from :class:`BaseBleakClient`.
|
||||
Added ``backend`` parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address_or_ble_device: Union[BLEDevice, str],
|
||||
disconnected_callback: Optional[Callable[[BleakClient], None]] = None,
|
||||
services: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
winrt: WinRTClientArgs = {},
|
||||
backend: Optional[Type[BaseBleakClient]] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
PlatformBleakClient = (
|
||||
get_platform_client_backend_type() if backend is None else backend
|
||||
)
|
||||
|
||||
self._backend = PlatformBleakClient(
|
||||
address_or_ble_device,
|
||||
disconnected_callback=(
|
||||
None
|
||||
if disconnected_callback is None
|
||||
else functools.partial(disconnected_callback, self)
|
||||
),
|
||||
services=(
|
||||
None if services is None else set(map(normalize_uuid_str, services))
|
||||
),
|
||||
timeout=timeout,
|
||||
winrt=winrt,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# device info
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
"""
|
||||
Gets the Bluetooth address of this device (UUID on macOS).
|
||||
"""
|
||||
return self._backend.address
|
||||
|
||||
@property
|
||||
def mtu_size(self) -> int:
|
||||
"""
|
||||
Gets the negotiated MTU size in bytes for the active connection.
|
||||
|
||||
Consider using :attr:`bleak.backends.characteristic.BleakGATTCharacteristic.max_write_without_response_size` instead.
|
||||
|
||||
.. warning:: The BlueZ backend will always return 23 (the minimum MTU size).
|
||||
See the ``mtu_size.py`` example for a way to hack around this.
|
||||
|
||||
"""
|
||||
return self._backend.mtu_size
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.__class__.__name__}, {self.address}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}, {self.address}, {type(self._backend)}>"
|
||||
|
||||
# Async Context managers
|
||||
|
||||
async def __aenter__(self) -> BleakClient:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Type[BaseException],
|
||||
exc_val: BaseException,
|
||||
exc_tb: TracebackType,
|
||||
) -> None:
|
||||
await self.disconnect()
|
||||
|
||||
# Connectivity methods
|
||||
|
||||
def set_disconnected_callback(
|
||||
self, callback: Optional[Callable[[BleakClient], None]], **kwargs
|
||||
) -> None:
|
||||
"""Set the disconnect callback.
|
||||
|
||||
.. deprecated:: 0.17.0
|
||||
This method will be removed in a future version of Bleak.
|
||||
Pass the callback to the :class:`BleakClient` constructor instead.
|
||||
|
||||
Args:
|
||||
callback: callback to be called on disconnection.
|
||||
|
||||
"""
|
||||
warn(
|
||||
"This method will be removed future version, pass the callback to the BleakClient constructor instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._backend.set_disconnected_callback(
|
||||
None if callback is None else functools.partial(callback, self), **kwargs
|
||||
)
|
||||
|
||||
async def connect(self, **kwargs) -> bool:
|
||||
"""Connect to the specified GATT server.
|
||||
|
||||
Args:
|
||||
**kwargs: For backwards compatibility - should not be used.
|
||||
|
||||
Returns:
|
||||
Always returns ``True`` for backwards compatibility.
|
||||
|
||||
"""
|
||||
return await self._backend.connect(**kwargs)
|
||||
|
||||
async def disconnect(self) -> bool:
|
||||
"""Disconnect from the specified GATT server.
|
||||
|
||||
Returns:
|
||||
Always returns ``True`` for backwards compatibility.
|
||||
|
||||
"""
|
||||
return await self._backend.disconnect()
|
||||
|
||||
async def pair(self, *args, **kwargs) -> bool:
|
||||
"""
|
||||
Pair with the specified GATT server.
|
||||
|
||||
This method is not available on macOS. Instead of manually initiating
|
||||
paring, the user will be prompted to pair the device the first time
|
||||
that a characteristic that requires authentication is read or written.
|
||||
This method may have backend-specific additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
Always returns ``True`` for backwards compatibility.
|
||||
|
||||
"""
|
||||
return await self._backend.pair(*args, **kwargs)
|
||||
|
||||
async def unpair(self) -> bool:
|
||||
"""
|
||||
Unpair from the specified GATT server.
|
||||
|
||||
Unpairing will also disconnect the device.
|
||||
|
||||
This method is only available on Windows and Linux and will raise an
|
||||
exception on other platforms.
|
||||
|
||||
Returns:
|
||||
Always returns ``True`` for backwards compatibility.
|
||||
"""
|
||||
return await self._backend.unpair()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
Check connection status between this client and the GATT server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
return self._backend.is_connected
|
||||
|
||||
# GATT services methods
|
||||
|
||||
async def get_services(self, **kwargs) -> BleakGATTServiceCollection:
|
||||
"""Get all services registered for this GATT server.
|
||||
|
||||
.. deprecated:: 0.17.0
|
||||
This method will be removed in a future version of Bleak.
|
||||
Use the :attr:`services` property instead.
|
||||
|
||||
Returns:
|
||||
A :class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||
|
||||
"""
|
||||
warn(
|
||||
"This method will be removed future version, use the services property instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return await self._backend.get_services(**kwargs)
|
||||
|
||||
@property
|
||||
def services(self) -> BleakGATTServiceCollection:
|
||||
"""
|
||||
Gets the collection of GATT services available on the device.
|
||||
|
||||
The returned value is only valid as long as the device is connected.
|
||||
|
||||
Raises:
|
||||
BleakError: if service discovery has not been performed yet during this connection.
|
||||
"""
|
||||
if not self._backend.services:
|
||||
raise BleakError("Service Discovery has not been performed yet")
|
||||
|
||||
return self._backend.services
|
||||
|
||||
# I/O methods
|
||||
|
||||
async def read_gatt_char(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID],
|
||||
**kwargs,
|
||||
) -> bytearray:
|
||||
"""
|
||||
Perform read operation on the specified GATT characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier:
|
||||
The characteristic to read from, specified by either integer
|
||||
handle, UUID or directly by the BleakGATTCharacteristic object
|
||||
representing it.
|
||||
|
||||
Returns:
|
||||
The read data.
|
||||
|
||||
"""
|
||||
return await self._backend.read_gatt_char(char_specifier, **kwargs)
|
||||
|
||||
async def write_gatt_char(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID],
|
||||
data: Buffer,
|
||||
response: bool = None,
|
||||
) -> None:
|
||||
"""
|
||||
Perform a write operation on the specified GATT characteristic.
|
||||
|
||||
There are two possible kinds of writes. *Write with response* (sometimes
|
||||
called a *Request*) will write the data then wait for a response from
|
||||
the remote device. *Write without response* (sometimes called *Command*)
|
||||
will queue data to be written and return immediately.
|
||||
|
||||
Each characteristic may support one kind or the other or both or neither.
|
||||
Consult the device's documentation or inspect the properties of the
|
||||
characteristic to find out which kind of writes are supported.
|
||||
|
||||
.. tip:: Explicit is better than implicit. Best practice is to always
|
||||
include an explicit ``response=True`` or ``response=False``
|
||||
when calling this method.
|
||||
|
||||
Args:
|
||||
char_specifier:
|
||||
The characteristic to write to, specified by either integer
|
||||
handle, UUID or directly by the :class:`~bleak.backends.characteristic.BleakGATTCharacteristic`
|
||||
object representing it. If a device has more than one characteristic
|
||||
with the same UUID, then attempting to use the UUID wil fail and
|
||||
a characteristic object must be used instead.
|
||||
data:
|
||||
The data to send. When a write-with-response operation is used,
|
||||
the length of the data is limited to 512 bytes. When a
|
||||
write-without-response operation is used, the length of the
|
||||
data is limited to :attr:`~bleak.backends.characteristic.BleakGATTCharacteristic.max_write_without_response_size`.
|
||||
Any type that supports the buffer protocol can be passed.
|
||||
response:
|
||||
If ``True``, a write-with-response operation will be used. If
|
||||
``False``, a write-without-response operation will be used.
|
||||
If omitted or ``None``, the "best" operation will be used
|
||||
based on the reported properties of the characteristic.
|
||||
|
||||
.. versionchanged:: 0.21
|
||||
The default behavior when ``response=`` is omitted was changed.
|
||||
|
||||
Example::
|
||||
|
||||
MY_CHAR_UUID = "1234"
|
||||
...
|
||||
await client.write_gatt_char(MY_CHAR_UUID, b"\x00\x01\x02\x03", response=True)
|
||||
"""
|
||||
if isinstance(char_specifier, BleakGATTCharacteristic):
|
||||
characteristic = char_specifier
|
||||
else:
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
if response is None:
|
||||
# if not specified, prefer write-with-response over write-without-
|
||||
# response if it is available since it is the more reliable write.
|
||||
response = "write" in characteristic.properties
|
||||
|
||||
await self._backend.write_gatt_char(characteristic, data, response)
|
||||
|
||||
async def start_notify(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID],
|
||||
callback: Callable[
|
||||
[BleakGATTCharacteristic, bytearray], Union[None, Awaitable[None]]
|
||||
],
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Activate notifications/indications on a characteristic.
|
||||
|
||||
Callbacks must accept two inputs. The first will be the characteristic
|
||||
and the second will be a ``bytearray`` containing the data received.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def callback(sender: BleakGATTCharacteristic, data: bytearray):
|
||||
print(f"{sender}: {data}")
|
||||
|
||||
client.start_notify(char_uuid, callback)
|
||||
|
||||
Args:
|
||||
char_specifier:
|
||||
The characteristic to activate notifications/indications on a
|
||||
characteristic, specified by either integer handle,
|
||||
UUID or directly by the BleakGATTCharacteristic object representing it.
|
||||
callback:
|
||||
The function to be called on notification. Can be regular
|
||||
function or async function.
|
||||
|
||||
|
||||
.. versionchanged:: 0.18
|
||||
The first argument of the callback is now a :class:`BleakGATTCharacteristic`
|
||||
instead of an ``int``.
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristic):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
if inspect.iscoroutinefunction(callback):
|
||||
|
||||
def wrapped_callback(data: bytearray) -> None:
|
||||
task = asyncio.create_task(callback(characteristic, data))
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
else:
|
||||
wrapped_callback = functools.partial(callback, characteristic)
|
||||
|
||||
await self._backend.start_notify(characteristic, wrapped_callback, **kwargs)
|
||||
|
||||
async def stop_notify(
|
||||
self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID]
|
||||
) -> None:
|
||||
"""
|
||||
Deactivate notification/indication on a specified characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier:
|
||||
The characteristic to deactivate notification/indication on,
|
||||
specified by either integer handle, UUID or directly by the
|
||||
BleakGATTCharacteristic object representing it.
|
||||
|
||||
.. tip:: Notifications are stopped automatically on disconnect, so this
|
||||
method does not need to be called unless notifications need to be
|
||||
stopped some time before the device disconnects.
|
||||
"""
|
||||
await self._backend.stop_notify(char_specifier)
|
||||
|
||||
async def read_gatt_descriptor(self, handle: int, **kwargs) -> bytearray:
|
||||
"""
|
||||
Perform read operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle: The handle of the descriptor to read from.
|
||||
|
||||
Returns:
|
||||
The read data.
|
||||
|
||||
"""
|
||||
return await self._backend.read_gatt_descriptor(handle, **kwargs)
|
||||
|
||||
async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None:
|
||||
"""
|
||||
Perform a write operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle:
|
||||
The handle of the descriptor to read from.
|
||||
data:
|
||||
The data to send.
|
||||
|
||||
"""
|
||||
await self._backend.write_gatt_descriptor(handle, data)
|
||||
|
||||
|
||||
# for backward compatibility
|
||||
def discover(*args, **kwargs):
|
||||
"""
|
||||
.. deprecated:: 0.17.0
|
||||
This method will be removed in a future version of Bleak.
|
||||
Use :meth:`BleakScanner.discover` instead.
|
||||
"""
|
||||
warn(
|
||||
"The discover function will removed in a future version, use BleakScanner.discover instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return BleakScanner.discover(*args, **kwargs)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Perform Bluetooth Low Energy device scan"
|
||||
)
|
||||
parser.add_argument("-i", dest="adapter", default=None, help="HCI device")
|
||||
parser.add_argument(
|
||||
"-t", dest="timeout", type=int, default=5, help="Duration to scan for"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
out = asyncio.run(discover(adapter=args.adapter, timeout=float(args.timeout)))
|
||||
for o in out:
|
||||
print(str(o))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
38
bleak/assigned_numbers.py
Normal file
38
bleak/assigned_numbers.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
Bluetooth Assigned Numbers
|
||||
--------------------------
|
||||
|
||||
This module contains useful assigned numbers from the Bluetooth spec.
|
||||
|
||||
See <https://www.bluetooth.com/specifications/assigned-numbers/>.
|
||||
"""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class AdvertisementDataType(IntEnum):
|
||||
"""
|
||||
Generic Access Profile advertisement data types.
|
||||
|
||||
`Source <https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Generic%20Access%20Profile.pdf>`.
|
||||
|
||||
.. versionadded:: 0.15
|
||||
"""
|
||||
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_SERVICE_UUID16 = 0x02
|
||||
COMPLETE_LIST_SERVICE_UUID16 = 0x03
|
||||
INCOMPLETE_LIST_SERVICE_UUID32 = 0x04
|
||||
COMPLETE_LIST_SERVICE_UUID32 = 0x05
|
||||
INCOMPLETE_LIST_SERVICE_UUID128 = 0x06
|
||||
COMPLETE_LIST_SERVICE_UUID128 = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
|
||||
SERVICE_DATA_UUID16 = 0x16
|
||||
SERVICE_DATA_UUID32 = 0x20
|
||||
SERVICE_DATA_UUID128 = 0x21
|
||||
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
7
bleak/backends/__init__.py
Normal file
7
bleak/backends/__init__.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
__init__.py
|
||||
|
||||
Created on 2017-11-19 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
1936
bleak/backends/_manufacturers.py
Normal file
1936
bleak/backends/_manufacturers.py
Normal file
File diff suppressed because it is too large
Load diff
1
bleak/backends/bluezdbus/__init__.py
Normal file
1
bleak/backends/bluezdbus/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""BlueZ backend."""
|
120
bleak/backends/bluezdbus/advertisement_monitor.py
Normal file
120
bleak/backends/bluezdbus/advertisement_monitor.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
"""
|
||||
Advertisement Monitor
|
||||
---------------------
|
||||
|
||||
This module contains types associated with the BlueZ D-Bus `advertisement
|
||||
monitor api <https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitor.rst>`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Iterable, NamedTuple, Tuple, Union, no_type_check
|
||||
|
||||
from dbus_fast.service import PropertyAccess, ServiceInterface, dbus_property, method
|
||||
|
||||
from ...assigned_numbers import AdvertisementDataType
|
||||
from . import defs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrPattern(NamedTuple):
|
||||
"""
|
||||
BlueZ advertisement monitor or-pattern.
|
||||
|
||||
https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitor.rst#arrayuint8-uint8-arraybyte-patterns-read-only-optional
|
||||
"""
|
||||
|
||||
start_position: int
|
||||
ad_data_type: AdvertisementDataType
|
||||
content_of_pattern: bytes
|
||||
|
||||
|
||||
# Windows has a similar structure, so we allow generic tuple for cross-platform compatibility
|
||||
OrPatternLike = Union[OrPattern, Tuple[int, AdvertisementDataType, bytes]]
|
||||
|
||||
|
||||
class AdvertisementMonitor(ServiceInterface):
|
||||
"""
|
||||
Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface.
|
||||
|
||||
The BlueZ advertisement monitor API design seems to be just for device
|
||||
presence (is it in range or out of range), but this isn't really what
|
||||
we want in Bleak, we want to monitor changes in advertisement data, just
|
||||
like in active scanning.
|
||||
|
||||
So the only thing we are using here is the "or_patterns" since it is
|
||||
currently required, but really we don't need that either. Hopefully an
|
||||
"all" "Type" could be added to BlueZ in the future.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
or_patterns: Iterable[OrPatternLike],
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
or_patterns:
|
||||
List of or patterns that will be returned by the ``Patterns`` property.
|
||||
"""
|
||||
super().__init__(defs.ADVERTISEMENT_MONITOR_INTERFACE)
|
||||
# dbus_fast marshaling requires list instead of tuple
|
||||
self._or_patterns = [list(p) for p in or_patterns]
|
||||
|
||||
@method()
|
||||
def Release(self):
|
||||
logger.debug("Release")
|
||||
|
||||
@method()
|
||||
def Activate(self):
|
||||
logger.debug("Activate")
|
||||
|
||||
# REVISIT: mypy is broke, so we have to add redundant @no_type_check
|
||||
# https://github.com/python/mypy/issues/6583
|
||||
|
||||
@method()
|
||||
@no_type_check
|
||||
def DeviceFound(self, device: "o"): # noqa: F821
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("DeviceFound %s", device)
|
||||
|
||||
@method()
|
||||
@no_type_check
|
||||
def DeviceLost(self, device: "o"): # noqa: F821
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("DeviceLost %s", device)
|
||||
|
||||
@dbus_property(PropertyAccess.READ)
|
||||
@no_type_check
|
||||
def Type(self) -> "s": # noqa: F821
|
||||
# this is currently the only type supported in BlueZ
|
||||
return "or_patterns"
|
||||
|
||||
@dbus_property(PropertyAccess.READ, disabled=True)
|
||||
@no_type_check
|
||||
def RSSILowThreshold(self) -> "n": # noqa: F821
|
||||
...
|
||||
|
||||
@dbus_property(PropertyAccess.READ, disabled=True)
|
||||
@no_type_check
|
||||
def RSSIHighThreshold(self) -> "n": # noqa: F821
|
||||
...
|
||||
|
||||
@dbus_property(PropertyAccess.READ, disabled=True)
|
||||
@no_type_check
|
||||
def RSSILowTimeout(self) -> "q": # noqa: F821
|
||||
...
|
||||
|
||||
@dbus_property(PropertyAccess.READ, disabled=True)
|
||||
@no_type_check
|
||||
def RSSIHighTimeout(self) -> "q": # noqa: F821
|
||||
...
|
||||
|
||||
@dbus_property(PropertyAccess.READ, disabled=True)
|
||||
@no_type_check
|
||||
def RSSISamplingPeriod(self) -> "q": # noqa: F821
|
||||
...
|
||||
|
||||
@dbus_property(PropertyAccess.READ)
|
||||
@no_type_check
|
||||
def Patterns(self) -> "a(yyay)": # noqa: F821
|
||||
return self._or_patterns
|
107
bleak/backends/bluezdbus/characteristic.py
Normal file
107
bleak/backends/bluezdbus/characteristic.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
from typing import Callable, List, Union
|
||||
from uuid import UUID
|
||||
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..descriptor import BleakGATTDescriptor
|
||||
from .defs import GattCharacteristic1
|
||||
from .utils import extract_service_handle_from_path
|
||||
|
||||
_GattCharacteristicsFlagsEnum = {
|
||||
0x0001: "broadcast",
|
||||
0x0002: "read",
|
||||
0x0004: "write-without-response",
|
||||
0x0008: "write",
|
||||
0x0010: "notify",
|
||||
0x0020: "indicate",
|
||||
0x0040: "authenticated-signed-writes",
|
||||
0x0080: "extended-properties",
|
||||
0x0100: "reliable-write",
|
||||
0x0200: "writable-auxiliaries",
|
||||
# "encrypt-read"
|
||||
# "encrypt-write"
|
||||
# "encrypt-authenticated-read"
|
||||
# "encrypt-authenticated-write"
|
||||
# "secure-read" #(Server only)
|
||||
# "secure-write" #(Server only)
|
||||
# "authorize"
|
||||
}
|
||||
|
||||
|
||||
class BleakGATTCharacteristicBlueZDBus(BleakGATTCharacteristic):
|
||||
"""GATT Characteristic implementation for the BlueZ DBus backend"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
obj: GattCharacteristic1,
|
||||
object_path: str,
|
||||
service_uuid: str,
|
||||
service_handle: int,
|
||||
max_write_without_response_size: Callable[[], int],
|
||||
):
|
||||
super(BleakGATTCharacteristicBlueZDBus, self).__init__(
|
||||
obj, max_write_without_response_size
|
||||
)
|
||||
self.__descriptors = []
|
||||
self.__path = object_path
|
||||
self.__service_uuid = service_uuid
|
||||
self.__service_handle = service_handle
|
||||
self._handle = extract_service_handle_from_path(object_path)
|
||||
|
||||
@property
|
||||
def service_uuid(self) -> str:
|
||||
"""The uuid of the Service containing this characteristic"""
|
||||
return self.__service_uuid
|
||||
|
||||
@property
|
||||
def service_handle(self) -> int:
|
||||
"""The handle of the Service containing this characteristic"""
|
||||
return self.__service_handle
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""The handle of this characteristic"""
|
||||
return self._handle
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""The uuid of this characteristic"""
|
||||
return self.obj.get("UUID")
|
||||
|
||||
@property
|
||||
def properties(self) -> List[str]:
|
||||
"""Properties of this characteristic
|
||||
|
||||
Returns the characteristics `Flags` present in the DBus API.
|
||||
"""
|
||||
return self.obj["Flags"]
|
||||
|
||||
@property
|
||||
def descriptors(self) -> List[BleakGATTDescriptor]:
|
||||
"""List of descriptors for this service"""
|
||||
return self.__descriptors
|
||||
|
||||
def get_descriptor(
|
||||
self, specifier: Union[int, str, UUID]
|
||||
) -> Union[BleakGATTDescriptor, None]:
|
||||
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)"""
|
||||
try:
|
||||
if isinstance(specifier, int):
|
||||
return next(filter(lambda x: x.handle == specifier, self.descriptors))
|
||||
else:
|
||||
return next(
|
||||
filter(lambda x: x.uuid == str(specifier), self.descriptors)
|
||||
)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
|
||||
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__descriptors.append(descriptor)
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""The DBus path. Mostly needed by `bleak`, not by end user"""
|
||||
return self.__path
|
993
bleak/backends/bluezdbus/client.py
Normal file
993
bleak/backends/bluezdbus/client.py
Normal file
|
@ -0,0 +1,993 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
BLE Client for BlueZ on Linux
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import Callable, Dict, Optional, Set, Union, cast
|
||||
from uuid import UUID
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
from typing_extensions import Buffer
|
||||
else:
|
||||
from collections.abc import Buffer
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from async_timeout import timeout as async_timeout
|
||||
else:
|
||||
from asyncio import timeout as async_timeout
|
||||
|
||||
from dbus_fast.aio import MessageBus
|
||||
from dbus_fast.constants import BusType, ErrorType, MessageType
|
||||
from dbus_fast.message import Message
|
||||
from dbus_fast.signature import Variant
|
||||
|
||||
from ... import BleakScanner
|
||||
from ...exc import (
|
||||
BleakCharacteristicNotFoundError,
|
||||
BleakDBusError,
|
||||
BleakDeviceNotFoundError,
|
||||
BleakError,
|
||||
)
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..client import BaseBleakClient, NotifyCallback
|
||||
from ..device import BLEDevice
|
||||
from ..service import BleakGATTServiceCollection
|
||||
from . import defs
|
||||
from .characteristic import BleakGATTCharacteristicBlueZDBus
|
||||
from .manager import get_global_bluez_manager
|
||||
from .scanner import BleakScannerBlueZDBus
|
||||
from .utils import assert_reply, get_dbus_authenticator
|
||||
from .version import BlueZFeatures
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# prevent tasks from being garbage collected
|
||||
_background_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
|
||||
class BleakClientBlueZDBus(BaseBleakClient):
|
||||
"""A native Linux Bleak Client
|
||||
|
||||
Implemented by using the `BlueZ DBUS API <https://docs.ubuntu.com/core/en/stacks/bluetooth/bluez/docs/reference/dbus-api>`_.
|
||||
|
||||
Args:
|
||||
address_or_ble_device (`BLEDevice` or str): The Bluetooth address of the BLE peripheral to connect to or the `BLEDevice` object representing it.
|
||||
services: Optional list of service UUIDs that will be used.
|
||||
|
||||
Keyword Args:
|
||||
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0.
|
||||
disconnected_callback (callable): Callback that will be scheduled in the
|
||||
event loop when the client is disconnected. The callable must take one
|
||||
argument, which will be this client object.
|
||||
adapter (str): Bluetooth adapter to use for discovery.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address_or_ble_device: Union[BLEDevice, str],
|
||||
services: Optional[Set[str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super(BleakClientBlueZDBus, self).__init__(address_or_ble_device, **kwargs)
|
||||
# kwarg "device" is for backwards compatibility
|
||||
self._adapter: Optional[str] = kwargs.get("adapter", kwargs.get("device"))
|
||||
|
||||
# Backend specific, D-Bus objects and data
|
||||
if isinstance(address_or_ble_device, BLEDevice):
|
||||
self._device_path = address_or_ble_device.details["path"]
|
||||
self._device_info = address_or_ble_device.details.get("props")
|
||||
else:
|
||||
self._device_path = None
|
||||
self._device_info = None
|
||||
|
||||
self._requested_services = services
|
||||
|
||||
# D-Bus message bus
|
||||
self._bus: Optional[MessageBus] = None
|
||||
# tracks device watcher subscription
|
||||
self._remove_device_watcher: Optional[Callable] = None
|
||||
# private backing for is_connected property
|
||||
self._is_connected = False
|
||||
# indicates disconnect request in progress when not None
|
||||
self._disconnecting_event: Optional[asyncio.Event] = None
|
||||
# used to ensure device gets disconnected if event loop crashes
|
||||
self._disconnect_monitor_event: Optional[asyncio.Event] = None
|
||||
# map of characteristic D-Bus object path to notification callback
|
||||
self._notification_callbacks: Dict[str, NotifyCallback] = {}
|
||||
|
||||
# used to override mtu_size property
|
||||
self._mtu_size: Optional[int] = None
|
||||
|
||||
# Connectivity methods
|
||||
|
||||
async def connect(self, dangerous_use_bleak_cache: bool = False, **kwargs) -> bool:
|
||||
"""Connect to the specified GATT server.
|
||||
|
||||
Keyword Args:
|
||||
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
Raises:
|
||||
BleakError: If the device is already connected or if the device could not be found.
|
||||
BleakDBusError: If there was a D-Bus error
|
||||
asyncio.TimeoutError: If the connection timed out
|
||||
"""
|
||||
logger.debug("Connecting to device @ %s", self.address)
|
||||
|
||||
if self.is_connected:
|
||||
raise BleakError("Client is already connected")
|
||||
|
||||
if not BlueZFeatures.checked_bluez_version:
|
||||
await BlueZFeatures.check_bluez_version()
|
||||
if not BlueZFeatures.supported_version:
|
||||
raise BleakError("Bleak requires BlueZ >= 5.43.")
|
||||
# A Discover must have been run before connecting to any devices.
|
||||
# Find the desired device before trying to connect.
|
||||
timeout = kwargs.get("timeout", self._timeout)
|
||||
if self._device_path is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
self.address,
|
||||
timeout=timeout,
|
||||
adapter=self._adapter,
|
||||
backend=BleakScannerBlueZDBus,
|
||||
)
|
||||
|
||||
if device:
|
||||
self._device_info = device.details.get("props")
|
||||
self._device_path = device.details["path"]
|
||||
else:
|
||||
raise BleakDeviceNotFoundError(
|
||||
self.address, f"Device with address {self.address} was not found."
|
||||
)
|
||||
|
||||
manager = await get_global_bluez_manager()
|
||||
|
||||
async with async_timeout(timeout):
|
||||
while True:
|
||||
# Each BLE connection session needs a new D-Bus connection to avoid a
|
||||
# BlueZ quirk where notifications are automatically enabled on reconnect.
|
||||
self._bus = await MessageBus(
|
||||
bus_type=BusType.SYSTEM,
|
||||
negotiate_unix_fd=True,
|
||||
auth=get_dbus_authenticator(),
|
||||
).connect()
|
||||
|
||||
def on_connected_changed(connected: bool) -> None:
|
||||
if not connected:
|
||||
logger.debug("Device disconnected (%s)", self._device_path)
|
||||
|
||||
self._is_connected = False
|
||||
|
||||
if self._disconnect_monitor_event:
|
||||
self._disconnect_monitor_event.set()
|
||||
self._disconnect_monitor_event = None
|
||||
|
||||
self._cleanup_all()
|
||||
if self._disconnected_callback is not None:
|
||||
self._disconnected_callback()
|
||||
disconnecting_event = self._disconnecting_event
|
||||
if disconnecting_event:
|
||||
disconnecting_event.set()
|
||||
|
||||
def on_value_changed(char_path: str, value: bytes) -> None:
|
||||
callback = self._notification_callbacks.get(char_path)
|
||||
|
||||
if callback:
|
||||
callback(bytearray(value))
|
||||
|
||||
watcher = manager.add_device_watcher(
|
||||
self._device_path, on_connected_changed, on_value_changed
|
||||
)
|
||||
self._remove_device_watcher = lambda: manager.remove_device_watcher(
|
||||
watcher
|
||||
)
|
||||
|
||||
self._disconnect_monitor_event = local_disconnect_monitor_event = (
|
||||
asyncio.Event()
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
#
|
||||
# The BlueZ backend does not disconnect devices when the
|
||||
# application closes or crashes. This can cause problems
|
||||
# when trying to reconnect to the same device. To work
|
||||
# around this, we check if the device is already connected.
|
||||
#
|
||||
# For additional details see https://github.com/bluez/bluez/issues/89
|
||||
#
|
||||
if manager.is_connected(self._device_path):
|
||||
logger.debug(
|
||||
'skipping calling "Connect" since %s is already connected',
|
||||
self._device_path,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Connecting to BlueZ path %s", self._device_path
|
||||
)
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
interface=defs.DEVICE_INTERFACE,
|
||||
path=self._device_path,
|
||||
member="Connect",
|
||||
)
|
||||
)
|
||||
|
||||
assert reply is not None
|
||||
|
||||
if reply.message_type == MessageType.ERROR:
|
||||
# This error is often caused by RF interference
|
||||
# from other Bluetooth or Wi-Fi devices. In many
|
||||
# cases, retrying will connect successfully.
|
||||
# Note: this error was added in BlueZ 6.62.
|
||||
if (
|
||||
reply.error_name == "org.bluez.Error.Failed"
|
||||
and reply.body
|
||||
and reply.body[0] == "le-connection-abort-by-local"
|
||||
):
|
||||
logger.debug(
|
||||
"retry due to le-connection-abort-by-local"
|
||||
)
|
||||
|
||||
# When this error occurs, BlueZ actually
|
||||
# connected so we get "Connected" property changes
|
||||
# that we need to wait for before attempting
|
||||
# to connect again.
|
||||
await local_disconnect_monitor_event.wait()
|
||||
|
||||
# Jump way back to the `while True:`` to retry.
|
||||
continue
|
||||
|
||||
if reply.error_name == ErrorType.UNKNOWN_OBJECT.value:
|
||||
raise BleakDeviceNotFoundError(
|
||||
self.address,
|
||||
f"Device with address {self.address} was not found. It may have been removed from BlueZ when scanning stopped.",
|
||||
)
|
||||
|
||||
assert_reply(reply)
|
||||
|
||||
self._is_connected = True
|
||||
|
||||
# Create a task that runs until the device is disconnected.
|
||||
task = asyncio.create_task(
|
||||
self._disconnect_monitor(
|
||||
self._bus,
|
||||
self._device_path,
|
||||
local_disconnect_monitor_event,
|
||||
)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
#
|
||||
# We will try to use the cache if it exists and `dangerous_use_bleak_cache`
|
||||
# is True.
|
||||
#
|
||||
await self.get_services(
|
||||
dangerous_use_bleak_cache=dangerous_use_bleak_cache
|
||||
)
|
||||
|
||||
return True
|
||||
except BaseException:
|
||||
# Calling Disconnect cancels any pending connect request. Also,
|
||||
# if connection was successful but get_services() raises (e.g.
|
||||
# because task was cancelled), the we still need to disconnect
|
||||
# before passing on the exception.
|
||||
if self._bus:
|
||||
# If disconnected callback already fired, this will be a no-op
|
||||
# since self._bus will be None and the _cleanup_all call will
|
||||
# have already disconnected.
|
||||
try:
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
interface=defs.DEVICE_INTERFACE,
|
||||
path=self._device_path,
|
||||
member="Disconnect",
|
||||
)
|
||||
)
|
||||
try:
|
||||
assert_reply(reply)
|
||||
except BleakDBusError as e:
|
||||
# if the object no longer exists, then we know we
|
||||
# are disconnected for sure, so don't need to log a
|
||||
# warning about it
|
||||
if e.dbus_error != ErrorType.UNKNOWN_OBJECT.value:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cancel connection ({self._device_path}): {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
except BaseException:
|
||||
# this effectively cancels the disconnect monitor in case the event
|
||||
# was not triggered by a D-Bus callback
|
||||
local_disconnect_monitor_event.set()
|
||||
self._cleanup_all()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def _disconnect_monitor(
|
||||
bus: MessageBus, device_path: str, disconnect_monitor_event: asyncio.Event
|
||||
) -> None:
|
||||
# This task runs until the device is disconnected. If the task is
|
||||
# cancelled, it probably means that the event loop crashed so we
|
||||
# try to disconnected the device. Otherwise BlueZ will keep the device
|
||||
# connected even after Python exits. This will only work if the event
|
||||
# loop is called with asyncio.run() or otherwise runs pending tasks
|
||||
# after the original event loop stops. This will also cause an exception
|
||||
# if a run loop is stopped before the device is disconnected since this
|
||||
# task will still be running and asyncio complains if a loop with running
|
||||
# tasks is stopped.
|
||||
try:
|
||||
await disconnect_monitor_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
# by using send() instead of call(), we ensure that the message
|
||||
# gets sent, but we don't wait for a reply, which could take
|
||||
# over one second while the device disconnects.
|
||||
await bus.send(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=device_path,
|
||||
interface=defs.DEVICE_INTERFACE,
|
||||
member="Disconnect",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cleanup_all(self) -> None:
|
||||
"""
|
||||
Free all the allocated resource in DBus. Use this method to
|
||||
eventually cleanup all otherwise leaked resources.
|
||||
"""
|
||||
logger.debug("_cleanup_all(%s)", self._device_path)
|
||||
|
||||
if self._remove_device_watcher:
|
||||
self._remove_device_watcher()
|
||||
self._remove_device_watcher = None
|
||||
|
||||
if not self._bus:
|
||||
logger.debug("already disconnected (%s)", self._device_path)
|
||||
return
|
||||
|
||||
# Try to disconnect the System Bus.
|
||||
try:
|
||||
self._bus.disconnect()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Attempt to disconnect system bus failed (%s): %s",
|
||||
self._device_path,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
# Critical to remove the `self._bus` object here to since it was
|
||||
# closed above. If not, calls made to it later could lead to
|
||||
# a stuck client.
|
||||
self._bus = None
|
||||
|
||||
# Reset all stored services.
|
||||
self.services = None
|
||||
|
||||
async def disconnect(self) -> bool:
|
||||
"""Disconnect from the specified GATT server.
|
||||
|
||||
Returns:
|
||||
Boolean representing if device is disconnected.
|
||||
|
||||
Raises:
|
||||
BleakDBusError: If there was a D-Bus error
|
||||
asyncio.TimeoutError if the device was not disconnected within 10 seconds
|
||||
"""
|
||||
logger.debug("Disconnecting ({%s})", self._device_path)
|
||||
|
||||
if self._bus is None:
|
||||
# No connection exists. Either one hasn't been created or
|
||||
# we have already called disconnect and closed the D-Bus
|
||||
# connection.
|
||||
logger.debug("already disconnected ({%s})", self._device_path)
|
||||
return True
|
||||
|
||||
if self._disconnecting_event:
|
||||
# another call to disconnect() is already in progress
|
||||
logger.debug("already in progress ({%s})", self._device_path)
|
||||
async with async_timeout(10):
|
||||
await self._disconnecting_event.wait()
|
||||
elif self.is_connected:
|
||||
self._disconnecting_event = asyncio.Event()
|
||||
try:
|
||||
# Try to disconnect the actual device/peripheral
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=self._device_path,
|
||||
interface=defs.DEVICE_INTERFACE,
|
||||
member="Disconnect",
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
async with async_timeout(10):
|
||||
await self._disconnecting_event.wait()
|
||||
finally:
|
||||
self._disconnecting_event = None
|
||||
|
||||
# sanity check to make sure _cleanup_all() was triggered by the
|
||||
# "PropertiesChanged" signal handler and that it completed successfully
|
||||
assert self._bus is None
|
||||
|
||||
return True
|
||||
|
||||
async def pair(self, *args, **kwargs) -> bool:
|
||||
"""Pair with the peripheral.
|
||||
|
||||
You can use ConnectDevice method if you already know the MAC address of the device.
|
||||
Else you need to StartDiscovery, Trust, Pair and Connect in sequence.
|
||||
|
||||
Returns:
|
||||
Boolean regarding success of pairing.
|
||||
|
||||
"""
|
||||
# See if it is already paired.
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=self._device_path,
|
||||
interface=defs.PROPERTIES_INTERFACE,
|
||||
member="Get",
|
||||
signature="ss",
|
||||
body=[defs.DEVICE_INTERFACE, "Paired"],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
if reply.body[0].value:
|
||||
logger.debug("BLE device @ %s is already paired", self.address)
|
||||
return True
|
||||
|
||||
# Set device as trusted.
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=self._device_path,
|
||||
interface=defs.PROPERTIES_INTERFACE,
|
||||
member="Set",
|
||||
signature="ssv",
|
||||
body=[defs.DEVICE_INTERFACE, "Trusted", Variant("b", True)],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
|
||||
logger.debug("Pairing to BLE device @ %s", self.address)
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=self._device_path,
|
||||
interface=defs.DEVICE_INTERFACE,
|
||||
member="Pair",
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=self._device_path,
|
||||
interface=defs.PROPERTIES_INTERFACE,
|
||||
member="Get",
|
||||
signature="ss",
|
||||
body=[defs.DEVICE_INTERFACE, "Paired"],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
|
||||
return reply.body[0].value
|
||||
|
||||
async def unpair(self) -> bool:
|
||||
"""Unpair with the peripheral.
|
||||
|
||||
Returns:
|
||||
Boolean regarding success of unpairing.
|
||||
|
||||
"""
|
||||
adapter_path = await self._get_adapter_path()
|
||||
device_path = await self._get_device_path()
|
||||
manager = await get_global_bluez_manager()
|
||||
|
||||
logger.debug(
|
||||
"Removing BlueZ device path %s from adapter path %s",
|
||||
device_path,
|
||||
adapter_path,
|
||||
)
|
||||
|
||||
# If this client object wants to connect again, BlueZ needs the device
|
||||
# to follow Discovery process again - so reset the local connection
|
||||
# state.
|
||||
#
|
||||
# (This is true even if the request to RemoveDevice fails,
|
||||
# so clear it before.)
|
||||
self._device_path = None
|
||||
self._device_info = None
|
||||
self._is_connected = False
|
||||
|
||||
try:
|
||||
reply = await manager._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=adapter_path,
|
||||
interface=defs.ADAPTER_INTERFACE,
|
||||
member="RemoveDevice",
|
||||
signature="o",
|
||||
body=[device_path],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
except BleakDBusError as e:
|
||||
if e.dbus_error == "org.bluez.Error.DoesNotExist":
|
||||
raise BleakDeviceNotFoundError(
|
||||
self.address, f"Device with address {self.address} was not found."
|
||||
) from e
|
||||
raise
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check connection status between this client and the server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
return self._DeprecatedIsConnectedReturn(
|
||||
False if self._bus is None else self._is_connected
|
||||
)
|
||||
|
||||
async def _acquire_mtu(self) -> None:
|
||||
"""Acquires the MTU for this device by calling the "AcquireWrite" or
|
||||
"AcquireNotify" method of the first characteristic that has such a method.
|
||||
|
||||
This method only needs to be called once, after connecting to the device
|
||||
but before accessing the ``mtu_size`` property.
|
||||
|
||||
If a device uses encryption on characteristics, it will need to be bonded
|
||||
first before calling this method.
|
||||
"""
|
||||
# This will try to get the "best" characteristic for getting the MTU.
|
||||
# We would rather not start notifications if we don't have to.
|
||||
try:
|
||||
method = "AcquireWrite"
|
||||
char = next(
|
||||
c
|
||||
for c in self.services.characteristics.values()
|
||||
if "write-without-response" in c.properties
|
||||
)
|
||||
except StopIteration:
|
||||
method = "AcquireNotify"
|
||||
char = next(
|
||||
c
|
||||
for c in self.services.characteristics.values()
|
||||
if "notify" in c.properties
|
||||
)
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=char.path,
|
||||
interface=defs.GATT_CHARACTERISTIC_INTERFACE,
|
||||
member=method,
|
||||
signature="a{sv}",
|
||||
body=[{}],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
|
||||
# we aren't actually using the write or notify, we just want the MTU
|
||||
os.close(reply.unix_fds[0])
|
||||
self._mtu_size = reply.body[1]
|
||||
|
||||
async def _get_adapter_path(self) -> str:
|
||||
"""Private coroutine to return the BlueZ path to the adapter this client is assigned to.
|
||||
|
||||
Can be called even if no connection has been established yet.
|
||||
"""
|
||||
if self._device_info:
|
||||
# If we have a BlueZ DBus object with _device_info, use what it tell us
|
||||
return self._device_info["Adapter"]
|
||||
if self._adapter:
|
||||
# If the adapter name was set in the constructor, convert to a BlueZ path
|
||||
return f"/org/bluez/{self._adapter}"
|
||||
|
||||
# Fall back to the system's default Bluetooth adapter
|
||||
manager = await get_global_bluez_manager()
|
||||
return manager.get_default_adapter()
|
||||
|
||||
async def _get_device_path(self) -> str:
|
||||
"""Private coroutine to return the BlueZ path to the device address this client is assigned to.
|
||||
|
||||
Unlike the _device_path property, this function can be called even if discovery process has not
|
||||
started and/or connection has not been established yet.
|
||||
"""
|
||||
if self._device_path:
|
||||
# If we have a BlueZ DBus object, return its device path
|
||||
return self._device_path
|
||||
|
||||
# Otherwise, build a new path using the adapter path and the BLE address
|
||||
adapter_path = await self._get_adapter_path()
|
||||
bluez_address = self.address.upper().replace(":", "_")
|
||||
return f"{adapter_path}/dev_{bluez_address}"
|
||||
|
||||
@property
|
||||
def mtu_size(self) -> int:
|
||||
"""Get ATT MTU size for active connection"""
|
||||
if self._mtu_size is None:
|
||||
warnings.warn(
|
||||
"Using default MTU value. Call _acquire_mtu() or set _mtu_size first to avoid this warning."
|
||||
)
|
||||
return 23
|
||||
|
||||
return self._mtu_size
|
||||
|
||||
# GATT services methods
|
||||
|
||||
async def get_services(
|
||||
self, dangerous_use_bleak_cache: bool = False, **kwargs
|
||||
) -> BleakGATTServiceCollection:
|
||||
"""Get all services registered for this GATT server.
|
||||
|
||||
Args:
|
||||
dangerous_use_bleak_cache (bool): Use cached services if available.
|
||||
|
||||
Returns:
|
||||
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
if self.services is not None:
|
||||
return self.services
|
||||
|
||||
manager = await get_global_bluez_manager()
|
||||
|
||||
self.services = await manager.get_services(
|
||||
self._device_path, dangerous_use_bleak_cache, self._requested_services
|
||||
)
|
||||
|
||||
return self.services
|
||||
|
||||
# IO methods
|
||||
|
||||
async def read_gatt_char(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristicBlueZDBus, int, str, UUID],
|
||||
**kwargs,
|
||||
) -> bytearray:
|
||||
"""Perform read operation on the specified GATT characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristicBlueZDBus, int, str or UUID): The characteristic to read from,
|
||||
specified by either integer handle, UUID or directly by the
|
||||
BleakGATTCharacteristicBlueZDBus object representing it.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristicBlueZDBus):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
|
||||
if not characteristic:
|
||||
# Special handling for BlueZ >= 5.48, where Battery Service (0000180f-0000-1000-8000-00805f9b34fb:)
|
||||
# has been moved to interface org.bluez.Battery1 instead of as a regular service.
|
||||
if (
|
||||
str(char_specifier) == "00002a19-0000-1000-8000-00805f9b34fb"
|
||||
and BlueZFeatures.hides_battery_characteristic
|
||||
):
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=self._device_path,
|
||||
interface=defs.PROPERTIES_INTERFACE,
|
||||
member="GetAll",
|
||||
signature="s",
|
||||
body=[defs.BATTERY_INTERFACE],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
# Simulate regular characteristics read to be consistent over all platforms.
|
||||
value = bytearray([reply.body[0]["Percentage"].value])
|
||||
logger.debug(
|
||||
"Read Battery Level {0} | {1}: {2}".format(
|
||||
char_specifier, self._device_path, value
|
||||
)
|
||||
)
|
||||
return value
|
||||
if (
|
||||
str(char_specifier) == "00002a00-0000-1000-8000-00805f9b34fb"
|
||||
and BlueZFeatures.hides_device_name_characteristic
|
||||
):
|
||||
# Simulate regular characteristics read to be consistent over all platforms.
|
||||
manager = await get_global_bluez_manager()
|
||||
value = bytearray(manager.get_device_name(self._device_path).encode())
|
||||
logger.debug(
|
||||
"Read Device Name {0} | {1}: {2}".format(
|
||||
char_specifier, self._device_path, value
|
||||
)
|
||||
)
|
||||
return value
|
||||
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
while True:
|
||||
assert self._bus
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=characteristic.path,
|
||||
interface=defs.GATT_CHARACTERISTIC_INTERFACE,
|
||||
member="ReadValue",
|
||||
signature="a{sv}",
|
||||
body=[{}],
|
||||
)
|
||||
)
|
||||
|
||||
assert reply
|
||||
|
||||
if reply.error_name == "org.bluez.Error.InProgress":
|
||||
logger.debug("retrying characteristic ReadValue due to InProgress")
|
||||
# Avoid calling in a tight loop. There is no dbus signal to
|
||||
# indicate ready, so unfortunately, we have to poll.
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
assert_reply(reply)
|
||||
break
|
||||
|
||||
value = bytearray(reply.body[0])
|
||||
|
||||
logger.debug(
|
||||
"Read Characteristic {0} | {1}: {2}".format(
|
||||
characteristic.uuid, characteristic.path, value
|
||||
)
|
||||
)
|
||||
return value
|
||||
|
||||
async def read_gatt_descriptor(self, handle: int, **kwargs) -> bytearray:
|
||||
"""Perform read operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle (int): The handle of the descriptor to read from.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
descriptor = self.services.get_descriptor(handle)
|
||||
if not descriptor:
|
||||
raise BleakError("Descriptor with handle {0} was not found!".format(handle))
|
||||
|
||||
while True:
|
||||
assert self._bus
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=descriptor.path,
|
||||
interface=defs.GATT_DESCRIPTOR_INTERFACE,
|
||||
member="ReadValue",
|
||||
signature="a{sv}",
|
||||
body=[{}],
|
||||
)
|
||||
)
|
||||
|
||||
assert reply
|
||||
|
||||
if reply.error_name == "org.bluez.Error.InProgress":
|
||||
logger.debug("retrying descriptor ReadValue due to InProgress")
|
||||
# Avoid calling in a tight loop. There is no dbus signal to
|
||||
# indicate ready, so unfortunately, we have to poll.
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
assert_reply(reply)
|
||||
break
|
||||
|
||||
value = bytearray(reply.body[0])
|
||||
|
||||
logger.debug("Read Descriptor %s | %s: %s", handle, descriptor.path, value)
|
||||
return value
|
||||
|
||||
async def write_gatt_char(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
data: Buffer,
|
||||
response: bool,
|
||||
) -> None:
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
# See docstring for details about this handling.
|
||||
if not response and not BlueZFeatures.can_write_without_response:
|
||||
raise BleakError("Write without response requires at least BlueZ 5.46")
|
||||
|
||||
if response or not BlueZFeatures.write_without_response_workaround_needed:
|
||||
while True:
|
||||
assert self._bus
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=characteristic.path,
|
||||
interface=defs.GATT_CHARACTERISTIC_INTERFACE,
|
||||
member="WriteValue",
|
||||
signature="aya{sv}",
|
||||
body=[
|
||||
bytes(data),
|
||||
{
|
||||
"type": Variant(
|
||||
"s", "request" if response else "command"
|
||||
)
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert reply
|
||||
|
||||
if reply.error_name == "org.bluez.Error.InProgress":
|
||||
logger.debug("retrying characteristic WriteValue due to InProgress")
|
||||
# Avoid calling in a tight loop. There is no dbus signal to
|
||||
# indicate ready, so unfortunately, we have to poll.
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
assert_reply(reply)
|
||||
break
|
||||
else:
|
||||
# Older versions of BlueZ don't have the "type" option, so we have
|
||||
# to write the hard way. This isn't the most efficient way of doing
|
||||
# things, but it works.
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=characteristic.path,
|
||||
interface=defs.GATT_CHARACTERISTIC_INTERFACE,
|
||||
member="AcquireWrite",
|
||||
signature="a{sv}",
|
||||
body=[{}],
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
fd = reply.unix_fds[0]
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
logger.debug(
|
||||
"Write Characteristic %s | %s: %s",
|
||||
characteristic.uuid,
|
||||
characteristic.path,
|
||||
data,
|
||||
)
|
||||
|
||||
async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None:
|
||||
"""Perform a write operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle: The handle of the descriptor to read from.
|
||||
data: The data to send (any bytes-like object).
|
||||
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
descriptor = self.services.get_descriptor(handle)
|
||||
|
||||
if not descriptor:
|
||||
raise BleakError(f"Descriptor with handle {handle} was not found!")
|
||||
|
||||
while True:
|
||||
assert self._bus
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=descriptor.path,
|
||||
interface=defs.GATT_DESCRIPTOR_INTERFACE,
|
||||
member="WriteValue",
|
||||
signature="aya{sv}",
|
||||
body=[bytes(data), {"type": Variant("s", "command")}],
|
||||
)
|
||||
)
|
||||
|
||||
assert reply
|
||||
|
||||
if reply.error_name == "org.bluez.Error.InProgress":
|
||||
logger.debug("retrying descriptor WriteValue due to InProgress")
|
||||
# Avoid calling in a tight loop. There is no dbus signal to
|
||||
# indicate ready, so unfortunately, we have to poll.
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
assert_reply(reply)
|
||||
break
|
||||
|
||||
logger.debug("Write Descriptor %s | %s: %s", handle, descriptor.path, data)
|
||||
|
||||
async def start_notify(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
callback: NotifyCallback,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Activate notifications/indications on a characteristic.
|
||||
"""
|
||||
characteristic = cast(BleakGATTCharacteristicBlueZDBus, characteristic)
|
||||
|
||||
self._notification_callbacks[characteristic.path] = callback
|
||||
|
||||
assert self._bus is not None
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=characteristic.path,
|
||||
interface=defs.GATT_CHARACTERISTIC_INTERFACE,
|
||||
member="StartNotify",
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
|
||||
async def stop_notify(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristicBlueZDBus, int, str, UUID],
|
||||
) -> None:
|
||||
"""Deactivate notification/indication on a specified characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristicBlueZDBus, int, str or UUID): The characteristic to deactivate
|
||||
notification/indication on, specified by either integer handle, UUID or
|
||||
directly by the BleakGATTCharacteristicBlueZDBus object representing it.
|
||||
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise BleakError("Not connected")
|
||||
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristicBlueZDBus):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
reply = await self._bus.call(
|
||||
Message(
|
||||
destination=defs.BLUEZ_SERVICE,
|
||||
path=characteristic.path,
|
||||
interface=defs.GATT_CHARACTERISTIC_INTERFACE,
|
||||
member="StopNotify",
|
||||
)
|
||||
)
|
||||
assert_reply(reply)
|
||||
|
||||
self._notification_callbacks.pop(characteristic.path, None)
|
166
bleak/backends/bluezdbus/defs.py
Normal file
166
bleak/backends/bluezdbus/defs.py
Normal file
|
@ -0,0 +1,166 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from typing import Dict, List, Literal, Tuple, TypedDict
|
||||
|
||||
# DBus Interfaces
|
||||
OBJECT_MANAGER_INTERFACE = "org.freedesktop.DBus.ObjectManager"
|
||||
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
||||
|
||||
# Bluez specific DBUS
|
||||
BLUEZ_SERVICE = "org.bluez"
|
||||
ADAPTER_INTERFACE = "org.bluez.Adapter1"
|
||||
ADVERTISEMENT_MONITOR_INTERFACE = "org.bluez.AdvertisementMonitor1"
|
||||
ADVERTISEMENT_MONITOR_MANAGER_INTERFACE = "org.bluez.AdvertisementMonitorManager1"
|
||||
DEVICE_INTERFACE = "org.bluez.Device1"
|
||||
BATTERY_INTERFACE = "org.bluez.Battery1"
|
||||
|
||||
# GATT interfaces
|
||||
GATT_MANAGER_INTERFACE = "org.bluez.GattManager1"
|
||||
GATT_PROFILE_INTERFACE = "org.bluez.GattProfile1"
|
||||
GATT_SERVICE_INTERFACE = "org.bluez.GattService1"
|
||||
GATT_CHARACTERISTIC_INTERFACE = "org.bluez.GattCharacteristic1"
|
||||
GATT_DESCRIPTOR_INTERFACE = "org.bluez.GattDescriptor1"
|
||||
|
||||
|
||||
# D-Bus properties for interfaces
|
||||
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst
|
||||
|
||||
|
||||
class Adapter1(TypedDict):
|
||||
Address: str
|
||||
Name: str
|
||||
Alias: str
|
||||
Class: int
|
||||
Powered: bool
|
||||
Discoverable: bool
|
||||
Pairable: bool
|
||||
PairableTimeout: int
|
||||
DiscoverableTimeout: int
|
||||
Discovering: int
|
||||
UUIDs: List[str]
|
||||
Modalias: str
|
||||
Roles: List[str]
|
||||
ExperimentalFeatures: List[str]
|
||||
|
||||
|
||||
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitor.rst
|
||||
|
||||
|
||||
class AdvertisementMonitor1(TypedDict):
|
||||
Type: str
|
||||
RSSILowThreshold: int
|
||||
RSSIHighThreshold: int
|
||||
RSSILowTimeout: int
|
||||
RSSIHighTimeout: int
|
||||
RSSISamplingPeriod: int
|
||||
Patterns: List[Tuple[int, int, bytes]]
|
||||
|
||||
|
||||
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitorManager.rst
|
||||
|
||||
|
||||
class AdvertisementMonitorManager1(TypedDict):
|
||||
SupportedMonitorTypes: List[str]
|
||||
SupportedFeatures: List[str]
|
||||
|
||||
|
||||
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.Battery.rst
|
||||
|
||||
|
||||
class Battery1(TypedDict):
|
||||
SupportedMonitorTypes: List[str]
|
||||
SupportedFeatures: List[str]
|
||||
|
||||
|
||||
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.Device.rst
|
||||
|
||||
|
||||
class Device1(TypedDict):
|
||||
Address: str
|
||||
AddressType: str
|
||||
Name: str
|
||||
Icon: str
|
||||
Class: int
|
||||
Appearance: int
|
||||
UUIDs: List[str]
|
||||
Paired: bool
|
||||
Bonded: bool
|
||||
Connected: bool
|
||||
Trusted: bool
|
||||
Blocked: bool
|
||||
WakeAllowed: bool
|
||||
Alias: str
|
||||
Adapter: str
|
||||
LegacyPairing: bool
|
||||
Modalias: str
|
||||
RSSI: int
|
||||
TxPower: int
|
||||
ManufacturerData: Dict[int, bytes]
|
||||
ServiceData: Dict[str, bytes]
|
||||
ServicesResolved: bool
|
||||
AdvertisingFlags: bytes
|
||||
AdvertisingData: Dict[int, bytes]
|
||||
|
||||
|
||||
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattService.rst
|
||||
|
||||
|
||||
class GattService1(TypedDict):
|
||||
UUID: str
|
||||
Primary: bool
|
||||
Device: str
|
||||
Includes: List[str]
|
||||
# Handle is server-only and not available in Bleak
|
||||
|
||||
|
||||
class GattCharacteristic1(TypedDict):
|
||||
UUID: str
|
||||
Service: str
|
||||
Value: bytes
|
||||
WriteAcquired: bool
|
||||
NotifyAcquired: bool
|
||||
Notifying: bool
|
||||
Flags: List[
|
||||
Literal[
|
||||
"broadcast",
|
||||
"read",
|
||||
"write-without-response",
|
||||
"write",
|
||||
"notify",
|
||||
"indicate",
|
||||
"authenticated-signed-writes",
|
||||
"extended-properties",
|
||||
"reliable-write",
|
||||
"writable-auxiliaries",
|
||||
"encrypt-read",
|
||||
"encrypt-write",
|
||||
# "encrypt-notify" and "encrypt-indicate" are server-only
|
||||
"encrypt-authenticated-read",
|
||||
"encrypt-authenticated-write",
|
||||
# "encrypt-authenticated-notify", "encrypt-authenticated-indicate",
|
||||
# "secure-read", "secure-write", "secure-notify", "secure-indicate"
|
||||
# are server-only
|
||||
"authorize",
|
||||
]
|
||||
]
|
||||
MTU: int
|
||||
# Handle is server-only and not available in Bleak
|
||||
|
||||
|
||||
class GattDescriptor1(TypedDict):
|
||||
UUID: str
|
||||
Characteristic: str
|
||||
Value: bytes
|
||||
Flags: List[
|
||||
Literal[
|
||||
"read",
|
||||
"write",
|
||||
"encrypt-read",
|
||||
"encrypt-write",
|
||||
"encrypt-authenticated-read",
|
||||
"encrypt-authenticated-write",
|
||||
# "secure-read" and "secure-write" are server-only and not available in Bleak
|
||||
"authorize",
|
||||
]
|
||||
]
|
||||
# Handle is server-only and not available in Bleak
|
44
bleak/backends/bluezdbus/descriptor.py
Normal file
44
bleak/backends/bluezdbus/descriptor.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from ..descriptor import BleakGATTDescriptor
|
||||
from .defs import GattDescriptor1
|
||||
|
||||
|
||||
class BleakGATTDescriptorBlueZDBus(BleakGATTDescriptor):
|
||||
"""GATT Descriptor implementation for BlueZ DBus backend"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
obj: GattDescriptor1,
|
||||
object_path: str,
|
||||
characteristic_uuid: str,
|
||||
characteristic_handle: int,
|
||||
):
|
||||
super(BleakGATTDescriptorBlueZDBus, self).__init__(obj)
|
||||
self.__path = object_path
|
||||
self.__characteristic_uuid = characteristic_uuid
|
||||
self.__characteristic_handle = characteristic_handle
|
||||
self.__handle = int(self.path.split("/")[-1].replace("desc", ""), 16)
|
||||
|
||||
@property
|
||||
def characteristic_handle(self) -> int:
|
||||
"""Handle for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_handle
|
||||
|
||||
@property
|
||||
def characteristic_uuid(self) -> str:
|
||||
"""UUID for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_uuid
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""UUID for this descriptor"""
|
||||
return self.obj["UUID"]
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""Integer handle for this descriptor"""
|
||||
return self.__handle
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""The DBus path. Mostly needed by `bleak`, not by end user"""
|
||||
return self.__path
|
1060
bleak/backends/bluezdbus/manager.py
Normal file
1060
bleak/backends/bluezdbus/manager.py
Normal file
File diff suppressed because it is too large
Load diff
286
bleak/backends/bluezdbus/scanner.py
Normal file
286
bleak/backends/bluezdbus/scanner.py
Normal file
|
@ -0,0 +1,286 @@
|
|||
import logging
|
||||
from typing import Callable, Coroutine, Dict, List, Literal, Optional, TypedDict
|
||||
from warnings import warn
|
||||
|
||||
from dbus_fast import Variant
|
||||
|
||||
from ...exc import BleakError
|
||||
from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner
|
||||
from .advertisement_monitor import OrPatternLike
|
||||
from .defs import Device1
|
||||
from .manager import get_global_bluez_manager
|
||||
from .utils import bdaddr_from_device_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlueZDiscoveryFilters(TypedDict, total=False):
|
||||
"""
|
||||
Dictionary of arguments for the ``org.bluez.Adapter1.SetDiscoveryFilter``
|
||||
D-Bus method.
|
||||
|
||||
https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst#void-setdiscoveryfilterdict-filter
|
||||
"""
|
||||
|
||||
UUIDs: List[str]
|
||||
"""
|
||||
Filter by service UUIDs, empty means match _any_ UUID.
|
||||
|
||||
Normally, the ``service_uuids`` argument of :class:`bleak.BleakScanner`
|
||||
is used instead.
|
||||
"""
|
||||
RSSI: int
|
||||
"""
|
||||
RSSI threshold value.
|
||||
"""
|
||||
Pathloss: int
|
||||
"""
|
||||
Pathloss threshold value.
|
||||
"""
|
||||
Transport: str
|
||||
"""
|
||||
Transport parameter determines the type of scan.
|
||||
|
||||
This should not be used since it is required to be set to ``"le"``.
|
||||
"""
|
||||
DuplicateData: bool
|
||||
"""
|
||||
Disables duplicate detection of advertisement data.
|
||||
|
||||
This does not affect the ``Filter Duplicates`` parameter of the ``LE Set Scan Enable``
|
||||
HCI command to the Bluetooth adapter!
|
||||
|
||||
Although the default value for BlueZ is ``True``, Bleak sets this to ``False`` by default.
|
||||
"""
|
||||
Discoverable: bool
|
||||
"""
|
||||
Make adapter discoverable while discovering,
|
||||
if the adapter is already discoverable setting
|
||||
this filter won't do anything.
|
||||
"""
|
||||
Pattern: str
|
||||
"""
|
||||
Discover devices where the pattern matches
|
||||
either the prefix of the address or
|
||||
device name which is convenient way to limited
|
||||
the number of device objects created during a
|
||||
discovery.
|
||||
"""
|
||||
|
||||
|
||||
class BlueZScannerArgs(TypedDict, total=False):
|
||||
"""
|
||||
:class:`BleakScanner` args that are specific to the BlueZ backend.
|
||||
"""
|
||||
|
||||
filters: BlueZDiscoveryFilters
|
||||
"""
|
||||
Filters to pass to the adapter SetDiscoveryFilter D-Bus method.
|
||||
|
||||
Only used for active scanning.
|
||||
"""
|
||||
|
||||
or_patterns: List[OrPatternLike]
|
||||
"""
|
||||
Or patterns to pass to the AdvertisementMonitor1 D-Bus interface.
|
||||
|
||||
Only used for passive scanning.
|
||||
"""
|
||||
|
||||
|
||||
class BleakScannerBlueZDBus(BaseBleakScanner):
|
||||
"""The native Linux Bleak BLE Scanner.
|
||||
|
||||
For possible values for `filters`, see the parameters to the
|
||||
``SetDiscoveryFilter`` method in the `BlueZ docs
|
||||
<https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst#void-setdiscoveryfilterdict-filter>`_
|
||||
|
||||
Args:
|
||||
detection_callback:
|
||||
Optional function that will be called each time a device is
|
||||
discovered or advertising data has changed.
|
||||
service_uuids:
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received. Specifying this
|
||||
also enables scanning while the screen is off on Android.
|
||||
scanning_mode:
|
||||
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
|
||||
**bluez:
|
||||
Dictionary of arguments specific to the BlueZ backend.
|
||||
**adapter (str):
|
||||
Bluetooth adapter to use for discovery.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detection_callback: Optional[AdvertisementDataCallback],
|
||||
service_uuids: Optional[List[str]],
|
||||
scanning_mode: Literal["active", "passive"],
|
||||
*,
|
||||
bluez: BlueZScannerArgs,
|
||||
**kwargs,
|
||||
):
|
||||
super(BleakScannerBlueZDBus, self).__init__(detection_callback, service_uuids)
|
||||
|
||||
self._scanning_mode = scanning_mode
|
||||
|
||||
# kwarg "device" is for backwards compatibility
|
||||
self._adapter: Optional[str] = kwargs.get("adapter", kwargs.get("device"))
|
||||
|
||||
# callback from manager for stopping scanning if it has been started
|
||||
self._stop: Optional[Callable[[], Coroutine]] = None
|
||||
|
||||
# Discovery filters
|
||||
|
||||
self._filters: Dict[str, Variant] = {}
|
||||
|
||||
self._filters["Transport"] = Variant("s", "le")
|
||||
self._filters["DuplicateData"] = Variant("b", False)
|
||||
|
||||
if self._service_uuids:
|
||||
self._filters["UUIDs"] = Variant("as", self._service_uuids)
|
||||
|
||||
filters = kwargs.get("filters")
|
||||
|
||||
if filters is None:
|
||||
filters = bluez.get("filters")
|
||||
else:
|
||||
warn(
|
||||
"the 'filters' kwarg is deprecated, use 'bluez' kwarg instead",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if filters is not None:
|
||||
self.set_scanning_filter(filters=filters)
|
||||
|
||||
self._or_patterns = bluez.get("or_patterns")
|
||||
|
||||
if self._scanning_mode == "passive" and service_uuids:
|
||||
logger.warning(
|
||||
"service uuid filtering is not implemented for passive scanning, use bluez or_patterns as a workaround"
|
||||
)
|
||||
|
||||
if self._scanning_mode == "passive" and not self._or_patterns:
|
||||
raise BleakError("passive scanning mode requires bluez or_patterns")
|
||||
|
||||
async def start(self) -> None:
|
||||
manager = await get_global_bluez_manager()
|
||||
|
||||
if self._adapter:
|
||||
adapter_path = f"/org/bluez/{self._adapter}"
|
||||
else:
|
||||
adapter_path = manager.get_default_adapter()
|
||||
|
||||
self.seen_devices = {}
|
||||
|
||||
if self._scanning_mode == "passive":
|
||||
self._stop = await manager.passive_scan(
|
||||
adapter_path,
|
||||
self._or_patterns,
|
||||
self._handle_advertising_data,
|
||||
self._handle_device_removed,
|
||||
)
|
||||
else:
|
||||
self._stop = await manager.active_scan(
|
||||
adapter_path,
|
||||
self._filters,
|
||||
self._handle_advertising_data,
|
||||
self._handle_device_removed,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._stop:
|
||||
# avoid reentrancy
|
||||
stop, self._stop = self._stop, None
|
||||
|
||||
await stop()
|
||||
|
||||
def set_scanning_filter(self, **kwargs) -> None:
|
||||
"""Sets OS level scanning filters for the BleakScanner.
|
||||
|
||||
For possible values for `filters`, see the parameters to the
|
||||
``SetDiscoveryFilter`` method in the `BlueZ docs
|
||||
<https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst#void-setdiscoveryfilterdict-filter>`_
|
||||
|
||||
See variant types here: <https://python-dbus-next.readthedocs.io/en/latest/type-system/>
|
||||
|
||||
Keyword Args:
|
||||
filters (dict): A dict of filters to be applied on discovery.
|
||||
|
||||
"""
|
||||
for k, v in kwargs.get("filters", {}).items():
|
||||
if k == "UUIDs":
|
||||
self._filters[k] = Variant("as", v)
|
||||
elif k == "RSSI":
|
||||
self._filters[k] = Variant("n", v)
|
||||
elif k == "Pathloss":
|
||||
self._filters[k] = Variant("n", v)
|
||||
elif k == "Transport":
|
||||
self._filters[k] = Variant("s", v)
|
||||
elif k == "DuplicateData":
|
||||
self._filters[k] = Variant("b", v)
|
||||
elif k == "Discoverable":
|
||||
self._filters[k] = Variant("b", v)
|
||||
elif k == "Pattern":
|
||||
self._filters[k] = Variant("s", v)
|
||||
else:
|
||||
logger.warning("Filter '%s' is not currently supported." % k)
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _handle_advertising_data(self, path: str, props: Device1) -> None:
|
||||
"""
|
||||
Handles advertising data received from the BlueZ manager instance.
|
||||
|
||||
Args:
|
||||
path: The D-Bus object path of the device.
|
||||
props: The D-Bus object properties of the device.
|
||||
"""
|
||||
_service_uuids = props.get("UUIDs", [])
|
||||
|
||||
if not self.is_allowed_uuid(_service_uuids):
|
||||
return
|
||||
|
||||
# Get all the information wanted to pack in the advertisement data
|
||||
_local_name = props.get("Name")
|
||||
_manufacturer_data = {
|
||||
k: bytes(v) for k, v in props.get("ManufacturerData", {}).items()
|
||||
}
|
||||
_service_data = {k: bytes(v) for k, v in props.get("ServiceData", {}).items()}
|
||||
|
||||
# Get tx power data
|
||||
tx_power = props.get("TxPower")
|
||||
|
||||
# Pack the advertisement data
|
||||
advertisement_data = AdvertisementData(
|
||||
local_name=_local_name,
|
||||
manufacturer_data=_manufacturer_data,
|
||||
service_data=_service_data,
|
||||
service_uuids=_service_uuids,
|
||||
tx_power=tx_power,
|
||||
rssi=props.get("RSSI", -127),
|
||||
platform_data=(path, props),
|
||||
)
|
||||
|
||||
device = self.create_or_update_device(
|
||||
props["Address"],
|
||||
props["Alias"],
|
||||
{"path": path, "props": props},
|
||||
advertisement_data,
|
||||
)
|
||||
|
||||
self.call_detection_callbacks(device, advertisement_data)
|
||||
|
||||
def _handle_device_removed(self, device_path: str) -> None:
|
||||
"""
|
||||
Handles a device being removed from BlueZ.
|
||||
"""
|
||||
try:
|
||||
bdaddr = bdaddr_from_device_path(device_path)
|
||||
del self.seen_devices[bdaddr]
|
||||
except KeyError:
|
||||
# The device will not have been added to self.seen_devices if no
|
||||
# advertising data was received, so this is expected to happen
|
||||
# occasionally.
|
||||
pass
|
44
bleak/backends/bluezdbus/service.py
Normal file
44
bleak/backends/bluezdbus/service.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from typing import Any, List
|
||||
|
||||
from ..service import BleakGATTService
|
||||
from .characteristic import BleakGATTCharacteristicBlueZDBus
|
||||
from .utils import extract_service_handle_from_path
|
||||
|
||||
|
||||
class BleakGATTServiceBlueZDBus(BleakGATTService):
|
||||
"""GATT Service implementation for the BlueZ DBus backend"""
|
||||
|
||||
def __init__(self, obj: Any, path: str):
|
||||
super().__init__(obj)
|
||||
self.__characteristics = []
|
||||
self.__path = path
|
||||
self.__handle = extract_service_handle_from_path(path)
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""The UUID to this service"""
|
||||
return self.obj["UUID"]
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""The integer handle of this service"""
|
||||
return self.__handle
|
||||
|
||||
@property
|
||||
def characteristics(self) -> List[BleakGATTCharacteristicBlueZDBus]:
|
||||
"""List of characteristics for this service"""
|
||||
return self.__characteristics
|
||||
|
||||
def add_characteristic(
|
||||
self, characteristic: BleakGATTCharacteristicBlueZDBus
|
||||
) -> None:
|
||||
"""Add a :py:class:`~BleakGATTCharacteristicBlueZDBus` to the service.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__characteristics.append(characteristic)
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""The DBus path. Mostly needed by `bleak`, not by end user"""
|
||||
return self.__path
|
202
bleak/backends/bluezdbus/signals.py
Normal file
202
bleak/backends/bluezdbus/signals.py
Normal file
|
@ -0,0 +1,202 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Coroutine, Dict, Optional
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
from dbus_fast.errors import InvalidObjectPathError
|
||||
from dbus_fast.message import Message
|
||||
from dbus_fast.validators import (
|
||||
assert_interface_name_valid,
|
||||
assert_member_name_valid,
|
||||
assert_object_path_valid,
|
||||
)
|
||||
|
||||
# TODO: this stuff should be improved and submitted upstream to dbus-next
|
||||
# https://github.com/altdesktop/python-dbus-next/issues/53
|
||||
|
||||
_message_types = ["signal", "method_call", "method_return", "error"]
|
||||
|
||||
|
||||
class InvalidMessageTypeError(TypeError):
|
||||
def __init__(self, type: str):
|
||||
super().__init__(f"invalid message type: {type}")
|
||||
|
||||
|
||||
def is_message_type_valid(type: str) -> bool:
|
||||
"""Whether this is a valid message type.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules
|
||||
|
||||
:param type: The message type to validate.
|
||||
:type name: str
|
||||
|
||||
:returns: Whether the name is a valid message type.
|
||||
:rtype: bool
|
||||
"""
|
||||
return type in _message_types
|
||||
|
||||
|
||||
def assert_bus_name_valid(type: str) -> None:
|
||||
"""Raise an error if this is not a valid message type.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules
|
||||
|
||||
:param type: The message type to validate.
|
||||
:type name: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidMessageTypeError` - If this is not a valid message type.
|
||||
"""
|
||||
if not is_message_type_valid(type):
|
||||
raise InvalidMessageTypeError(type)
|
||||
|
||||
|
||||
class MatchRules:
|
||||
"""D-Bus signal match rules.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type: str = "signal",
|
||||
sender: Optional[str] = None,
|
||||
interface: Optional[str] = None,
|
||||
member: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
path_namespace: Optional[str] = None,
|
||||
destination: Optional[str] = None,
|
||||
arg0namespace: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
assert_bus_name_valid(type)
|
||||
self.type: str = type
|
||||
|
||||
if sender:
|
||||
assert_bus_name_valid(sender)
|
||||
self.sender: str = sender
|
||||
else:
|
||||
self.sender = None
|
||||
|
||||
if interface:
|
||||
assert_interface_name_valid(interface)
|
||||
self.interface: str = interface
|
||||
else:
|
||||
self.interface = None
|
||||
|
||||
if member:
|
||||
assert_member_name_valid(member)
|
||||
self.member: str = member
|
||||
else:
|
||||
self.member = None
|
||||
|
||||
if path:
|
||||
assert_object_path_valid(path)
|
||||
self.path: str = path
|
||||
else:
|
||||
self.path = None
|
||||
|
||||
if path_namespace:
|
||||
assert_object_path_valid(path_namespace)
|
||||
self.path_namespace: str = path_namespace
|
||||
else:
|
||||
self.path_namespace = None
|
||||
|
||||
if path and path_namespace:
|
||||
raise TypeError(
|
||||
"message rules cannot have both 'path' and 'path_namespace' at the same time"
|
||||
)
|
||||
|
||||
if destination:
|
||||
assert_bus_name_valid(destination)
|
||||
self.destination: str = destination
|
||||
else:
|
||||
self.destination = None
|
||||
|
||||
if arg0namespace:
|
||||
assert_bus_name_valid(arg0namespace)
|
||||
self.arg0namespace: str = arg0namespace
|
||||
else:
|
||||
self.arg0namespace = None
|
||||
|
||||
if kwargs:
|
||||
for k, v in kwargs.items():
|
||||
if re.match(r"^arg\d+$", k):
|
||||
if not isinstance(v, str):
|
||||
raise TypeError(f"kwarg '{k}' must have a str value")
|
||||
elif re.match(r"^arg\d+path$", k):
|
||||
if not isinstance(v, str):
|
||||
raise InvalidObjectPathError(v)
|
||||
assert_object_path_valid(v[:-1] if v.endswith("/") else v)
|
||||
else:
|
||||
raise ValueError("kwargs must be in the form 'arg0' or 'arg0path'")
|
||||
self.args: Dict[str, str] = kwargs
|
||||
else:
|
||||
self.args = None
|
||||
|
||||
@staticmethod
|
||||
def parse(rules: str) -> MatchRules:
|
||||
return MatchRules(**dict(r.split("=") for r in rules.split(",")))
|
||||
|
||||
def __str__(self) -> str:
|
||||
rules = [f"type={self.type}"]
|
||||
|
||||
if self.sender:
|
||||
rules.append(f"sender={self.sender}")
|
||||
|
||||
if self.interface:
|
||||
rules.append(f"interface={self.interface}")
|
||||
|
||||
if self.member:
|
||||
rules.append(f"member={self.member}")
|
||||
|
||||
if self.path:
|
||||
rules.append(f"path={self.path}")
|
||||
|
||||
if self.path_namespace:
|
||||
rules.append(f"path_namespace={self.path_namespace}")
|
||||
|
||||
if self.destination:
|
||||
rules.append(f"destination={self.destination}")
|
||||
|
||||
if self.args:
|
||||
for k, v in self.args.items():
|
||||
rules.append(f"{k}={v}")
|
||||
|
||||
if self.arg0namespace:
|
||||
rules.append(f"arg0namespace={self.arg0namespace}")
|
||||
|
||||
return ",".join(rules)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"MatchRules({self})"
|
||||
|
||||
|
||||
def add_match(bus: MessageBus, rules: MatchRules) -> Coroutine[Any, Any, Message]:
|
||||
"""Calls org.freedesktop.DBus.AddMatch using ``rules``."""
|
||||
return bus.call(
|
||||
Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
member="AddMatch",
|
||||
signature="s",
|
||||
body=[str(rules)],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def remove_match(bus: MessageBus, rules: MatchRules) -> Coroutine[Any, Any, Message]:
|
||||
"""Calls org.freedesktop.DBus.RemoveMatch using ``rules``."""
|
||||
return bus.call(
|
||||
Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
member="RemoveMatch",
|
||||
signature="s",
|
||||
body=[str(rules)],
|
||||
)
|
||||
)
|
69
bleak/backends/bluezdbus/utils.py
Normal file
69
bleak/backends/bluezdbus/utils.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from dbus_fast.auth import AuthExternal
|
||||
from dbus_fast.constants import MessageType
|
||||
from dbus_fast.message import Message
|
||||
|
||||
from ...exc import BleakDBusError, BleakError
|
||||
|
||||
|
||||
def assert_reply(reply: Message) -> None:
|
||||
"""Checks that a D-Bus message is a valid reply.
|
||||
|
||||
Raises:
|
||||
BleakDBusError: if the message type is ``MessageType.ERROR``
|
||||
AssertionError: if the message type is not ``MessageType.METHOD_RETURN``
|
||||
"""
|
||||
if reply.message_type == MessageType.ERROR:
|
||||
raise BleakDBusError(reply.error_name, reply.body)
|
||||
assert reply.message_type == MessageType.METHOD_RETURN
|
||||
|
||||
|
||||
def extract_service_handle_from_path(path: str) -> int:
|
||||
try:
|
||||
return int(path[-4:], 16)
|
||||
except Exception as e:
|
||||
raise BleakError(f"Could not parse service handle from path: {path}") from e
|
||||
|
||||
|
||||
def bdaddr_from_device_path(device_path: str) -> str:
|
||||
"""
|
||||
Scrape the Bluetooth address from a D-Bus device path.
|
||||
|
||||
Args:
|
||||
device_path: The D-Bus object path of the device.
|
||||
|
||||
Returns:
|
||||
A Bluetooth address as a string.
|
||||
"""
|
||||
return ":".join(device_path[-17:].split("_"))
|
||||
|
||||
|
||||
def device_path_from_characteristic_path(characteristic_path: str) -> str:
|
||||
"""
|
||||
Scrape the device path from a D-Bus characteristic path.
|
||||
|
||||
Args:
|
||||
characteristic_path: The D-Bus object path of the characteristic.
|
||||
|
||||
Returns:
|
||||
A D-Bus object path of the device.
|
||||
"""
|
||||
# /org/bluez/hci1/dev_FA_23_9D_AA_45_46/service000c/char000d
|
||||
return characteristic_path[:37]
|
||||
|
||||
|
||||
def get_dbus_authenticator() -> Optional[AuthExternal]:
|
||||
uid = None
|
||||
try:
|
||||
uid = int(os.environ.get("BLEAK_DBUS_AUTH_UID", ""))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
auth = None
|
||||
if uid is not None:
|
||||
auth = AuthExternal(uid=uid)
|
||||
|
||||
return auth
|
62
bleak/backends/bluezdbus/version.py
Normal file
62
bleak/backends/bluezdbus/version.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_bluetoothctl_version() -> Optional[re.Match]:
|
||||
"""Get the version of bluetoothctl."""
|
||||
with contextlib.suppress(Exception):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"bluetoothctl", "--version", stdout=asyncio.subprocess.PIPE
|
||||
)
|
||||
out = await proc.stdout.read()
|
||||
version = re.search(b"(\\d+).(\\d+)", out.strip(b"'"))
|
||||
await proc.wait()
|
||||
return version
|
||||
return None
|
||||
|
||||
|
||||
class BlueZFeatures:
|
||||
"""Check which features are supported by the BlueZ backend."""
|
||||
|
||||
checked_bluez_version = False
|
||||
supported_version = True
|
||||
can_write_without_response = True
|
||||
write_without_response_workaround_needed = False
|
||||
hides_battery_characteristic = True
|
||||
hides_device_name_characteristic = True
|
||||
_check_bluez_event: Optional[asyncio.Event] = None
|
||||
|
||||
@classmethod
|
||||
async def check_bluez_version(cls) -> None:
|
||||
"""Check the bluez version."""
|
||||
if cls._check_bluez_event:
|
||||
# If there is already a check in progress
|
||||
# it wins, wait for it instead
|
||||
await cls._check_bluez_event.wait()
|
||||
return
|
||||
cls._check_bluez_event = asyncio.Event()
|
||||
version_output = await _get_bluetoothctl_version()
|
||||
if version_output:
|
||||
major, minor = tuple(map(int, version_output.groups()))
|
||||
cls.supported_version = major == 5 and minor >= 34
|
||||
cls.can_write_without_response = major == 5 and minor >= 46
|
||||
cls.write_without_response_workaround_needed = not (
|
||||
major == 5 and minor >= 51
|
||||
)
|
||||
cls.hides_battery_characteristic = major == 5 and minor >= 48 and minor < 55
|
||||
cls.hides_device_name_characteristic = major == 5 and minor >= 48
|
||||
else:
|
||||
# Its possible they may be running inside a container where
|
||||
# bluetoothctl is not available and they only have access to the
|
||||
# BlueZ D-Bus API.
|
||||
logging.warning(
|
||||
"Could not determine BlueZ version, bluetoothctl not available, assuming 5.51+"
|
||||
)
|
||||
|
||||
cls._check_bluez_event.set()
|
||||
cls.checked_bluez_version = True
|
133
bleak/backends/characteristic.py
Normal file
133
bleak/backends/characteristic.py
Normal file
|
@ -0,0 +1,133 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Interface class for the Bleak representation of a GATT Characteristic
|
||||
|
||||
Created on 2019-03-19 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
||||
import abc
|
||||
import enum
|
||||
from typing import Any, Callable, List, Union
|
||||
from uuid import UUID
|
||||
|
||||
from ..uuids import uuidstr_to_str
|
||||
from .descriptor import BleakGATTDescriptor
|
||||
|
||||
|
||||
class GattCharacteristicsFlags(enum.Enum):
|
||||
broadcast = 0x0001
|
||||
read = 0x0002
|
||||
write_without_response = 0x0004
|
||||
write = 0x0008
|
||||
notify = 0x0010
|
||||
indicate = 0x0020
|
||||
authenticated_signed_writes = 0x0040
|
||||
extended_properties = 0x0080
|
||||
reliable_write = 0x0100
|
||||
writable_auxiliaries = 0x0200
|
||||
|
||||
|
||||
class BleakGATTCharacteristic(abc.ABC):
|
||||
"""Interface for the Bleak representation of a GATT Characteristic"""
|
||||
|
||||
def __init__(self, obj: Any, max_write_without_response_size: Callable[[], int]):
|
||||
"""
|
||||
Args:
|
||||
obj:
|
||||
A platform-specific object for this characteristic.
|
||||
max_write_without_response_size:
|
||||
The maximum size in bytes that can be written to the
|
||||
characteristic in a single write without response command.
|
||||
"""
|
||||
self.obj = obj
|
||||
self._max_write_without_response_size = max_write_without_response_size
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.uuid} (Handle: {self.handle}): {self.description}"
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def service_uuid(self) -> str:
|
||||
"""The UUID of the Service containing this characteristic"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def service_handle(self) -> int:
|
||||
"""The integer handle of the Service containing this characteristic"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def handle(self) -> int:
|
||||
"""The handle for this characteristic"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def uuid(self) -> str:
|
||||
"""The UUID for this characteristic"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Description for this characteristic"""
|
||||
return uuidstr_to_str(self.uuid)
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def properties(self) -> List[str]:
|
||||
"""Properties of this characteristic"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def max_write_without_response_size(self) -> int:
|
||||
"""
|
||||
Gets the maximum size in bytes that can be used for the *data* argument
|
||||
of :meth:`BleakClient.write_gatt_char()` when ``response=False``.
|
||||
|
||||
In rare cases, a device may take a long time to update this value, so
|
||||
reading this property may return the default value of ``20`` and reading
|
||||
it again after a some time may return the expected higher value.
|
||||
|
||||
If you *really* need to wait for a higher value, you can do something
|
||||
like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
while char.max_write_without_response_size == 20:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
.. warning:: Linux quirk: For BlueZ versions < 5.62, this property
|
||||
will always return ``20``.
|
||||
|
||||
.. versionadded:: 0.16
|
||||
"""
|
||||
|
||||
# for backwards compatibility
|
||||
if isinstance(self._max_write_without_response_size, int):
|
||||
return self._max_write_without_response_size
|
||||
|
||||
return self._max_write_without_response_size()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def descriptors(self) -> List[BleakGATTDescriptor]:
|
||||
"""List of descriptors for this service"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_descriptor(
|
||||
self, specifier: Union[int, str, UUID]
|
||||
) -> Union[BleakGATTDescriptor, None]:
|
||||
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
|
||||
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
raise NotImplementedError()
|
274
bleak/backends/client.py
Normal file
274
bleak/backends/client.py
Normal file
|
@ -0,0 +1,274 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Base class for backend clients.
|
||||
|
||||
Created on 2018-04-23 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
||||
import abc
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Callable, Optional, Type, Union
|
||||
from warnings import warn
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
from typing_extensions import Buffer
|
||||
else:
|
||||
from collections.abc import Buffer
|
||||
|
||||
from ..exc import BleakError
|
||||
from .characteristic import BleakGATTCharacteristic
|
||||
from .device import BLEDevice
|
||||
from .service import BleakGATTServiceCollection
|
||||
|
||||
NotifyCallback = Callable[[bytearray], None]
|
||||
|
||||
|
||||
class BaseBleakClient(abc.ABC):
|
||||
"""The Client Interface for Bleak Backend implementations to implement.
|
||||
|
||||
The documentation of this interface should thus be safe to use as a reference for your implementation.
|
||||
|
||||
Args:
|
||||
address_or_ble_device (`BLEDevice` or str): The Bluetooth address of the BLE peripheral to connect to or the `BLEDevice` object representing it.
|
||||
|
||||
Keyword Args:
|
||||
timeout (float): Timeout for required ``discover`` call. Defaults to 10.0.
|
||||
disconnected_callback (callable): Callback that will be scheduled in the
|
||||
event loop when the client is disconnected. The callable must take one
|
||||
argument, which will be this client object.
|
||||
"""
|
||||
|
||||
def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs):
|
||||
if isinstance(address_or_ble_device, BLEDevice):
|
||||
self.address = address_or_ble_device.address
|
||||
else:
|
||||
self.address = address_or_ble_device
|
||||
|
||||
self.services: Optional[BleakGATTServiceCollection] = None
|
||||
|
||||
self._timeout = kwargs.get("timeout", 10.0)
|
||||
self._disconnected_callback: Optional[Callable[[], None]] = kwargs.get(
|
||||
"disconnected_callback"
|
||||
)
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def mtu_size(self) -> int:
|
||||
"""Gets the negotiated MTU."""
|
||||
raise NotImplementedError
|
||||
|
||||
# Connectivity methods
|
||||
|
||||
def set_disconnected_callback(
|
||||
self, callback: Optional[Callable[[], None]], **kwargs
|
||||
) -> None:
|
||||
"""Set the disconnect callback.
|
||||
The callback will only be called on unsolicited disconnect event.
|
||||
|
||||
Set the callback to ``None`` to remove any existing callback.
|
||||
|
||||
Args:
|
||||
callback: callback to be called on disconnection.
|
||||
|
||||
"""
|
||||
self._disconnected_callback = callback
|
||||
|
||||
@abc.abstractmethod
|
||||
async def connect(self, **kwargs) -> bool:
|
||||
"""Connect to the specified GATT server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def disconnect(self) -> bool:
|
||||
"""Disconnect from the specified GATT server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def pair(self, *args, **kwargs) -> bool:
|
||||
"""Pair with the peripheral."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def unpair(self) -> bool:
|
||||
"""Unpair with the peripheral."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""Check connection status between this client and the server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
class _DeprecatedIsConnectedReturn:
|
||||
"""Wrapper for ``is_connected`` return value to provide deprecation warning."""
|
||||
|
||||
def __init__(self, value: bool):
|
||||
self._value = value
|
||||
|
||||
def __bool__(self):
|
||||
return self._value
|
||||
|
||||
def __call__(self) -> bool:
|
||||
warn(
|
||||
"is_connected has been changed to a property. Calling it as an async method will be removed in a future version",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
f = asyncio.Future()
|
||||
f.set_result(self._value)
|
||||
return f
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return repr(self._value)
|
||||
|
||||
# GATT services methods
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_services(self, **kwargs) -> BleakGATTServiceCollection:
|
||||
"""Get all services registered for this GATT server.
|
||||
|
||||
Returns:
|
||||
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
# I/O methods
|
||||
|
||||
@abc.abstractmethod
|
||||
async def read_gatt_char(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID],
|
||||
**kwargs,
|
||||
) -> bytearray:
|
||||
"""Perform read operation on the specified GATT characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to read from,
|
||||
specified by either integer handle, UUID or directly by the
|
||||
BleakGATTCharacteristic object representing it.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def read_gatt_descriptor(self, handle: int, **kwargs) -> bytearray:
|
||||
"""Perform read operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle (int): The handle of the descriptor to read from.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def write_gatt_char(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
data: Buffer,
|
||||
response: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Perform a write operation on the specified GATT characteristic.
|
||||
|
||||
Args:
|
||||
characteristic: The characteristic to write to.
|
||||
data: The data to send.
|
||||
response: If write-with-response operation should be done.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None:
|
||||
"""Perform a write operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle: The handle of the descriptor to read from.
|
||||
data: The data to send (any bytes-like object).
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def start_notify(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
callback: NotifyCallback,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Activate notifications/indications on a characteristic.
|
||||
|
||||
Implementers should call the OS function to enable notifications or
|
||||
indications on the characteristic.
|
||||
|
||||
To keep things the same cross-platform, notifications should be preferred
|
||||
over indications if possible when a characteristic supports both.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def stop_notify(
|
||||
self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID]
|
||||
) -> None:
|
||||
"""Deactivate notification/indication on a specified characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to deactivate
|
||||
notification/indication on, specified by either integer handle, UUID or
|
||||
directly by the BleakGATTCharacteristic object representing it.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def get_platform_client_backend_type() -> Type[BaseBleakClient]:
|
||||
"""
|
||||
Gets the platform-specific :class:`BaseBleakClient` type.
|
||||
"""
|
||||
if os.environ.get("P4A_BOOTSTRAP") is not None:
|
||||
from bleak.backends.p4android.client import BleakClientP4Android
|
||||
|
||||
return BleakClientP4Android
|
||||
|
||||
if platform.system() == "Linux":
|
||||
from bleak.backends.bluezdbus.client import BleakClientBlueZDBus
|
||||
|
||||
return BleakClientBlueZDBus
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
from bleak.backends.corebluetooth.client import BleakClientCoreBluetooth
|
||||
|
||||
return BleakClientCoreBluetooth
|
||||
|
||||
if platform.system() == "Windows":
|
||||
from bleak.backends.winrt.client import BleakClientWinRT
|
||||
|
||||
return BleakClientWinRT
|
||||
|
||||
raise BleakError(f"Unsupported platform: {platform.system()}")
|
371
bleak/backends/corebluetooth/CentralManagerDelegate.py
Normal file
371
bleak/backends/corebluetooth/CentralManagerDelegate.py
Normal file
|
@ -0,0 +1,371 @@
|
|||
"""
|
||||
CentralManagerDelegate will implement the CBCentralManagerDelegate protocol to
|
||||
manage CoreBluetooth services and resources on the Central End
|
||||
|
||||
Created on June, 25 2019 by kevincar <kevincarrolldavis@gmail.com>
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from async_timeout import timeout as async_timeout
|
||||
else:
|
||||
from asyncio import timeout as async_timeout
|
||||
|
||||
import objc
|
||||
from CoreBluetooth import (
|
||||
CBUUID,
|
||||
CBCentralManager,
|
||||
CBManagerStatePoweredOff,
|
||||
CBManagerStatePoweredOn,
|
||||
CBManagerStateResetting,
|
||||
CBManagerStateUnauthorized,
|
||||
CBManagerStateUnknown,
|
||||
CBManagerStateUnsupported,
|
||||
CBPeripheral,
|
||||
)
|
||||
from Foundation import (
|
||||
NSUUID,
|
||||
NSArray,
|
||||
NSDictionary,
|
||||
NSError,
|
||||
NSKeyValueChangeNewKey,
|
||||
NSKeyValueObservingOptionNew,
|
||||
NSNumber,
|
||||
NSObject,
|
||||
NSString,
|
||||
)
|
||||
from libdispatch import DISPATCH_QUEUE_SERIAL, dispatch_queue_create
|
||||
|
||||
from ...exc import BleakError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CBCentralManagerDelegate = objc.protocolNamed("CBCentralManagerDelegate")
|
||||
|
||||
|
||||
DisconnectCallback = Callable[[], None]
|
||||
|
||||
|
||||
class CentralManagerDelegate(NSObject):
|
||||
"""macOS conforming python class for managing the CentralManger for BLE"""
|
||||
|
||||
___pyobjc_protocols__ = [CBCentralManagerDelegate]
|
||||
|
||||
def init(self) -> Optional["CentralManagerDelegate"]:
|
||||
"""macOS init function for NSObject"""
|
||||
self = objc.super(CentralManagerDelegate, self).init()
|
||||
|
||||
if self is None:
|
||||
return None
|
||||
|
||||
self.event_loop = asyncio.get_running_loop()
|
||||
self._connect_futures: Dict[NSUUID, asyncio.Future] = {}
|
||||
|
||||
self.callbacks: Dict[
|
||||
int, Callable[[CBPeripheral, Dict[str, Any], int], None]
|
||||
] = {}
|
||||
self._disconnect_callbacks: Dict[NSUUID, DisconnectCallback] = {}
|
||||
self._disconnect_futures: Dict[NSUUID, asyncio.Future] = {}
|
||||
|
||||
self._did_update_state_event = threading.Event()
|
||||
self.central_manager = CBCentralManager.alloc().initWithDelegate_queue_(
|
||||
self, dispatch_queue_create(b"bleak.corebluetooth", DISPATCH_QUEUE_SERIAL)
|
||||
)
|
||||
|
||||
# according to CoreBluetooth docs, it is not valid to call CBCentral
|
||||
# methods until the centralManagerDidUpdateState_() delegate method
|
||||
# is called and the current state is CBManagerStatePoweredOn.
|
||||
# It doesn't take long for the callback to occur, so we should be able
|
||||
# to do a blocking wait here without anyone complaining.
|
||||
self._did_update_state_event.wait(1)
|
||||
|
||||
if self.central_manager.state() == CBManagerStateUnsupported:
|
||||
raise BleakError("BLE is unsupported")
|
||||
|
||||
if self.central_manager.state() == CBManagerStateUnauthorized:
|
||||
raise BleakError("BLE is not authorized - check macOS privacy settings")
|
||||
|
||||
if self.central_manager.state() != CBManagerStatePoweredOn:
|
||||
raise BleakError("Bluetooth device is turned off")
|
||||
|
||||
# isScanning property was added in 10.13
|
||||
if objc.macos_available(10, 13):
|
||||
self.central_manager.addObserver_forKeyPath_options_context_(
|
||||
self, "isScanning", NSKeyValueObservingOptionNew, 0
|
||||
)
|
||||
self._did_start_scanning_event: Optional[asyncio.Event] = None
|
||||
self._did_stop_scanning_event: Optional[asyncio.Event] = None
|
||||
|
||||
return self
|
||||
|
||||
def __del__(self) -> None:
|
||||
if objc.macos_available(10, 13):
|
||||
try:
|
||||
self.central_manager.removeObserver_forKeyPath_(self, "isScanning")
|
||||
except IndexError:
|
||||
# If self.init() raised an exception before calling
|
||||
# addObserver_forKeyPath_options_context_, attempting
|
||||
# to remove the observer will fail with IndexError
|
||||
pass
|
||||
|
||||
# User defined functions
|
||||
|
||||
@objc.python_method
|
||||
async def start_scan(self, service_uuids: Optional[List[str]]) -> None:
|
||||
service_uuids = (
|
||||
NSArray.alloc().initWithArray_(
|
||||
list(map(CBUUID.UUIDWithString_, service_uuids))
|
||||
)
|
||||
if service_uuids
|
||||
else None
|
||||
)
|
||||
|
||||
self.central_manager.scanForPeripheralsWithServices_options_(
|
||||
service_uuids, None
|
||||
)
|
||||
|
||||
# The `isScanning` property was added in macOS 10.13, so before that
|
||||
# just waiting some will have to do.
|
||||
if objc.macos_available(10, 13):
|
||||
event = asyncio.Event()
|
||||
self._did_start_scanning_event = event
|
||||
if not self.central_manager.isScanning():
|
||||
await event.wait()
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@objc.python_method
|
||||
async def stop_scan(self) -> None:
|
||||
self.central_manager.stopScan()
|
||||
|
||||
# The `isScanning` property was added in macOS 10.13, so before that
|
||||
# just waiting some will have to do.
|
||||
if objc.macos_available(10, 13):
|
||||
event = asyncio.Event()
|
||||
self._did_stop_scanning_event = event
|
||||
if self.central_manager.isScanning():
|
||||
await event.wait()
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@objc.python_method
|
||||
async def connect(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
disconnect_callback: DisconnectCallback,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
try:
|
||||
self._disconnect_callbacks[peripheral.identifier()] = disconnect_callback
|
||||
future = self.event_loop.create_future()
|
||||
|
||||
self._connect_futures[peripheral.identifier()] = future
|
||||
try:
|
||||
self.central_manager.connectPeripheral_options_(peripheral, None)
|
||||
async with async_timeout(timeout):
|
||||
await future
|
||||
finally:
|
||||
del self._connect_futures[peripheral.identifier()]
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug(f"Connection timed out after {timeout} seconds.")
|
||||
del self._disconnect_callbacks[peripheral.identifier()]
|
||||
future = self.event_loop.create_future()
|
||||
|
||||
self._disconnect_futures[peripheral.identifier()] = future
|
||||
try:
|
||||
self.central_manager.cancelPeripheralConnection_(peripheral)
|
||||
await future
|
||||
finally:
|
||||
del self._disconnect_futures[peripheral.identifier()]
|
||||
|
||||
raise
|
||||
|
||||
@objc.python_method
|
||||
async def disconnect(self, peripheral: CBPeripheral) -> None:
|
||||
future = self.event_loop.create_future()
|
||||
|
||||
self._disconnect_futures[peripheral.identifier()] = future
|
||||
try:
|
||||
self.central_manager.cancelPeripheralConnection_(peripheral)
|
||||
await future
|
||||
finally:
|
||||
del self._disconnect_futures[peripheral.identifier()]
|
||||
|
||||
@objc.python_method
|
||||
def _changed_is_scanning(self, is_scanning: bool) -> None:
|
||||
if is_scanning:
|
||||
if self._did_start_scanning_event:
|
||||
self._did_start_scanning_event.set()
|
||||
else:
|
||||
if self._did_stop_scanning_event:
|
||||
self._did_stop_scanning_event.set()
|
||||
|
||||
def observeValueForKeyPath_ofObject_change_context_(
|
||||
self, keyPath: NSString, object: Any, change: NSDictionary, context: int
|
||||
) -> None:
|
||||
logger.debug("'%s' changed", keyPath)
|
||||
|
||||
if keyPath != "isScanning":
|
||||
return
|
||||
|
||||
is_scanning = bool(change[NSKeyValueChangeNewKey])
|
||||
self.event_loop.call_soon_threadsafe(self._changed_is_scanning, is_scanning)
|
||||
|
||||
# Protocol Functions
|
||||
|
||||
def centralManagerDidUpdateState_(self, centralManager: CBCentralManager) -> None:
|
||||
logger.debug("centralManagerDidUpdateState_")
|
||||
if centralManager.state() == CBManagerStateUnknown:
|
||||
logger.debug("Cannot detect bluetooth device")
|
||||
elif centralManager.state() == CBManagerStateResetting:
|
||||
logger.debug("Bluetooth is resetting")
|
||||
elif centralManager.state() == CBManagerStateUnsupported:
|
||||
logger.debug("Bluetooth is unsupported")
|
||||
elif centralManager.state() == CBManagerStateUnauthorized:
|
||||
logger.debug("Bluetooth is unauthorized")
|
||||
elif centralManager.state() == CBManagerStatePoweredOff:
|
||||
logger.debug("Bluetooth powered off")
|
||||
elif centralManager.state() == CBManagerStatePoweredOn:
|
||||
logger.debug("Bluetooth powered on")
|
||||
|
||||
self._did_update_state_event.set()
|
||||
|
||||
@objc.python_method
|
||||
def did_discover_peripheral(
|
||||
self,
|
||||
central: CBCentralManager,
|
||||
peripheral: CBPeripheral,
|
||||
advertisementData: NSDictionary,
|
||||
RSSI: NSNumber,
|
||||
) -> None:
|
||||
# Note: this function might be called several times for same device.
|
||||
# This can happen for instance when an active scan is done, and the
|
||||
# second call with contain the data from the BLE scan response.
|
||||
# Example a first time with the following keys in advertisementData:
|
||||
# ['kCBAdvDataLocalName', 'kCBAdvDataIsConnectable', 'kCBAdvDataChannel']
|
||||
# ... and later a second time with other keys (and values) such as:
|
||||
# ['kCBAdvDataServiceUUIDs', 'kCBAdvDataIsConnectable', 'kCBAdvDataChannel']
|
||||
#
|
||||
# i.e it is best not to trust advertisementData for later use and data
|
||||
# from it should be copied.
|
||||
#
|
||||
# This behaviour could be affected by the
|
||||
# CBCentralManagerScanOptionAllowDuplicatesKey global setting.
|
||||
|
||||
uuid_string = peripheral.identifier().UUIDString()
|
||||
|
||||
for callback in self.callbacks.values():
|
||||
if callback:
|
||||
callback(peripheral, advertisementData, RSSI)
|
||||
|
||||
logger.debug(
|
||||
"Discovered device %s: %s @ RSSI: %d (kCBAdvData %r) and Central: %r",
|
||||
uuid_string,
|
||||
peripheral.name(),
|
||||
RSSI,
|
||||
advertisementData.keys(),
|
||||
central,
|
||||
)
|
||||
|
||||
def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(
|
||||
self,
|
||||
central: CBCentralManager,
|
||||
peripheral: CBPeripheral,
|
||||
advertisementData: NSDictionary,
|
||||
RSSI: NSNumber,
|
||||
) -> None:
|
||||
logger.debug("centralManager_didDiscoverPeripheral_advertisementData_RSSI_")
|
||||
self.event_loop.call_soon_threadsafe(
|
||||
self.did_discover_peripheral,
|
||||
central,
|
||||
peripheral,
|
||||
advertisementData,
|
||||
RSSI,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_connect_peripheral(
|
||||
self, central: CBCentralManager, peripheral: CBPeripheral
|
||||
) -> None:
|
||||
future = self._connect_futures.get(peripheral.identifier(), None)
|
||||
if future is not None:
|
||||
future.set_result(True)
|
||||
|
||||
def centralManager_didConnectPeripheral_(
|
||||
self, central: CBCentralManager, peripheral: CBPeripheral
|
||||
) -> None:
|
||||
logger.debug("centralManager_didConnectPeripheral_")
|
||||
self.event_loop.call_soon_threadsafe(
|
||||
self.did_connect_peripheral,
|
||||
central,
|
||||
peripheral,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_fail_to_connect_peripheral(
|
||||
self,
|
||||
centralManager: CBCentralManager,
|
||||
peripheral: CBPeripheral,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
future = self._connect_futures.get(peripheral.identifier(), None)
|
||||
if future is not None:
|
||||
if error is not None:
|
||||
future.set_exception(BleakError(f"failed to connect: {error}"))
|
||||
else:
|
||||
future.set_result(False)
|
||||
|
||||
def centralManager_didFailToConnectPeripheral_error_(
|
||||
self,
|
||||
centralManager: CBCentralManager,
|
||||
peripheral: CBPeripheral,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("centralManager_didFailToConnectPeripheral_error_")
|
||||
self.event_loop.call_soon_threadsafe(
|
||||
self.did_fail_to_connect_peripheral,
|
||||
centralManager,
|
||||
peripheral,
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_disconnect_peripheral(
|
||||
self,
|
||||
central: CBCentralManager,
|
||||
peripheral: CBPeripheral,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("Peripheral Device disconnected!")
|
||||
|
||||
future = self._disconnect_futures.get(peripheral.identifier(), None)
|
||||
if future is not None:
|
||||
if error is not None:
|
||||
future.set_exception(BleakError(f"disconnect failed: {error}"))
|
||||
else:
|
||||
future.set_result(None)
|
||||
|
||||
callback = self._disconnect_callbacks.pop(peripheral.identifier(), None)
|
||||
|
||||
if callback is not None:
|
||||
callback()
|
||||
|
||||
def centralManager_didDisconnectPeripheral_error_(
|
||||
self,
|
||||
central: CBCentralManager,
|
||||
peripheral: CBPeripheral,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("centralManager_didDisconnectPeripheral_error_")
|
||||
self.event_loop.call_soon_threadsafe(
|
||||
self.did_disconnect_peripheral,
|
||||
central,
|
||||
peripheral,
|
||||
error,
|
||||
)
|
629
bleak/backends/corebluetooth/PeripheralDelegate.py
Normal file
629
bleak/backends/corebluetooth/PeripheralDelegate.py
Normal file
|
@ -0,0 +1,629 @@
|
|||
"""
|
||||
|
||||
PeripheralDelegate
|
||||
|
||||
Created by kevincar <kevincarrolldavis@gmail.com>
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, Iterable, NewType, Optional
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from async_timeout import timeout as async_timeout
|
||||
else:
|
||||
from asyncio import timeout as async_timeout
|
||||
|
||||
import objc
|
||||
from CoreBluetooth import (
|
||||
CBCharacteristic,
|
||||
CBCharacteristicWriteWithResponse,
|
||||
CBDescriptor,
|
||||
CBPeripheral,
|
||||
CBService,
|
||||
)
|
||||
from Foundation import NSUUID, NSArray, NSData, NSError, NSNumber, NSObject, NSString
|
||||
|
||||
from ...exc import BleakError
|
||||
from ..client import NotifyCallback
|
||||
|
||||
# logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CBPeripheralDelegate = objc.protocolNamed("CBPeripheralDelegate")
|
||||
|
||||
CBCharacteristicWriteType = NewType("CBCharacteristicWriteType", int)
|
||||
|
||||
|
||||
class PeripheralDelegate(NSObject):
|
||||
"""macOS conforming python class for managing the PeripheralDelegate for BLE"""
|
||||
|
||||
___pyobjc_protocols__ = [CBPeripheralDelegate]
|
||||
|
||||
def initWithPeripheral_(
|
||||
self, peripheral: CBPeripheral
|
||||
) -> Optional[PeripheralDelegate]:
|
||||
"""macOS init function for NSObject"""
|
||||
self = objc.super(PeripheralDelegate, self).init()
|
||||
|
||||
if self is None:
|
||||
return None
|
||||
|
||||
self.peripheral = peripheral
|
||||
self.peripheral.setDelegate_(self)
|
||||
|
||||
self._event_loop = asyncio.get_running_loop()
|
||||
self._services_discovered_future = self._event_loop.create_future()
|
||||
|
||||
self._service_characteristic_discovered_futures: Dict[int, asyncio.Future] = {}
|
||||
self._characteristic_descriptor_discover_futures: Dict[int, asyncio.Future] = {}
|
||||
|
||||
self._characteristic_read_futures: Dict[int, asyncio.Future] = {}
|
||||
self._characteristic_write_futures: Dict[int, asyncio.Future] = {}
|
||||
|
||||
self._descriptor_read_futures: Dict[int, asyncio.Future] = {}
|
||||
self._descriptor_write_futures: Dict[int, asyncio.Future] = {}
|
||||
|
||||
self._characteristic_notify_change_futures: Dict[int, asyncio.Future] = {}
|
||||
self._characteristic_notify_callbacks: Dict[int, NotifyCallback] = {}
|
||||
|
||||
self._read_rssi_futures: Dict[NSUUID, asyncio.Future] = {}
|
||||
|
||||
return self
|
||||
|
||||
@objc.python_method
|
||||
def futures(self) -> Iterable[asyncio.Future]:
|
||||
"""
|
||||
Gets all futures for this delegate.
|
||||
|
||||
These can be used to handle any pending futures when a peripheral is disconnected.
|
||||
"""
|
||||
services_discovered_future = (
|
||||
(self._services_discovered_future,)
|
||||
if hasattr(self, "_services_discovered_future")
|
||||
else ()
|
||||
)
|
||||
|
||||
return itertools.chain(
|
||||
services_discovered_future,
|
||||
self._service_characteristic_discovered_futures.values(),
|
||||
self._characteristic_descriptor_discover_futures.values(),
|
||||
self._characteristic_read_futures.values(),
|
||||
self._characteristic_write_futures.values(),
|
||||
self._descriptor_read_futures.values(),
|
||||
self._descriptor_write_futures.values(),
|
||||
self._characteristic_notify_change_futures.values(),
|
||||
self._read_rssi_futures.values(),
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
async def discover_services(self, services: Optional[NSArray]) -> NSArray:
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._services_discovered_future = future
|
||||
try:
|
||||
self.peripheral.discoverServices_(services)
|
||||
return await future
|
||||
finally:
|
||||
del self._services_discovered_future
|
||||
|
||||
@objc.python_method
|
||||
async def discover_characteristics(self, service: CBService) -> NSArray:
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._service_characteristic_discovered_futures[service.startHandle()] = future
|
||||
try:
|
||||
self.peripheral.discoverCharacteristics_forService_(None, service)
|
||||
return await future
|
||||
finally:
|
||||
del self._service_characteristic_discovered_futures[service.startHandle()]
|
||||
|
||||
@objc.python_method
|
||||
async def discover_descriptors(self, characteristic: CBCharacteristic) -> NSArray:
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._characteristic_descriptor_discover_futures[characteristic.handle()] = (
|
||||
future
|
||||
)
|
||||
try:
|
||||
self.peripheral.discoverDescriptorsForCharacteristic_(characteristic)
|
||||
await future
|
||||
finally:
|
||||
del self._characteristic_descriptor_discover_futures[
|
||||
characteristic.handle()
|
||||
]
|
||||
|
||||
return characteristic.descriptors()
|
||||
|
||||
@objc.python_method
|
||||
async def read_characteristic(
|
||||
self,
|
||||
characteristic: CBCharacteristic,
|
||||
use_cached: bool = True,
|
||||
timeout: int = 20,
|
||||
) -> NSData:
|
||||
if characteristic.value() is not None and use_cached:
|
||||
return characteristic.value()
|
||||
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._characteristic_read_futures[characteristic.handle()] = future
|
||||
try:
|
||||
self.peripheral.readValueForCharacteristic_(characteristic)
|
||||
async with async_timeout(timeout):
|
||||
return await future
|
||||
finally:
|
||||
del self._characteristic_read_futures[characteristic.handle()]
|
||||
|
||||
@objc.python_method
|
||||
async def read_descriptor(
|
||||
self, descriptor: CBDescriptor, use_cached: bool = True
|
||||
) -> Any:
|
||||
if descriptor.value() is not None and use_cached:
|
||||
return descriptor.value()
|
||||
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._descriptor_read_futures[descriptor.handle()] = future
|
||||
try:
|
||||
self.peripheral.readValueForDescriptor_(descriptor)
|
||||
return await future
|
||||
finally:
|
||||
del self._descriptor_read_futures[descriptor.handle()]
|
||||
|
||||
@objc.python_method
|
||||
async def write_characteristic(
|
||||
self,
|
||||
characteristic: CBCharacteristic,
|
||||
value: NSData,
|
||||
response: CBCharacteristicWriteType,
|
||||
) -> None:
|
||||
# in CoreBluetooth there is no indication of success or failure of
|
||||
# CBCharacteristicWriteWithoutResponse
|
||||
if response == CBCharacteristicWriteWithResponse:
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._characteristic_write_futures[characteristic.handle()] = future
|
||||
try:
|
||||
self.peripheral.writeValue_forCharacteristic_type_(
|
||||
value, characteristic, response
|
||||
)
|
||||
await future
|
||||
finally:
|
||||
del self._characteristic_write_futures[characteristic.handle()]
|
||||
else:
|
||||
self.peripheral.writeValue_forCharacteristic_type_(
|
||||
value, characteristic, response
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
async def write_descriptor(self, descriptor: CBDescriptor, value: NSData) -> None:
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._descriptor_write_futures[descriptor.handle()] = future
|
||||
try:
|
||||
self.peripheral.writeValue_forDescriptor_(value, descriptor)
|
||||
await future
|
||||
finally:
|
||||
del self._descriptor_write_futures[descriptor.handle()]
|
||||
|
||||
@objc.python_method
|
||||
async def start_notifications(
|
||||
self, characteristic: CBCharacteristic, callback: NotifyCallback
|
||||
) -> None:
|
||||
c_handle = characteristic.handle()
|
||||
if c_handle in self._characteristic_notify_callbacks:
|
||||
raise ValueError("Characteristic notifications already started")
|
||||
|
||||
self._characteristic_notify_callbacks[c_handle] = callback
|
||||
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._characteristic_notify_change_futures[c_handle] = future
|
||||
try:
|
||||
self.peripheral.setNotifyValue_forCharacteristic_(True, characteristic)
|
||||
await future
|
||||
finally:
|
||||
del self._characteristic_notify_change_futures[c_handle]
|
||||
|
||||
@objc.python_method
|
||||
async def stop_notifications(self, characteristic: CBCharacteristic) -> None:
|
||||
c_handle = characteristic.handle()
|
||||
if c_handle not in self._characteristic_notify_callbacks:
|
||||
raise ValueError("Characteristic notification never started")
|
||||
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._characteristic_notify_change_futures[c_handle] = future
|
||||
try:
|
||||
self.peripheral.setNotifyValue_forCharacteristic_(False, characteristic)
|
||||
await future
|
||||
finally:
|
||||
del self._characteristic_notify_change_futures[c_handle]
|
||||
|
||||
self._characteristic_notify_callbacks.pop(c_handle)
|
||||
|
||||
@objc.python_method
|
||||
async def read_rssi(self) -> NSNumber:
|
||||
future = self._event_loop.create_future()
|
||||
|
||||
self._read_rssi_futures[self.peripheral.identifier()] = future
|
||||
try:
|
||||
self.peripheral.readRSSI()
|
||||
return await future
|
||||
finally:
|
||||
del self._read_rssi_futures[self.peripheral.identifier()]
|
||||
|
||||
# Protocol Functions
|
||||
|
||||
@objc.python_method
|
||||
def did_discover_services(
|
||||
self, peripheral: CBPeripheral, services: NSArray, error: Optional[NSError]
|
||||
) -> None:
|
||||
future = self._services_discovered_future
|
||||
if error is not None:
|
||||
exception = BleakError(f"Failed to discover services {error}")
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Services discovered")
|
||||
future.set_result(services)
|
||||
|
||||
def peripheral_didDiscoverServices_(
|
||||
self, peripheral: CBPeripheral, error: Optional[NSError]
|
||||
) -> None:
|
||||
logger.debug("peripheral_didDiscoverServices_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_discover_services,
|
||||
peripheral,
|
||||
peripheral.services(),
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_discover_characteristics_for_service(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
service: CBService,
|
||||
characteristics: NSArray,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
future = self._service_characteristic_discovered_futures.get(
|
||||
service.startHandle()
|
||||
)
|
||||
if not future:
|
||||
logger.debug(
|
||||
f"Unexpected event didDiscoverCharacteristicsForService for {service.startHandle()}"
|
||||
)
|
||||
return
|
||||
if error is not None:
|
||||
exception = BleakError(
|
||||
f"Failed to discover characteristics for service {service.startHandle()}: {error}"
|
||||
)
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Characteristics discovered")
|
||||
future.set_result(characteristics)
|
||||
|
||||
def peripheral_didDiscoverCharacteristicsForService_error_(
|
||||
self, peripheral: CBPeripheral, service: CBService, error: Optional[NSError]
|
||||
) -> None:
|
||||
logger.debug("peripheral_didDiscoverCharacteristicsForService_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_discover_characteristics_for_service,
|
||||
peripheral,
|
||||
service,
|
||||
service.characteristics(),
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_discover_descriptors_for_characteristic(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
future = self._characteristic_descriptor_discover_futures.get(
|
||||
characteristic.handle()
|
||||
)
|
||||
if not future:
|
||||
logger.warning(
|
||||
f"Unexpected event didDiscoverDescriptorsForCharacteristic for {characteristic.handle()}"
|
||||
)
|
||||
return
|
||||
if error is not None:
|
||||
exception = BleakError(
|
||||
f"Failed to discover descriptors for characteristic {characteristic.handle()}: {error}"
|
||||
)
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug(f"Descriptor discovered {characteristic.handle()}")
|
||||
future.set_result(None)
|
||||
|
||||
def peripheral_didDiscoverDescriptorsForCharacteristic_error_(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didDiscoverDescriptorsForCharacteristic_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_discover_descriptors_for_characteristic,
|
||||
peripheral,
|
||||
characteristic,
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_update_value_for_characteristic(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
value: NSData,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
c_handle = characteristic.handle()
|
||||
|
||||
future = self._characteristic_read_futures.get(c_handle)
|
||||
|
||||
# If there is no pending read request, then this must be a notification
|
||||
# (the same delegate callback is used by both).
|
||||
if not future:
|
||||
if error is None:
|
||||
notify_callback = self._characteristic_notify_callbacks.get(c_handle)
|
||||
|
||||
if notify_callback:
|
||||
notify_callback(bytearray(value))
|
||||
return
|
||||
|
||||
if error is not None:
|
||||
exception = BleakError(f"Failed to read characteristic {c_handle}: {error}")
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Read characteristic value")
|
||||
future.set_result(value)
|
||||
|
||||
def peripheral_didUpdateValueForCharacteristic_error_(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didUpdateValueForCharacteristic_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_update_value_for_characteristic,
|
||||
peripheral,
|
||||
characteristic,
|
||||
characteristic.value(),
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_update_value_for_descriptor(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
descriptor: CBDescriptor,
|
||||
value: NSObject,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
future = self._descriptor_read_futures.get(descriptor.handle())
|
||||
if not future:
|
||||
logger.warning("Unexpected event didUpdateValueForDescriptor")
|
||||
return
|
||||
if error is not None:
|
||||
exception = BleakError(
|
||||
f"Failed to read descriptor {descriptor.handle()}: {error}"
|
||||
)
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Read descriptor value")
|
||||
future.set_result(value)
|
||||
|
||||
def peripheral_didUpdateValueForDescriptor_error_(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
descriptor: CBDescriptor,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didUpdateValueForDescriptor_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_update_value_for_descriptor,
|
||||
peripheral,
|
||||
descriptor,
|
||||
descriptor.value(),
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_write_value_for_characteristic(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
future = self._characteristic_write_futures.get(characteristic.handle(), None)
|
||||
if not future:
|
||||
return # event only expected on write with response
|
||||
if error is not None:
|
||||
exception = BleakError(
|
||||
f"Failed to write characteristic {characteristic.handle()}: {error}"
|
||||
)
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Write Characteristic Value")
|
||||
future.set_result(None)
|
||||
|
||||
def peripheral_didWriteValueForCharacteristic_error_(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didWriteValueForCharacteristic_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_write_value_for_characteristic,
|
||||
peripheral,
|
||||
characteristic,
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_write_value_for_descriptor(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
descriptor: CBDescriptor,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
future = self._descriptor_write_futures.get(descriptor.handle())
|
||||
if not future:
|
||||
logger.warning("Unexpected event didWriteValueForDescriptor")
|
||||
return
|
||||
if error is not None:
|
||||
exception = BleakError(
|
||||
f"Failed to write descriptor {descriptor.handle()}: {error}"
|
||||
)
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Write Descriptor Value")
|
||||
future.set_result(None)
|
||||
|
||||
def peripheral_didWriteValueForDescriptor_error_(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
descriptor: CBDescriptor,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didWriteValueForDescriptor_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_write_value_for_descriptor,
|
||||
peripheral,
|
||||
descriptor,
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_update_notification_for_characteristic(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
c_handle = characteristic.handle()
|
||||
future = self._characteristic_notify_change_futures.get(c_handle)
|
||||
if not future:
|
||||
logger.warning(
|
||||
"Unexpected event didUpdateNotificationStateForCharacteristic"
|
||||
)
|
||||
return
|
||||
if error is not None:
|
||||
exception = BleakError(
|
||||
f"Failed to update the notification status for characteristic {c_handle}: {error}"
|
||||
)
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
logger.debug("Character Notify Update")
|
||||
future.set_result(None)
|
||||
|
||||
def peripheral_didUpdateNotificationStateForCharacteristic_error_(
|
||||
self,
|
||||
peripheral: CBPeripheral,
|
||||
characteristic: CBCharacteristic,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didUpdateNotificationStateForCharacteristic_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_update_notification_for_characteristic,
|
||||
peripheral,
|
||||
characteristic,
|
||||
error,
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_read_rssi(
|
||||
self, peripheral: CBPeripheral, rssi: NSNumber, error: Optional[NSError]
|
||||
) -> None:
|
||||
future = self._read_rssi_futures.get(peripheral.identifier(), None)
|
||||
|
||||
if not future:
|
||||
logger.warning("Unexpected event did_read_rssi")
|
||||
return
|
||||
|
||||
if error is not None:
|
||||
exception = BleakError(f"Failed to read RSSI: {error}")
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
future.set_result(rssi)
|
||||
|
||||
# peripheral_didReadRSSI_error_ method is added dynamically later
|
||||
|
||||
# Bleak currently doesn't use the callbacks below other than for debug logging
|
||||
|
||||
@objc.python_method
|
||||
def did_update_name(self, peripheral: CBPeripheral, name: NSString) -> None:
|
||||
logger.debug(f"name of {peripheral.identifier()} changed to {name}")
|
||||
|
||||
def peripheralDidUpdateName_(self, peripheral: CBPeripheral) -> None:
|
||||
logger.debug("peripheralDidUpdateName_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_update_name, peripheral, peripheral.name()
|
||||
)
|
||||
|
||||
@objc.python_method
|
||||
def did_modify_services(
|
||||
self, peripheral: CBPeripheral, invalidated_services: NSArray
|
||||
) -> None:
|
||||
logger.debug(
|
||||
f"{peripheral.identifier()} invalidated services: {invalidated_services}"
|
||||
)
|
||||
|
||||
def peripheral_didModifyServices_(
|
||||
self, peripheral: CBPeripheral, invalidatedServices: NSArray
|
||||
) -> None:
|
||||
logger.debug("peripheral_didModifyServices_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_modify_services, peripheral, invalidatedServices
|
||||
)
|
||||
|
||||
|
||||
# peripheralDidUpdateRSSI:error: was deprecated and replaced with
|
||||
# peripheral:didReadRSSI:error: in macOS 10.13
|
||||
if objc.macos_available(10, 13):
|
||||
|
||||
def peripheral_didReadRSSI_error_(
|
||||
self: PeripheralDelegate,
|
||||
peripheral: CBPeripheral,
|
||||
rssi: NSNumber,
|
||||
error: Optional[NSError],
|
||||
) -> None:
|
||||
logger.debug("peripheral_didReadRSSI_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_read_rssi, peripheral, rssi, error
|
||||
)
|
||||
|
||||
objc.classAddMethod(
|
||||
PeripheralDelegate,
|
||||
b"peripheral:didReadRSSI:error:",
|
||||
peripheral_didReadRSSI_error_,
|
||||
)
|
||||
|
||||
|
||||
else:
|
||||
|
||||
def peripheralDidUpdateRSSI_error_(
|
||||
self: PeripheralDelegate, peripheral: CBPeripheral, error: Optional[NSError]
|
||||
) -> None:
|
||||
logger.debug("peripheralDidUpdateRSSI_error_")
|
||||
self._event_loop.call_soon_threadsafe(
|
||||
self.did_read_rssi, peripheral, peripheral.RSSI(), error
|
||||
)
|
||||
|
||||
objc.classAddMethod(
|
||||
PeripheralDelegate,
|
||||
b"peripheralDidUpdateRSSI:error:",
|
||||
peripheralDidUpdateRSSI_error_,
|
||||
)
|
11
bleak/backends/corebluetooth/__init__.py
Normal file
11
bleak/backends/corebluetooth/__init__.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
__init__.py
|
||||
|
||||
Created on 2017-11-19 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
||||
|
||||
import objc
|
||||
|
||||
objc.options.verbose = True
|
121
bleak/backends/corebluetooth/characteristic.py
Normal file
121
bleak/backends/corebluetooth/characteristic.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
"""
|
||||
Interface class for the Bleak representation of a GATT Characteristic
|
||||
|
||||
Created on 2019-06-28 by kevincar <kevincarrolldavis@gmail.com>
|
||||
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from CoreBluetooth import CBCharacteristic
|
||||
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..descriptor import BleakGATTDescriptor
|
||||
from .descriptor import BleakGATTDescriptorCoreBluetooth
|
||||
from .utils import cb_uuid_to_str
|
||||
|
||||
|
||||
class CBCharacteristicProperties(Enum):
|
||||
BROADCAST = 0x1
|
||||
READ = 0x2
|
||||
WRITE_WITHOUT_RESPONSE = 0x4
|
||||
WRITE = 0x8
|
||||
NOTIFY = 0x10
|
||||
INDICATE = 0x20
|
||||
AUTHENTICATED_SIGNED_WRITES = 0x40
|
||||
EXTENDED_PROPERTIES = 0x80
|
||||
NOTIFY_ENCRYPTION_REQUIRED = 0x100
|
||||
INDICATE_ENCRYPTION_REQUIRED = 0x200
|
||||
|
||||
|
||||
_GattCharacteristicsPropertiesEnum: Dict[Optional[int], Tuple[str, str]] = {
|
||||
None: ("None", "The characteristic doesn’t have any properties that apply"),
|
||||
1: ("Broadcast".lower(), "The characteristic supports broadcasting"),
|
||||
2: ("Read".lower(), "The characteristic is readable"),
|
||||
4: (
|
||||
"Write-Without-Response".lower(),
|
||||
"The characteristic supports Write Without Response",
|
||||
),
|
||||
8: ("Write".lower(), "The characteristic is writable"),
|
||||
16: ("Notify".lower(), "The characteristic is notifiable"),
|
||||
32: ("Indicate".lower(), "The characteristic is indicatable"),
|
||||
64: (
|
||||
"Authenticated-Signed-Writes".lower(),
|
||||
"The characteristic supports signed writes",
|
||||
),
|
||||
128: (
|
||||
"Extended-Properties".lower(),
|
||||
"The ExtendedProperties Descriptor is present",
|
||||
),
|
||||
256: ("Reliable-Writes".lower(), "The characteristic supports reliable writes"),
|
||||
512: (
|
||||
"Writable-Auxiliaries".lower(),
|
||||
"The characteristic has writable auxiliaries",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BleakGATTCharacteristicCoreBluetooth(BleakGATTCharacteristic):
|
||||
"""GATT Characteristic implementation for the CoreBluetooth backend"""
|
||||
|
||||
def __init__(
|
||||
self, obj: CBCharacteristic, max_write_without_response_size: Callable[[], int]
|
||||
):
|
||||
super().__init__(obj, max_write_without_response_size)
|
||||
self.__descriptors: List[BleakGATTDescriptorCoreBluetooth] = []
|
||||
# self.__props = obj.properties()
|
||||
self.__props: List[str] = [
|
||||
_GattCharacteristicsPropertiesEnum[v][0]
|
||||
for v in [2**n for n in range(10)]
|
||||
if (self.obj.properties() & v)
|
||||
]
|
||||
self._uuid: str = cb_uuid_to_str(self.obj.UUID())
|
||||
|
||||
@property
|
||||
def service_uuid(self) -> str:
|
||||
"""The uuid of the Service containing this characteristic"""
|
||||
return cb_uuid_to_str(self.obj.service().UUID())
|
||||
|
||||
@property
|
||||
def service_handle(self) -> int:
|
||||
return int(self.obj.service().startHandle())
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""Integer handle for this characteristic"""
|
||||
return int(self.obj.handle())
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""The uuid of this characteristic"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def properties(self) -> List[str]:
|
||||
"""Properties of this characteristic"""
|
||||
return self.__props
|
||||
|
||||
@property
|
||||
def descriptors(self) -> List[BleakGATTDescriptor]:
|
||||
"""List of descriptors for this service"""
|
||||
return self.__descriptors
|
||||
|
||||
def get_descriptor(self, specifier) -> Union[BleakGATTDescriptor, None]:
|
||||
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)"""
|
||||
try:
|
||||
if isinstance(specifier, int):
|
||||
return next(filter(lambda x: x.handle == specifier, self.descriptors))
|
||||
else:
|
||||
return next(
|
||||
filter(lambda x: x.uuid == str(specifier), self.descriptors)
|
||||
)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def add_descriptor(self, descriptor: BleakGATTDescriptor):
|
||||
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__descriptors.append(descriptor)
|
389
bleak/backends/corebluetooth/client.py
Normal file
389
bleak/backends/corebluetooth/client.py
Normal file
|
@ -0,0 +1,389 @@
|
|||
"""
|
||||
BLE Client for CoreBluetooth on macOS
|
||||
|
||||
Created on 2019-06-26 by kevincar <kevincarrolldavis@gmail.com>
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Optional, Set, Union
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
from typing_extensions import Buffer
|
||||
else:
|
||||
from collections.abc import Buffer
|
||||
|
||||
from CoreBluetooth import (
|
||||
CBUUID,
|
||||
CBCharacteristicWriteWithoutResponse,
|
||||
CBCharacteristicWriteWithResponse,
|
||||
CBPeripheral,
|
||||
CBPeripheralStateConnected,
|
||||
)
|
||||
from Foundation import NSArray, NSData
|
||||
|
||||
from ... import BleakScanner
|
||||
from ...exc import (
|
||||
BleakCharacteristicNotFoundError,
|
||||
BleakDeviceNotFoundError,
|
||||
BleakError,
|
||||
)
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..client import BaseBleakClient, NotifyCallback
|
||||
from ..device import BLEDevice
|
||||
from ..service import BleakGATTServiceCollection
|
||||
from .CentralManagerDelegate import CentralManagerDelegate
|
||||
from .characteristic import BleakGATTCharacteristicCoreBluetooth
|
||||
from .descriptor import BleakGATTDescriptorCoreBluetooth
|
||||
from .PeripheralDelegate import PeripheralDelegate
|
||||
from .scanner import BleakScannerCoreBluetooth
|
||||
from .service import BleakGATTServiceCoreBluetooth
|
||||
from .utils import cb_uuid_to_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleakClientCoreBluetooth(BaseBleakClient):
|
||||
"""CoreBluetooth class interface for BleakClient
|
||||
|
||||
Args:
|
||||
address_or_ble_device (`BLEDevice` or str): The Bluetooth address of the BLE peripheral to connect to or the `BLEDevice` object representing it.
|
||||
services: Optional set of service UUIDs that will be used.
|
||||
|
||||
Keyword Args:
|
||||
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address_or_ble_device: Union[BLEDevice, str],
|
||||
services: Optional[Set[str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super(BleakClientCoreBluetooth, self).__init__(address_or_ble_device, **kwargs)
|
||||
|
||||
self._peripheral: Optional[CBPeripheral] = None
|
||||
self._delegate: Optional[PeripheralDelegate] = None
|
||||
self._central_manager_delegate: Optional[CentralManagerDelegate] = None
|
||||
|
||||
if isinstance(address_or_ble_device, BLEDevice):
|
||||
(
|
||||
self._peripheral,
|
||||
self._central_manager_delegate,
|
||||
) = address_or_ble_device.details
|
||||
|
||||
self._requested_services = (
|
||||
NSArray.alloc().initWithArray_(list(map(CBUUID.UUIDWithString_, services)))
|
||||
if services
|
||||
else None
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "BleakClientCoreBluetooth ({})".format(self.address)
|
||||
|
||||
async def connect(self, **kwargs) -> bool:
|
||||
"""Connect to a specified Peripheral
|
||||
|
||||
Keyword Args:
|
||||
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
timeout = kwargs.get("timeout", self._timeout)
|
||||
if self._peripheral is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
self.address, timeout=timeout, backend=BleakScannerCoreBluetooth
|
||||
)
|
||||
|
||||
if device:
|
||||
self._peripheral, self._central_manager_delegate = device.details
|
||||
else:
|
||||
raise BleakDeviceNotFoundError(
|
||||
self.address, f"Device with address {self.address} was not found"
|
||||
)
|
||||
|
||||
if self._delegate is None:
|
||||
self._delegate = PeripheralDelegate.alloc().initWithPeripheral_(
|
||||
self._peripheral
|
||||
)
|
||||
|
||||
def disconnect_callback() -> None:
|
||||
# Ensure that `get_services` retrieves services again, rather
|
||||
# than using the cached object
|
||||
self.services = None
|
||||
|
||||
# If there are any pending futures waiting for delegate callbacks, we
|
||||
# need to raise an exception since the callback will no longer be
|
||||
# called because the device is disconnected.
|
||||
for future in self._delegate.futures():
|
||||
try:
|
||||
future.set_exception(BleakError("disconnected"))
|
||||
except asyncio.InvalidStateError:
|
||||
# the future was already done
|
||||
pass
|
||||
|
||||
if self._disconnected_callback:
|
||||
self._disconnected_callback()
|
||||
|
||||
manager = self._central_manager_delegate
|
||||
logger.debug("CentralManagerDelegate at {}".format(manager))
|
||||
logger.debug("Connecting to BLE device @ {}".format(self.address))
|
||||
await manager.connect(self._peripheral, disconnect_callback, timeout=timeout)
|
||||
|
||||
# Now get services
|
||||
await self.get_services()
|
||||
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> bool:
|
||||
"""Disconnect from the peripheral device"""
|
||||
if (
|
||||
self._peripheral is None
|
||||
or self._peripheral.state() != CBPeripheralStateConnected
|
||||
):
|
||||
return True
|
||||
|
||||
await self._central_manager_delegate.disconnect(self._peripheral)
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Checks for current active connection"""
|
||||
return self._DeprecatedIsConnectedReturn(
|
||||
False
|
||||
if self._peripheral is None
|
||||
else self._peripheral.state() == CBPeripheralStateConnected
|
||||
)
|
||||
|
||||
@property
|
||||
def mtu_size(self) -> int:
|
||||
"""Get ATT MTU size for active connection"""
|
||||
# Use type CBCharacteristicWriteWithoutResponse to get maximum write
|
||||
# value length based on the negotiated ATT MTU size. Add the ATT header
|
||||
# length (+3) to get the actual ATT MTU size.
|
||||
return (
|
||||
self._peripheral.maximumWriteValueLengthForType_(
|
||||
CBCharacteristicWriteWithoutResponse
|
||||
)
|
||||
+ 3
|
||||
)
|
||||
|
||||
async def pair(self, *args, **kwargs) -> bool:
|
||||
"""Attempt to pair with a peripheral.
|
||||
|
||||
.. note::
|
||||
|
||||
This is not available on macOS since there is not explicit method to do a pairing, Instead the docs
|
||||
state that it "auto-pairs" when trying to read a characteristic that requires encryption, something
|
||||
Bleak cannot do apparently.
|
||||
|
||||
Reference:
|
||||
|
||||
- `Apple Docs <https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/BestPracticesForSettingUpYourIOSDeviceAsAPeripheral/BestPracticesForSettingUpYourIOSDeviceAsAPeripheral.html#//apple_ref/doc/uid/TP40013257-CH5-SW1>`_
|
||||
- `Stack Overflow post #1 <https://stackoverflow.com/questions/25254932/can-you-pair-a-bluetooth-le-device-in-an-ios-app>`_
|
||||
- `Stack Overflow post #2 <https://stackoverflow.com/questions/47546690/ios-bluetooth-pairing-request-dialog-can-i-know-the-users-choice>`_
|
||||
|
||||
Returns:
|
||||
Boolean regarding success of pairing.
|
||||
|
||||
"""
|
||||
raise NotImplementedError("Pairing is not available in Core Bluetooth.")
|
||||
|
||||
async def unpair(self) -> bool:
|
||||
"""
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
raise NotImplementedError("Pairing is not available in Core Bluetooth.")
|
||||
|
||||
async def get_services(self, **kwargs) -> BleakGATTServiceCollection:
|
||||
"""Get all services registered for this GATT server.
|
||||
|
||||
Returns:
|
||||
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||
|
||||
"""
|
||||
if self.services is not None:
|
||||
return self.services
|
||||
|
||||
services = BleakGATTServiceCollection()
|
||||
|
||||
logger.debug("Retrieving services...")
|
||||
cb_services = await self._delegate.discover_services(self._requested_services)
|
||||
|
||||
for service in cb_services:
|
||||
serviceUUID = service.UUID().UUIDString()
|
||||
logger.debug(
|
||||
"Retrieving characteristics for service {}".format(serviceUUID)
|
||||
)
|
||||
characteristics = await self._delegate.discover_characteristics(service)
|
||||
|
||||
services.add_service(BleakGATTServiceCoreBluetooth(service))
|
||||
|
||||
for characteristic in characteristics:
|
||||
cUUID = characteristic.UUID().UUIDString()
|
||||
logger.debug(
|
||||
"Retrieving descriptors for characteristic {}".format(cUUID)
|
||||
)
|
||||
descriptors = await self._delegate.discover_descriptors(characteristic)
|
||||
|
||||
services.add_characteristic(
|
||||
BleakGATTCharacteristicCoreBluetooth(
|
||||
characteristic,
|
||||
lambda: self._peripheral.maximumWriteValueLengthForType_(
|
||||
CBCharacteristicWriteWithoutResponse
|
||||
),
|
||||
)
|
||||
)
|
||||
for descriptor in descriptors:
|
||||
services.add_descriptor(
|
||||
BleakGATTDescriptorCoreBluetooth(
|
||||
descriptor,
|
||||
cb_uuid_to_str(characteristic.UUID()),
|
||||
int(characteristic.handle()),
|
||||
)
|
||||
)
|
||||
logger.debug("Services resolved for %s", str(self))
|
||||
self.services = services
|
||||
return self.services
|
||||
|
||||
async def read_gatt_char(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID],
|
||||
use_cached: bool = False,
|
||||
**kwargs,
|
||||
) -> bytearray:
|
||||
"""Perform read operation on the specified GATT characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to read from,
|
||||
specified by either integer handle, UUID or directly by the
|
||||
BleakGATTCharacteristic object representing it.
|
||||
use_cached (bool): `False` forces macOS to read the value from the
|
||||
device again and not use its own cached value. Defaults to `False`.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristic):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
output = await self._delegate.read_characteristic(
|
||||
characteristic.obj, use_cached=use_cached
|
||||
)
|
||||
value = bytearray(output)
|
||||
logger.debug("Read Characteristic {0} : {1}".format(characteristic.uuid, value))
|
||||
return value
|
||||
|
||||
async def read_gatt_descriptor(
|
||||
self, handle: int, use_cached: bool = False, **kwargs
|
||||
) -> bytearray:
|
||||
"""Perform read operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle (int): The handle of the descriptor to read from.
|
||||
use_cached (bool): `False` forces Windows to read the value from the
|
||||
device again and not use its own cached value. Defaults to `False`.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
"""
|
||||
descriptor = self.services.get_descriptor(handle)
|
||||
if not descriptor:
|
||||
raise BleakError("Descriptor {} was not found!".format(handle))
|
||||
|
||||
output = await self._delegate.read_descriptor(
|
||||
descriptor.obj, use_cached=use_cached
|
||||
)
|
||||
if isinstance(
|
||||
output, str
|
||||
): # Sometimes a `pyobjc_unicode`or `__NSCFString` is returned and they can be used as regular Python strings.
|
||||
value = bytearray(output.encode("utf-8"))
|
||||
else: # _NSInlineData
|
||||
value = bytearray(output) # value.getBytes_length_(None, len(value))
|
||||
logger.debug("Read Descriptor {0} : {1}".format(handle, value))
|
||||
return value
|
||||
|
||||
async def write_gatt_char(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
data: Buffer,
|
||||
response: bool,
|
||||
) -> None:
|
||||
value = NSData.alloc().initWithBytes_length_(data, len(data))
|
||||
await self._delegate.write_characteristic(
|
||||
characteristic.obj,
|
||||
value,
|
||||
(
|
||||
CBCharacteristicWriteWithResponse
|
||||
if response
|
||||
else CBCharacteristicWriteWithoutResponse
|
||||
),
|
||||
)
|
||||
logger.debug(f"Write Characteristic {characteristic.uuid} : {data}")
|
||||
|
||||
async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None:
|
||||
"""Perform a write operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
handle: The handle of the descriptor to read from.
|
||||
data: The data to send (any bytes-like object).
|
||||
|
||||
"""
|
||||
descriptor = self.services.get_descriptor(handle)
|
||||
if not descriptor:
|
||||
raise BleakError("Descriptor {} was not found!".format(handle))
|
||||
|
||||
value = NSData.alloc().initWithBytes_length_(data, len(data))
|
||||
await self._delegate.write_descriptor(descriptor.obj, value)
|
||||
logger.debug("Write Descriptor {0} : {1}".format(handle, data))
|
||||
|
||||
async def start_notify(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
callback: NotifyCallback,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Activate notifications/indications on a characteristic.
|
||||
"""
|
||||
assert self._delegate is not None
|
||||
|
||||
await self._delegate.start_notifications(characteristic.obj, callback)
|
||||
|
||||
async def stop_notify(
|
||||
self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID]
|
||||
) -> None:
|
||||
"""Deactivate notification/indication on a specified characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to deactivate
|
||||
notification/indication on, specified by either integer handle, UUID or
|
||||
directly by the BleakGATTCharacteristic object representing it.
|
||||
|
||||
|
||||
"""
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristic):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
await self._delegate.stop_notifications(characteristic.obj)
|
||||
|
||||
async def get_rssi(self) -> int:
|
||||
"""To get RSSI value in dBm of the connected Peripheral"""
|
||||
return int(await self._delegate.read_rssi())
|
43
bleak/backends/corebluetooth/descriptor.py
Normal file
43
bleak/backends/corebluetooth/descriptor.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
"""
|
||||
Interface class for the Bleak representation of a GATT Descriptor
|
||||
|
||||
Created on 2019-06-28 by kevincar <kevincarrolldavis@gmail.com>
|
||||
|
||||
"""
|
||||
|
||||
from CoreBluetooth import CBDescriptor
|
||||
|
||||
from ..corebluetooth.utils import cb_uuid_to_str
|
||||
from ..descriptor import BleakGATTDescriptor
|
||||
|
||||
|
||||
class BleakGATTDescriptorCoreBluetooth(BleakGATTDescriptor):
|
||||
"""GATT Descriptor implementation for CoreBluetooth backend"""
|
||||
|
||||
def __init__(
|
||||
self, obj: CBDescriptor, characteristic_uuid: str, characteristic_handle: int
|
||||
):
|
||||
super(BleakGATTDescriptorCoreBluetooth, self).__init__(obj)
|
||||
self.obj: CBDescriptor = obj
|
||||
self.__characteristic_uuid: str = characteristic_uuid
|
||||
self.__characteristic_handle: int = characteristic_handle
|
||||
|
||||
@property
|
||||
def characteristic_handle(self) -> int:
|
||||
"""handle for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_handle
|
||||
|
||||
@property
|
||||
def characteristic_uuid(self) -> str:
|
||||
"""UUID for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_uuid
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""UUID for this descriptor"""
|
||||
return cb_uuid_to_str(self.obj.UUID())
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""Integer handle for this descriptor"""
|
||||
return int(self.obj.handle())
|
185
bleak/backends/corebluetooth/scanner.py
Normal file
185
bleak/backends/corebluetooth/scanner.py
Normal file
|
@ -0,0 +1,185 @@
|
|||
import logging
|
||||
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
||||
|
||||
import objc
|
||||
from CoreBluetooth import CBPeripheral
|
||||
from Foundation import NSBundle
|
||||
|
||||
from ...exc import BleakError
|
||||
from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner
|
||||
from .CentralManagerDelegate import CentralManagerDelegate
|
||||
from .utils import cb_uuid_to_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CBScannerArgs(TypedDict, total=False):
|
||||
"""
|
||||
Platform-specific :class:`BleakScanner` args for the CoreBluetooth backend.
|
||||
"""
|
||||
|
||||
use_bdaddr: bool
|
||||
"""
|
||||
If true, use Bluetooth address instead of UUID.
|
||||
|
||||
.. warning:: This uses an undocumented IOBluetooth API to get the Bluetooth
|
||||
address and may break in the future macOS releases. `It is known to not
|
||||
work on macOS 10.15 <https://github.com/hbldh/bleak/issues/1286>`_.
|
||||
"""
|
||||
|
||||
|
||||
class BleakScannerCoreBluetooth(BaseBleakScanner):
|
||||
"""The native macOS Bleak BLE Scanner.
|
||||
|
||||
Documentation:
|
||||
https://developer.apple.com/documentation/corebluetooth/cbcentralmanager
|
||||
|
||||
CoreBluetooth doesn't explicitly use Bluetooth addresses to identify peripheral
|
||||
devices because private devices may obscure their Bluetooth addresses. To cope
|
||||
with this, CoreBluetooth utilizes UUIDs for each peripheral. Bleak uses
|
||||
this for the BLEDevice address on macOS.
|
||||
|
||||
Args:
|
||||
detection_callback:
|
||||
Optional function that will be called each time a device is
|
||||
discovered or advertising data has changed.
|
||||
service_uuids:
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received. Required on
|
||||
macOS >= 12.0, < 12.3 (unless you create an app with ``py2app``).
|
||||
scanning_mode:
|
||||
Set to ``"passive"`` to avoid the ``"active"`` scanning mode. Not
|
||||
supported on macOS! Will raise :class:`BleakError` if set to
|
||||
``"passive"``
|
||||
**timeout (float):
|
||||
The scanning timeout to be used, in case of missing
|
||||
``stopScan_`` method.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detection_callback: Optional[AdvertisementDataCallback],
|
||||
service_uuids: Optional[List[str]],
|
||||
scanning_mode: Literal["active", "passive"],
|
||||
*,
|
||||
cb: CBScannerArgs,
|
||||
**kwargs
|
||||
):
|
||||
super(BleakScannerCoreBluetooth, self).__init__(
|
||||
detection_callback, service_uuids
|
||||
)
|
||||
|
||||
self._use_bdaddr = cb.get("use_bdaddr", False)
|
||||
|
||||
if scanning_mode == "passive":
|
||||
raise BleakError("macOS does not support passive scanning")
|
||||
|
||||
self._manager = CentralManagerDelegate.alloc().init()
|
||||
self._timeout: float = kwargs.get("timeout", 5.0)
|
||||
if (
|
||||
objc.macos_available(12, 0)
|
||||
and not objc.macos_available(12, 3)
|
||||
and not self._service_uuids
|
||||
):
|
||||
# See https://github.com/hbldh/bleak/issues/720
|
||||
if NSBundle.mainBundle().bundleIdentifier() == "org.python.python":
|
||||
logger.error(
|
||||
"macOS 12.0, 12.1 and 12.2 require non-empty service_uuids kwarg, otherwise no advertisement data will be received"
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
self.seen_devices = {}
|
||||
|
||||
def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None:
|
||||
|
||||
service_uuids = [
|
||||
cb_uuid_to_str(u) for u in a.get("kCBAdvDataServiceUUIDs", [])
|
||||
]
|
||||
|
||||
if not self.is_allowed_uuid(service_uuids):
|
||||
return
|
||||
|
||||
# Process service data
|
||||
service_data_dict_raw = a.get("kCBAdvDataServiceData", {})
|
||||
service_data = {
|
||||
cb_uuid_to_str(k): bytes(v) for k, v in service_data_dict_raw.items()
|
||||
}
|
||||
|
||||
# Process manufacturer data into a more friendly format
|
||||
manufacturer_binary_data = a.get("kCBAdvDataManufacturerData")
|
||||
manufacturer_data = {}
|
||||
if manufacturer_binary_data:
|
||||
manufacturer_id = int.from_bytes(
|
||||
manufacturer_binary_data[0:2], byteorder="little"
|
||||
)
|
||||
manufacturer_value = bytes(manufacturer_binary_data[2:])
|
||||
manufacturer_data[manufacturer_id] = manufacturer_value
|
||||
|
||||
# set tx_power data if available
|
||||
tx_power = a.get("kCBAdvDataTxPowerLevel")
|
||||
|
||||
advertisement_data = AdvertisementData(
|
||||
local_name=a.get("kCBAdvDataLocalName"),
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_data=service_data,
|
||||
service_uuids=service_uuids,
|
||||
tx_power=tx_power,
|
||||
rssi=r,
|
||||
platform_data=(p, a, r),
|
||||
)
|
||||
|
||||
if self._use_bdaddr:
|
||||
# HACK: retrieveAddressForPeripheral_ is undocumented but seems to do the trick
|
||||
address_bytes: bytes = (
|
||||
self._manager.central_manager.retrieveAddressForPeripheral_(p)
|
||||
)
|
||||
if address_bytes is None:
|
||||
logger.debug(
|
||||
"Could not get Bluetooth address for %s. Ignoring this device.",
|
||||
p.identifier().UUIDString(),
|
||||
)
|
||||
address = address_bytes.hex(":").upper()
|
||||
else:
|
||||
address = p.identifier().UUIDString()
|
||||
|
||||
device = self.create_or_update_device(
|
||||
address,
|
||||
p.name(),
|
||||
(p, self._manager.central_manager.delegate()),
|
||||
advertisement_data,
|
||||
)
|
||||
|
||||
self.call_detection_callbacks(device, advertisement_data)
|
||||
|
||||
self._manager.callbacks[id(self)] = callback
|
||||
await self._manager.start_scan(self._service_uuids)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._manager.stop_scan()
|
||||
self._manager.callbacks.pop(id(self), None)
|
||||
|
||||
def set_scanning_filter(self, **kwargs) -> None:
|
||||
"""Set scanning filter for the scanner.
|
||||
|
||||
.. note::
|
||||
|
||||
This is not implemented for macOS yet.
|
||||
|
||||
Raises:
|
||||
|
||||
``NotImplementedError``
|
||||
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Need to evaluate which macOS versions to support first..."
|
||||
)
|
||||
|
||||
# macOS specific methods
|
||||
|
||||
@property
|
||||
def is_scanning(self):
|
||||
# TODO: Evaluate if newer macOS than 10.11 has isScanning.
|
||||
try:
|
||||
return self._manager.isScanning_
|
||||
except Exception:
|
||||
return None
|
42
bleak/backends/corebluetooth/service.py
Normal file
42
bleak/backends/corebluetooth/service.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from typing import List
|
||||
|
||||
from CoreBluetooth import CBService
|
||||
|
||||
from ..service import BleakGATTService
|
||||
from .characteristic import BleakGATTCharacteristicCoreBluetooth
|
||||
from .utils import cb_uuid_to_str
|
||||
|
||||
|
||||
class BleakGATTServiceCoreBluetooth(BleakGATTService):
|
||||
"""GATT Characteristic implementation for the CoreBluetooth backend"""
|
||||
|
||||
def __init__(self, obj: CBService):
|
||||
super().__init__(obj)
|
||||
self.__characteristics: List[BleakGATTCharacteristicCoreBluetooth] = []
|
||||
# N.B. the `startHandle` method of the CBService is an undocumented Core Bluetooth feature,
|
||||
# which Bleak takes advantage of in order to have a service handle to use.
|
||||
self.__handle: int = int(self.obj.startHandle())
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""The integer handle of this service"""
|
||||
return self.__handle
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""UUID for this service."""
|
||||
return cb_uuid_to_str(self.obj.UUID())
|
||||
|
||||
@property
|
||||
def characteristics(self) -> List[BleakGATTCharacteristicCoreBluetooth]:
|
||||
"""List of characteristics for this service"""
|
||||
return self.__characteristics
|
||||
|
||||
def add_characteristic(
|
||||
self, characteristic: BleakGATTCharacteristicCoreBluetooth
|
||||
) -> None:
|
||||
"""Add a :py:class:`~BleakGATTCharacteristicCoreBluetooth` to the service.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__characteristics.append(characteristic)
|
42
bleak/backends/corebluetooth/utils.py
Normal file
42
bleak/backends/corebluetooth/utils.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from CoreBluetooth import CBUUID
|
||||
from Foundation import NSData
|
||||
|
||||
from ...uuids import normalize_uuid_str
|
||||
|
||||
|
||||
def cb_uuid_to_str(uuid: CBUUID) -> str:
|
||||
"""Converts a CoreBluetooth UUID to a Python string.
|
||||
|
||||
If ``uuid`` is a 16-bit UUID, it is assumed to be a Bluetooth GATT UUID
|
||||
(``0000xxxx-0000-1000-8000-00805f9b34fb``).
|
||||
|
||||
Args
|
||||
uuid: The UUID.
|
||||
|
||||
Returns:
|
||||
The UUID as a lower case Python string (``xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx``)
|
||||
"""
|
||||
return normalize_uuid_str(uuid.UUIDString())
|
||||
|
||||
|
||||
def _is_uuid_16bit_compatible(_uuid: str) -> bool:
|
||||
test_uuid = "0000ffff-0000-1000-8000-00805f9b34fb"
|
||||
test_int = _convert_uuid_to_int(test_uuid)
|
||||
uuid_int = _convert_uuid_to_int(_uuid)
|
||||
result_int = uuid_int & test_int
|
||||
return uuid_int == result_int
|
||||
|
||||
|
||||
def _convert_uuid_to_int(_uuid: str) -> int:
|
||||
UUID_cb = CBUUID.alloc().initWithString_(_uuid)
|
||||
UUID_data = UUID_cb.data()
|
||||
UUID_bytes = UUID_data.getBytes_length_(None, len(UUID_data))
|
||||
UUID_int = int.from_bytes(UUID_bytes, byteorder="big")
|
||||
return UUID_int
|
||||
|
||||
|
||||
def _convert_int_to_uuid(i: int) -> str:
|
||||
UUID_bytes = i.to_bytes(length=16, byteorder="big")
|
||||
UUID_data = NSData.alloc().initWithBytes_length_(UUID_bytes, len(UUID_bytes))
|
||||
UUID_cb = CBUUID.alloc().initWithData_(UUID_data)
|
||||
return UUID_cb.UUIDString().lower()
|
143
bleak/backends/descriptor.py
Normal file
143
bleak/backends/descriptor.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Interface class for the Bleak representation of a GATT Descriptor
|
||||
|
||||
Created on 2019-03-19 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
||||
import abc
|
||||
from typing import Any
|
||||
|
||||
from ..uuids import normalize_uuid_16
|
||||
|
||||
_descriptor_descriptions = {
|
||||
normalize_uuid_16(0x2905): [
|
||||
"Characteristic Aggregate Format",
|
||||
"org.bluetooth.descriptor.gatt.characteristic_aggregate_format",
|
||||
"0x2905",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2900): [
|
||||
"Characteristic Extended Properties",
|
||||
"org.bluetooth.descriptor.gatt.characteristic_extended_properties",
|
||||
"0x2900",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2904): [
|
||||
"Characteristic Presentation Format",
|
||||
"org.bluetooth.descriptor.gatt.characteristic_presentation_format",
|
||||
"0x2904",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2901): [
|
||||
"Characteristic User Description",
|
||||
"org.bluetooth.descriptor.gatt.characteristic_user_description",
|
||||
"0x2901",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2902): [
|
||||
"Client Characteristic Configuration",
|
||||
"org.bluetooth.descriptor.gatt.client_characteristic_configuration",
|
||||
"0x2902",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x290B): [
|
||||
"Environmental Sensing Configuration",
|
||||
"org.bluetooth.descriptor.es_configuration",
|
||||
"0x290B",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x290C): [
|
||||
"Environmental Sensing Measurement",
|
||||
"org.bluetooth.descriptor.es_measurement",
|
||||
"0x290C",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x290D): [
|
||||
"Environmental Sensing Trigger Setting",
|
||||
"org.bluetooth.descriptor.es_trigger_setting",
|
||||
"0x290D",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2907): [
|
||||
"External Report Reference",
|
||||
"org.bluetooth.descriptor.external_report_reference",
|
||||
"0x2907",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2909): [
|
||||
"Number of Digitals",
|
||||
"org.bluetooth.descriptor.number_of_digitals",
|
||||
"0x2909",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2908): [
|
||||
"Report Reference",
|
||||
"org.bluetooth.descriptor.report_reference",
|
||||
"0x2908",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2903): [
|
||||
"Server Characteristic Configuration",
|
||||
"org.bluetooth.descriptor.gatt.server_characteristic_configuration",
|
||||
"0x2903",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x290E): [
|
||||
"Time Trigger Setting",
|
||||
"org.bluetooth.descriptor.time_trigger_setting",
|
||||
"0x290E",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x2906): [
|
||||
"Valid Range",
|
||||
"org.bluetooth.descriptor.valid_range",
|
||||
"0x2906",
|
||||
"GSS",
|
||||
],
|
||||
normalize_uuid_16(0x290A): [
|
||||
"Value Trigger Setting",
|
||||
"org.bluetooth.descriptor.value_trigger_setting",
|
||||
"0x290A",
|
||||
"GSS",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class BleakGATTDescriptor(abc.ABC):
|
||||
"""Interface for the Bleak representation of a GATT Descriptor"""
|
||||
|
||||
def __init__(self, obj: Any):
|
||||
self.obj = obj
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.uuid} (Handle: {self.handle}): {self.description}"
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def characteristic_uuid(self) -> str:
|
||||
"""UUID for the characteristic that this descriptor belongs to"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def characteristic_handle(self) -> int:
|
||||
"""handle for the characteristic that this descriptor belongs to"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def uuid(self) -> str:
|
||||
"""UUID for this descriptor"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def handle(self) -> int:
|
||||
"""Integer handle for this descriptor"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""A text description of what this descriptor represents"""
|
||||
return _descriptor_descriptions.get(self.uuid, ["Unknown"])[0]
|
73
bleak/backends/device.py
Normal file
73
bleak/backends/device.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Wrapper class for Bluetooth LE servers returned from calling
|
||||
:py:meth:`bleak.discover`.
|
||||
|
||||
Created on 2018-04-23 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from typing import Any, Optional
|
||||
from warnings import warn
|
||||
|
||||
|
||||
class BLEDevice:
|
||||
"""
|
||||
A simple wrapper class representing a BLE server detected during scanning.
|
||||
"""
|
||||
|
||||
__slots__ = ("address", "name", "details", "_rssi", "_metadata")
|
||||
|
||||
def __init__(
|
||||
self, address: str, name: Optional[str], details: Any, rssi: int, **kwargs
|
||||
):
|
||||
#: The Bluetooth address of the device on this machine (UUID on macOS).
|
||||
self.address = address
|
||||
#: The operating system name of the device (not necessarily the local name
|
||||
#: from the advertising data), suitable for display to the user.
|
||||
self.name = name
|
||||
#: The OS native details required for connecting to the device.
|
||||
self.details = details
|
||||
|
||||
# for backwards compatibility
|
||||
self._rssi = rssi
|
||||
self._metadata = kwargs
|
||||
|
||||
@property
|
||||
def rssi(self) -> int:
|
||||
"""
|
||||
Gets the RSSI of the last received advertisement.
|
||||
|
||||
.. deprecated:: 0.19.0
|
||||
Use :class:`AdvertisementData` from detection callback or
|
||||
:attr:`BleakScanner.discovered_devices_and_advertisement_data` instead.
|
||||
"""
|
||||
warn(
|
||||
"BLEDevice.rssi is deprecated and will be removed in a future version of Bleak, use AdvertisementData.rssi instead",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self._rssi
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict:
|
||||
"""
|
||||
Gets additional advertisement data for the device.
|
||||
|
||||
.. deprecated:: 0.19.0
|
||||
Use :class:`AdvertisementData` from detection callback or
|
||||
:attr:`BleakScanner.discovered_devices_and_advertisement_data` instead.
|
||||
"""
|
||||
warn(
|
||||
"BLEDevice.metadata is deprecated and will be removed in a future version of Bleak, use AdvertisementData instead",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self._metadata
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.address}: {self.name}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"BLEDevice({self.address}, {self.name})"
|
0
bleak/backends/p4android/__init__.py
Normal file
0
bleak/backends/p4android/__init__.py
Normal file
96
bleak/backends/p4android/characteristic.py
Normal file
96
bleak/backends/p4android/characteristic.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
from typing import Callable, List, Union
|
||||
from uuid import UUID
|
||||
|
||||
from ...exc import BleakError
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..descriptor import BleakGATTDescriptor
|
||||
from . import defs
|
||||
|
||||
|
||||
class BleakGATTCharacteristicP4Android(BleakGATTCharacteristic):
|
||||
"""GATT Characteristic implementation for the python-for-android backend"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
java,
|
||||
service_uuid: str,
|
||||
service_handle: int,
|
||||
max_write_without_response_size: Callable[[], int],
|
||||
):
|
||||
super(BleakGATTCharacteristicP4Android, self).__init__(
|
||||
java, max_write_without_response_size
|
||||
)
|
||||
self.__uuid = self.obj.getUuid().toString()
|
||||
self.__handle = self.obj.getInstanceId()
|
||||
self.__service_uuid = service_uuid
|
||||
self.__service_handle = service_handle
|
||||
self.__descriptors = []
|
||||
self.__notification_descriptor = None
|
||||
|
||||
self.__properties = [
|
||||
name
|
||||
for flag, name in defs.CHARACTERISTIC_PROPERTY_DBUS_NAMES.items()
|
||||
if flag & self.obj.getProperties()
|
||||
]
|
||||
|
||||
@property
|
||||
def service_uuid(self) -> str:
|
||||
"""The uuid of the Service containing this characteristic"""
|
||||
return self.__service_uuid
|
||||
|
||||
@property
|
||||
def service_handle(self) -> int:
|
||||
"""The integer handle of the Service containing this characteristic"""
|
||||
return int(self.__service_handle)
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""The handle of this characteristic"""
|
||||
return self.__handle
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""The uuid of this characteristic"""
|
||||
return self.__uuid
|
||||
|
||||
@property
|
||||
def properties(self) -> List[str]:
|
||||
"""Properties of this characteristic"""
|
||||
return self.__properties
|
||||
|
||||
@property
|
||||
def descriptors(self) -> List[BleakGATTDescriptor]:
|
||||
"""List of descriptors for this service"""
|
||||
return self.__descriptors
|
||||
|
||||
def get_descriptor(
|
||||
self, specifier: Union[str, UUID]
|
||||
) -> Union[BleakGATTDescriptor, None]:
|
||||
"""Get a descriptor by UUID (str or uuid.UUID)"""
|
||||
if isinstance(specifier, int):
|
||||
raise BleakError(
|
||||
"The Android Bluetooth API does not provide access to descriptor handles."
|
||||
)
|
||||
|
||||
matches = [
|
||||
descriptor
|
||||
for descriptor in self.descriptors
|
||||
if descriptor.uuid == str(specifier)
|
||||
]
|
||||
if len(matches) == 0:
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
def add_descriptor(self, descriptor: BleakGATTDescriptor):
|
||||
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__descriptors.append(descriptor)
|
||||
if descriptor.uuid == defs.CLIENT_CHARACTERISTIC_CONFIGURATION_UUID:
|
||||
self.__notification_descriptor = descriptor
|
||||
|
||||
@property
|
||||
def notification_descriptor(self) -> BleakGATTDescriptor:
|
||||
"""The notification descriptor. Mostly needed by `bleak`, not by end user"""
|
||||
return self.__notification_descriptor
|
545
bleak/backends/p4android/client.py
Normal file
545
bleak/backends/p4android/client.py
Normal file
|
@ -0,0 +1,545 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
BLE Client for python-for-android
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
import warnings
|
||||
from typing import Optional, Set, Union
|
||||
|
||||
from android.broadcast import BroadcastReceiver
|
||||
from jnius import java_method
|
||||
|
||||
from ...exc import BleakCharacteristicNotFoundError, BleakError
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..client import BaseBleakClient, NotifyCallback
|
||||
from ..device import BLEDevice
|
||||
from ..service import BleakGATTServiceCollection
|
||||
from . import defs, utils
|
||||
from .characteristic import BleakGATTCharacteristicP4Android
|
||||
from .descriptor import BleakGATTDescriptorP4Android
|
||||
from .service import BleakGATTServiceP4Android
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleakClientP4Android(BaseBleakClient):
|
||||
"""A python-for-android Bleak Client
|
||||
|
||||
Args:
|
||||
address_or_ble_device:
|
||||
The Bluetooth address of the BLE peripheral to connect to or the
|
||||
:class:`BLEDevice` object representing it.
|
||||
services:
|
||||
Optional set of services UUIDs to filter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address_or_ble_device: Union[BLEDevice, str],
|
||||
services: Optional[Set[uuid.UUID]],
|
||||
**kwargs,
|
||||
):
|
||||
super(BleakClientP4Android, self).__init__(address_or_ble_device, **kwargs)
|
||||
self._requested_services = (
|
||||
set(map(defs.UUID.fromString, services)) if services else None
|
||||
)
|
||||
# kwarg "device" is for backwards compatibility
|
||||
self.__adapter = kwargs.get("adapter", kwargs.get("device", None))
|
||||
self.__gatt = None
|
||||
self.__mtu = 23
|
||||
|
||||
def __del__(self):
|
||||
if self.__gatt is not None:
|
||||
self.__gatt.close()
|
||||
self.__gatt = None
|
||||
|
||||
# Connectivity methods
|
||||
|
||||
async def connect(self, **kwargs) -> bool:
|
||||
"""Connect to the specified GATT server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
self.__adapter = defs.BluetoothAdapter.getDefaultAdapter()
|
||||
if self.__adapter is None:
|
||||
raise BleakError("Bluetooth is not supported on this hardware platform")
|
||||
if self.__adapter.getState() != defs.BluetoothAdapter.STATE_ON:
|
||||
raise BleakError("Bluetooth is not turned on")
|
||||
|
||||
self.__device = self.__adapter.getRemoteDevice(self.address)
|
||||
|
||||
self.__callbacks = _PythonBluetoothGattCallback(self, loop)
|
||||
|
||||
self._subscriptions = {}
|
||||
|
||||
logger.debug(f"Connecting to BLE device @ {self.address}")
|
||||
|
||||
(self.__gatt,) = await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__device.connectGatt,
|
||||
dispatchParams=(
|
||||
defs.context,
|
||||
False,
|
||||
self.__callbacks.java,
|
||||
defs.BluetoothDevice.TRANSPORT_LE,
|
||||
),
|
||||
resultApi="onConnectionStateChange",
|
||||
resultExpected=(defs.BluetoothProfile.STATE_CONNECTED,),
|
||||
return_indicates_status=False,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.debug("Connection successful.")
|
||||
|
||||
# unlike other backends, Android doesn't automatically negotiate
|
||||
# the MTU, so we request the largest size possible like BlueZ
|
||||
logger.debug("requesting mtu...")
|
||||
(self.__mtu,) = await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.requestMtu,
|
||||
dispatchParams=(517,),
|
||||
resultApi="onMtuChanged",
|
||||
)
|
||||
|
||||
logger.debug("discovering services...")
|
||||
await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.discoverServices,
|
||||
dispatchParams=(),
|
||||
resultApi="onServicesDiscovered",
|
||||
)
|
||||
|
||||
await self.get_services()
|
||||
except BaseException:
|
||||
# if connecting is canceled or one of the above fails, we need to
|
||||
# disconnect
|
||||
try:
|
||||
await self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> bool:
|
||||
"""Disconnect from the specified GATT server.
|
||||
|
||||
Returns:
|
||||
Boolean representing if device is disconnected.
|
||||
|
||||
"""
|
||||
logger.debug("Disconnecting from BLE device...")
|
||||
if self.__gatt is None:
|
||||
# No connection exists. Either one hasn't been created or
|
||||
# we have already called disconnect and closed the gatt
|
||||
# connection.
|
||||
logger.debug("already disconnected")
|
||||
return True
|
||||
|
||||
# Try to disconnect the actual device/peripheral
|
||||
try:
|
||||
await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.disconnect,
|
||||
dispatchParams=(),
|
||||
resultApi="onConnectionStateChange",
|
||||
resultExpected=(defs.BluetoothProfile.STATE_DISCONNECTED,),
|
||||
unless_already=True,
|
||||
return_indicates_status=False,
|
||||
)
|
||||
self.__gatt.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Attempt to disconnect device failed: {e}")
|
||||
|
||||
self.__gatt = None
|
||||
self.__callbacks = None
|
||||
|
||||
# Reset all stored services.
|
||||
self.services = None
|
||||
|
||||
return True
|
||||
|
||||
async def pair(self, *args, **kwargs) -> bool:
|
||||
"""Pair with the peripheral.
|
||||
|
||||
You can use ConnectDevice method if you already know the MAC address of the device.
|
||||
Else you need to StartDiscovery, Trust, Pair and Connect in sequence.
|
||||
|
||||
Returns:
|
||||
Boolean regarding success of pairing.
|
||||
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
bondedFuture = loop.create_future()
|
||||
|
||||
def handleBondStateChanged(context, intent):
|
||||
bond_state = intent.getIntExtra(defs.BluetoothDevice.EXTRA_BOND_STATE, -1)
|
||||
if bond_state == -1:
|
||||
loop.call_soon_threadsafe(
|
||||
bondedFuture.set_exception,
|
||||
BleakError(f"Unexpected bond state {bond_state}"),
|
||||
)
|
||||
elif bond_state == defs.BluetoothDevice.BOND_NONE:
|
||||
loop.call_soon_threadsafe(
|
||||
bondedFuture.set_exception,
|
||||
BleakError(
|
||||
f"Device with address {self.address} could not be paired with."
|
||||
),
|
||||
)
|
||||
elif bond_state == defs.BluetoothDevice.BOND_BONDED:
|
||||
loop.call_soon_threadsafe(bondedFuture.set_result, True)
|
||||
|
||||
receiver = BroadcastReceiver(
|
||||
handleBondStateChanged,
|
||||
actions=[defs.BluetoothDevice.ACTION_BOND_STATE_CHANGED],
|
||||
)
|
||||
receiver.start()
|
||||
try:
|
||||
# See if it is already paired.
|
||||
bond_state = self.__device.getBondState()
|
||||
if bond_state == defs.BluetoothDevice.BOND_BONDED:
|
||||
return True
|
||||
elif bond_state == defs.BluetoothDevice.BOND_NONE:
|
||||
logger.debug(f"Pairing to BLE device @ {self.address}")
|
||||
if not self.__device.createBond():
|
||||
raise BleakError(
|
||||
f"Could not initiate bonding with device @ {self.address}"
|
||||
)
|
||||
return await bondedFuture
|
||||
finally:
|
||||
await receiver.stop()
|
||||
|
||||
async def unpair(self) -> bool:
|
||||
"""Unpair with the peripheral.
|
||||
|
||||
Returns:
|
||||
Boolean regarding success of unpairing.
|
||||
|
||||
"""
|
||||
warnings.warn(
|
||||
"Unpairing is seemingly unavailable in the Android API at the moment."
|
||||
)
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check connection status between this client and the server.
|
||||
|
||||
Returns:
|
||||
Boolean representing connection status.
|
||||
|
||||
"""
|
||||
return (
|
||||
self.__callbacks is not None
|
||||
and self.__callbacks.states["onConnectionStateChange"][1]
|
||||
== defs.BluetoothProfile.STATE_CONNECTED
|
||||
)
|
||||
|
||||
@property
|
||||
def mtu_size(self) -> Optional[int]:
|
||||
return self.__mtu
|
||||
|
||||
# GATT services methods
|
||||
|
||||
async def get_services(self) -> BleakGATTServiceCollection:
|
||||
"""Get all services registered for this GATT server.
|
||||
|
||||
Returns:
|
||||
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
|
||||
|
||||
"""
|
||||
if self.services is not None:
|
||||
return self.services
|
||||
|
||||
services = BleakGATTServiceCollection()
|
||||
|
||||
logger.debug("Get Services...")
|
||||
for java_service in self.__gatt.getServices():
|
||||
if (
|
||||
self._requested_services is not None
|
||||
and java_service.getUuid() not in self._requested_services
|
||||
):
|
||||
continue
|
||||
|
||||
service = BleakGATTServiceP4Android(java_service)
|
||||
services.add_service(service)
|
||||
|
||||
for java_characteristic in java_service.getCharacteristics():
|
||||
|
||||
characteristic = BleakGATTCharacteristicP4Android(
|
||||
java_characteristic,
|
||||
service.uuid,
|
||||
service.handle,
|
||||
lambda: self.__mtu - 3,
|
||||
)
|
||||
services.add_characteristic(characteristic)
|
||||
|
||||
for descriptor_index, java_descriptor in enumerate(
|
||||
java_characteristic.getDescriptors()
|
||||
):
|
||||
|
||||
descriptor = BleakGATTDescriptorP4Android(
|
||||
java_descriptor,
|
||||
characteristic.uuid,
|
||||
characteristic.handle,
|
||||
descriptor_index,
|
||||
)
|
||||
services.add_descriptor(descriptor)
|
||||
|
||||
self.services = services
|
||||
return self.services
|
||||
|
||||
# IO methods
|
||||
|
||||
async def read_gatt_char(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristicP4Android, int, str, uuid.UUID],
|
||||
**kwargs,
|
||||
) -> bytearray:
|
||||
"""Perform read operation on the specified GATT characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristicP4Android, int, str or UUID): The characteristic to read from,
|
||||
specified by either integer handle, UUID or directly by the
|
||||
BleakGATTCharacteristicP4Android object representing it.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristicP4Android):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
(value,) = await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.readCharacteristic,
|
||||
dispatchParams=(characteristic.obj,),
|
||||
resultApi=("onCharacteristicRead", characteristic.handle),
|
||||
)
|
||||
value = bytearray(value)
|
||||
logger.debug(
|
||||
f"Read Characteristic {characteristic.uuid} | {characteristic.handle}: {value}"
|
||||
)
|
||||
return value
|
||||
|
||||
async def read_gatt_descriptor(
|
||||
self,
|
||||
desc_specifier: Union[BleakGATTDescriptorP4Android, str, uuid.UUID],
|
||||
**kwargs,
|
||||
) -> bytearray:
|
||||
"""Perform read operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
desc_specifier (BleakGATTDescriptorP4Android, str or UUID): The descriptor to read from,
|
||||
specified by either UUID or directly by the
|
||||
BleakGATTDescriptorP4Android object representing it.
|
||||
|
||||
Returns:
|
||||
(bytearray) The read data.
|
||||
|
||||
"""
|
||||
if not isinstance(desc_specifier, BleakGATTDescriptorP4Android):
|
||||
descriptor = self.services.get_descriptor(desc_specifier)
|
||||
else:
|
||||
descriptor = desc_specifier
|
||||
|
||||
if not descriptor:
|
||||
raise BleakError(f"Descriptor with UUID {desc_specifier} was not found!")
|
||||
|
||||
(value,) = await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.readDescriptor,
|
||||
dispatchParams=(descriptor.obj,),
|
||||
resultApi=("onDescriptorRead", descriptor.uuid),
|
||||
)
|
||||
value = bytearray(value)
|
||||
|
||||
logger.debug(
|
||||
f"Read Descriptor {descriptor.uuid} | {descriptor.handle}: {value}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
async def write_gatt_char(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
data: bytearray,
|
||||
response: bool,
|
||||
) -> None:
|
||||
if response:
|
||||
characteristic.obj.setWriteType(
|
||||
defs.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
)
|
||||
else:
|
||||
characteristic.obj.setWriteType(
|
||||
defs.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
)
|
||||
|
||||
characteristic.obj.setValue(data)
|
||||
|
||||
await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.writeCharacteristic,
|
||||
dispatchParams=(characteristic.obj,),
|
||||
resultApi=("onCharacteristicWrite", characteristic.handle),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Write Characteristic {characteristic.uuid} | {characteristic.handle}: {data}"
|
||||
)
|
||||
|
||||
async def write_gatt_descriptor(
|
||||
self,
|
||||
desc_specifier: Union[BleakGATTDescriptorP4Android, str, uuid.UUID],
|
||||
data: bytearray,
|
||||
) -> None:
|
||||
"""Perform a write operation on the specified GATT descriptor.
|
||||
|
||||
Args:
|
||||
desc_specifier (BleakGATTDescriptorP4Android, str or UUID): The descriptor to write
|
||||
to, specified by either UUID or directly by the
|
||||
BleakGATTDescriptorP4Android object representing it.
|
||||
data (bytes or bytearray): The data to send.
|
||||
|
||||
"""
|
||||
if not isinstance(desc_specifier, BleakGATTDescriptorP4Android):
|
||||
descriptor = self.services.get_descriptor(desc_specifier)
|
||||
else:
|
||||
descriptor = desc_specifier
|
||||
|
||||
if not descriptor:
|
||||
raise BleakError(f"Descriptor {desc_specifier} was not found!")
|
||||
|
||||
descriptor.obj.setValue(data)
|
||||
|
||||
await self.__callbacks.perform_and_wait(
|
||||
dispatchApi=self.__gatt.writeDescriptor,
|
||||
dispatchParams=(descriptor.obj,),
|
||||
resultApi=("onDescriptorWrite", descriptor.uuid),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Write Descriptor {descriptor.uuid} | {descriptor.handle}: {data}"
|
||||
)
|
||||
|
||||
async def start_notify(
|
||||
self,
|
||||
characteristic: BleakGATTCharacteristic,
|
||||
callback: NotifyCallback,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Activate notifications/indications on a characteristic.
|
||||
"""
|
||||
self._subscriptions[characteristic.handle] = callback
|
||||
|
||||
assert self.__gatt is not None
|
||||
|
||||
if not self.__gatt.setCharacteristicNotification(characteristic.obj, True):
|
||||
raise BleakError(
|
||||
f"Failed to enable notification for characteristic {characteristic.uuid}"
|
||||
)
|
||||
|
||||
await self.write_gatt_descriptor(
|
||||
characteristic.notification_descriptor,
|
||||
defs.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||
)
|
||||
|
||||
async def stop_notify(
|
||||
self,
|
||||
char_specifier: Union[BleakGATTCharacteristicP4Android, int, str, uuid.UUID],
|
||||
) -> None:
|
||||
"""Deactivate notification/indication on a specified characteristic.
|
||||
|
||||
Args:
|
||||
char_specifier (BleakGATTCharacteristicP4Android, int, str or UUID): The characteristic to deactivate
|
||||
notification/indication on, specified by either integer handle, UUID or
|
||||
directly by the BleakGATTCharacteristicP4Android object representing it.
|
||||
|
||||
"""
|
||||
if not isinstance(char_specifier, BleakGATTCharacteristicP4Android):
|
||||
characteristic = self.services.get_characteristic(char_specifier)
|
||||
else:
|
||||
characteristic = char_specifier
|
||||
if not characteristic:
|
||||
raise BleakCharacteristicNotFoundError(char_specifier)
|
||||
|
||||
await self.write_gatt_descriptor(
|
||||
characteristic.notification_descriptor,
|
||||
defs.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE,
|
||||
)
|
||||
|
||||
if not self.__gatt.setCharacteristicNotification(characteristic.obj, False):
|
||||
raise BleakError(
|
||||
f"Failed to disable notification for characteristic {characteristic.uuid}"
|
||||
)
|
||||
del self._subscriptions[characteristic.handle]
|
||||
|
||||
|
||||
class _PythonBluetoothGattCallback(utils.AsyncJavaCallbacks):
|
||||
__javainterfaces__ = [
|
||||
"com.github.hbldh.bleak.PythonBluetoothGattCallback$Interface"
|
||||
]
|
||||
|
||||
def __init__(self, client, loop):
|
||||
super().__init__(loop)
|
||||
self._client = client
|
||||
self.java = defs.PythonBluetoothGattCallback(self)
|
||||
|
||||
def result_state(self, status, resultApi, *data):
|
||||
if status == defs.BluetoothGatt.GATT_SUCCESS:
|
||||
failure_str = None
|
||||
else:
|
||||
failure_str = defs.GATT_STATUS_STRINGS.get(status, status)
|
||||
self._loop.call_soon_threadsafe(
|
||||
self._result_state_unthreadsafe, failure_str, resultApi, data
|
||||
)
|
||||
|
||||
@java_method("(II)V")
|
||||
def onConnectionStateChange(self, status, new_state):
|
||||
try:
|
||||
self.result_state(status, "onConnectionStateChange", new_state)
|
||||
except BleakError:
|
||||
pass
|
||||
if (
|
||||
new_state == defs.BluetoothProfile.STATE_DISCONNECTED
|
||||
and self._client._disconnected_callback is not None
|
||||
):
|
||||
self._client._disconnected_callback()
|
||||
|
||||
@java_method("(II)V")
|
||||
def onMtuChanged(self, mtu, status):
|
||||
self.result_state(status, "onMtuChanged", mtu)
|
||||
|
||||
@java_method("(I)V")
|
||||
def onServicesDiscovered(self, status):
|
||||
self.result_state(status, "onServicesDiscovered")
|
||||
|
||||
@java_method("(I[B)V")
|
||||
def onCharacteristicChanged(self, handle, value):
|
||||
self._loop.call_soon_threadsafe(
|
||||
self._client._subscriptions[handle], bytearray(value.tolist())
|
||||
)
|
||||
|
||||
@java_method("(II[B)V")
|
||||
def onCharacteristicRead(self, handle, status, value):
|
||||
self.result_state(
|
||||
status, ("onCharacteristicRead", handle), bytes(value.tolist())
|
||||
)
|
||||
|
||||
@java_method("(II)V")
|
||||
def onCharacteristicWrite(self, handle, status):
|
||||
self.result_state(status, ("onCharacteristicWrite", handle))
|
||||
|
||||
@java_method("(Ljava/lang/String;I[B)V")
|
||||
def onDescriptorRead(self, uuid, status, value):
|
||||
self.result_state(status, ("onDescriptorRead", uuid), bytes(value.tolist()))
|
||||
|
||||
@java_method("(Ljava/lang/String;I)V")
|
||||
def onDescriptorWrite(self, uuid, status):
|
||||
self.result_state(status, ("onDescriptorWrite", uuid))
|
91
bleak/backends/p4android/defs.py
Normal file
91
bleak/backends/p4android/defs.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import enum
|
||||
|
||||
from jnius import autoclass, cast
|
||||
|
||||
import bleak.exc
|
||||
from bleak.uuids import normalize_uuid_16
|
||||
|
||||
# caching constants avoids unnecessary extra use of the jni-python interface, which can be slow
|
||||
|
||||
List = autoclass("java.util.ArrayList")
|
||||
UUID = autoclass("java.util.UUID")
|
||||
BluetoothAdapter = autoclass("android.bluetooth.BluetoothAdapter")
|
||||
ScanCallback = autoclass("android.bluetooth.le.ScanCallback")
|
||||
ScanFilter = autoclass("android.bluetooth.le.ScanFilter")
|
||||
ScanFilterBuilder = autoclass("android.bluetooth.le.ScanFilter$Builder")
|
||||
ScanSettings = autoclass("android.bluetooth.le.ScanSettings")
|
||||
ScanSettingsBuilder = autoclass("android.bluetooth.le.ScanSettings$Builder")
|
||||
BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice")
|
||||
BluetoothGatt = autoclass("android.bluetooth.BluetoothGatt")
|
||||
BluetoothGattCharacteristic = autoclass("android.bluetooth.BluetoothGattCharacteristic")
|
||||
BluetoothGattDescriptor = autoclass("android.bluetooth.BluetoothGattDescriptor")
|
||||
BluetoothProfile = autoclass("android.bluetooth.BluetoothProfile")
|
||||
|
||||
PythonActivity = autoclass("org.kivy.android.PythonActivity")
|
||||
ParcelUuid = autoclass("android.os.ParcelUuid")
|
||||
activity = cast("android.app.Activity", PythonActivity.mActivity)
|
||||
context = cast("android.content.Context", activity.getApplicationContext())
|
||||
|
||||
ScanResult = autoclass("android.bluetooth.le.ScanResult")
|
||||
|
||||
BLEAK_JNI_NAMESPACE = "com.github.hbldh.bleak"
|
||||
PythonScanCallback = autoclass(BLEAK_JNI_NAMESPACE + ".PythonScanCallback")
|
||||
PythonBluetoothGattCallback = autoclass(
|
||||
BLEAK_JNI_NAMESPACE + ".PythonBluetoothGattCallback"
|
||||
)
|
||||
|
||||
|
||||
class ScanFailed(enum.IntEnum):
|
||||
ALREADY_STARTED = ScanCallback.SCAN_FAILED_ALREADY_STARTED
|
||||
APPLICATION_REGISTRATION_FAILED = (
|
||||
ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED
|
||||
)
|
||||
FEATURE_UNSUPPORTED = ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED
|
||||
INTERNAL_ERROR = ScanCallback.SCAN_FAILED_INTERNAL_ERROR
|
||||
|
||||
|
||||
GATT_SUCCESS = 0x0000
|
||||
# TODO: we may need different lookups, e.g. one for bleak.exc.CONTROLLER_ERROR_CODES
|
||||
GATT_STATUS_STRINGS = {
|
||||
# https://developer.android.com/reference/android/bluetooth/BluetoothGatt
|
||||
# https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/5738f83aeb59361a0a2eda2460113f6dc9194271/stack/include/gatt_api.h
|
||||
# https://android.googlesource.com/platform/system/bt/+/master/stack/include/gatt_api.h
|
||||
# https://www.bluetooth.com/specifications/bluetooth-core-specification/
|
||||
**bleak.exc.PROTOCOL_ERROR_CODES,
|
||||
0x007F: "Too Short",
|
||||
0x0080: "No Resources",
|
||||
0x0081: "Internal Error",
|
||||
0x0082: "Wrong State",
|
||||
0x0083: "DB Full",
|
||||
0x0084: "Busy",
|
||||
0x0085: "Error",
|
||||
0x0086: "Command Started",
|
||||
0x0087: "Illegal Parameter",
|
||||
0x0088: "Pending",
|
||||
0x0089: "Auth Failure",
|
||||
0x008A: "More",
|
||||
0x008B: "Invalid Configuration",
|
||||
0x008C: "Service Started",
|
||||
0x008D: "Encrypted No MITM",
|
||||
0x008E: "Not Encrypted",
|
||||
0x008F: "Congested",
|
||||
0x0090: "Duplicate Reg",
|
||||
0x0091: "Already Open",
|
||||
0x0092: "Cancel",
|
||||
0x0101: "Failure",
|
||||
}
|
||||
|
||||
CHARACTERISTIC_PROPERTY_DBUS_NAMES = {
|
||||
BluetoothGattCharacteristic.PROPERTY_BROADCAST: "broadcast",
|
||||
BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS: "extended-properties",
|
||||
BluetoothGattCharacteristic.PROPERTY_INDICATE: "indicate",
|
||||
BluetoothGattCharacteristic.PROPERTY_NOTIFY: "notify",
|
||||
BluetoothGattCharacteristic.PROPERTY_READ: "read",
|
||||
BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE: "authenticated-signed-writes",
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE: "write",
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE: "write-without-response",
|
||||
}
|
||||
|
||||
CLIENT_CHARACTERISTIC_CONFIGURATION_UUID = normalize_uuid_16(0x2902)
|
37
bleak/backends/p4android/descriptor.py
Normal file
37
bleak/backends/p4android/descriptor.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
from ..descriptor import BleakGATTDescriptor
|
||||
|
||||
|
||||
class BleakGATTDescriptorP4Android(BleakGATTDescriptor):
|
||||
"""GATT Descriptor implementation for python-for-android backend"""
|
||||
|
||||
def __init__(
|
||||
self, java, characteristic_uuid: str, characteristic_handle: int, index: int
|
||||
):
|
||||
super(BleakGATTDescriptorP4Android, self).__init__(java)
|
||||
self.__uuid = self.obj.getUuid().toString()
|
||||
self.__characteristic_uuid = characteristic_uuid
|
||||
self.__characteristic_handle = characteristic_handle
|
||||
# many devices have sequential handles and this formula will mysteriously work for them
|
||||
# it's possible this formula could make duplicate handles on other devices.
|
||||
self.__fake_handle = self.__characteristic_handle + 1 + index
|
||||
|
||||
@property
|
||||
def characteristic_handle(self) -> int:
|
||||
"""handle for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_handle
|
||||
|
||||
@property
|
||||
def characteristic_uuid(self) -> str:
|
||||
"""UUID for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_uuid
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""UUID for this descriptor"""
|
||||
return self.__uuid
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""Integer handle for this descriptor"""
|
||||
# 2021-01 The Android Bluetooth API does not appear to provide access to descriptor handles.
|
||||
return self.__fake_handle
|
|
@ -0,0 +1,84 @@
|
|||
package com.github.hbldh.bleak;
|
||||
|
||||
import java.net.ConnectException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCallback;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.bluetooth.BluetoothGattDescriptor;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
|
||||
|
||||
public final class PythonBluetoothGattCallback extends BluetoothGattCallback
|
||||
{
|
||||
public interface Interface
|
||||
{
|
||||
public void onConnectionStateChange(int status, int newState);
|
||||
public void onMtuChanged(int mtu, int status);
|
||||
public void onServicesDiscovered(int status);
|
||||
public void onCharacteristicChanged(int handle, byte[] value);
|
||||
public void onCharacteristicRead(int handle, int status, byte[] value);
|
||||
public void onCharacteristicWrite(int handle, int status);
|
||||
public void onDescriptorRead(String uuid, int status, byte[] value);
|
||||
public void onDescriptorWrite(String uuid, int status);
|
||||
}
|
||||
private Interface callback;
|
||||
|
||||
public PythonBluetoothGattCallback(Interface pythonCallback)
|
||||
{
|
||||
callback = pythonCallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState)
|
||||
{
|
||||
callback.onConnectionStateChange(status, newState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMtuChanged(BluetoothGatt gatt, int mtu, int status)
|
||||
{
|
||||
callback.onMtuChanged(mtu, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServicesDiscovered(BluetoothGatt gatt, int status)
|
||||
{
|
||||
callback.onServicesDiscovered(status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)
|
||||
{
|
||||
callback.onCharacteristicRead(characteristic.getInstanceId(), status, characteristic.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)
|
||||
{
|
||||
callback.onCharacteristicWrite(characteristic.getInstanceId(), status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic)
|
||||
{
|
||||
callback.onCharacteristicChanged(characteristic.getInstanceId(), characteristic.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status)
|
||||
{
|
||||
callback.onDescriptorRead(descriptor.getUuid().toString(), status, descriptor.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status)
|
||||
{
|
||||
callback.onDescriptorWrite(descriptor.getUuid().toString(), status);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package com.github.hbldh.bleak;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import android.bluetooth.le.ScanCallback;
|
||||
import android.bluetooth.le.ScanResult;
|
||||
|
||||
public final class PythonScanCallback extends ScanCallback
|
||||
{
|
||||
public interface Interface
|
||||
{
|
||||
public void onScanFailed(int code);
|
||||
public void onScanResult(ScanResult result);
|
||||
}
|
||||
private Interface callback;
|
||||
|
||||
public PythonScanCallback(Interface pythonCallback)
|
||||
{
|
||||
callback = pythonCallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBatchScanResults(List<ScanResult> results)
|
||||
{
|
||||
for (ScanResult result : results) {
|
||||
callback.onScanResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScanFailed(int errorCode)
|
||||
{
|
||||
callback.onScanFailed(errorCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScanResult(int callbackType, ScanResult result)
|
||||
{
|
||||
callback.onScanResult(result);
|
||||
}
|
||||
}
|
58
bleak/backends/p4android/recipes/bleak/__init__.py
Normal file
58
bleak/backends/p4android/recipes/bleak/__init__.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
import os
|
||||
from os.path import join
|
||||
|
||||
import sh
|
||||
from pythonforandroid.recipe import PythonRecipe
|
||||
from pythonforandroid.toolchain import info, shprint
|
||||
|
||||
|
||||
class BleakRecipe(PythonRecipe):
|
||||
version = None # Must be none for p4a to correctly clone repo
|
||||
fix_setup_py_version = "bleak develop branch"
|
||||
url = "git+https://github.com/hbldh/bleak.git"
|
||||
name = "bleak"
|
||||
|
||||
depends = ["pyjnius"]
|
||||
call_hostpython_via_targetpython = False
|
||||
|
||||
fix_setup_filename = "fix_setup.py"
|
||||
|
||||
def prepare_build_dir(self, arch):
|
||||
super().prepare_build_dir(arch) # Unpack the url file to the get_build_dir
|
||||
build_dir = self.get_build_dir(arch)
|
||||
|
||||
setup_py_path = join(build_dir, "setup.py")
|
||||
if not os.path.exists(setup_py_path):
|
||||
# Perform the p4a temporary fix
|
||||
# At the moment, p4a recipe installing requires setup.py to be present
|
||||
# So, we create a setup.py file only for android
|
||||
|
||||
fix_setup_py_path = join(self.get_recipe_dir(), self.fix_setup_filename)
|
||||
with open(fix_setup_py_path, "r") as f:
|
||||
contents = f.read()
|
||||
|
||||
# Write to the correct location and fill in the version number
|
||||
with open(setup_py_path, "w") as f:
|
||||
f.write(contents.replace("[VERSION]", self.fix_setup_py_version))
|
||||
else:
|
||||
info("setup.py found in bleak directory, are you installing older version?")
|
||||
|
||||
def get_recipe_env(self, arch=None, with_flags_in_cc=True):
|
||||
env = super().get_recipe_env(arch, with_flags_in_cc)
|
||||
# to find jnius and identify p4a
|
||||
env["PYJNIUS_PACKAGES"] = self.ctx.get_site_packages_dir(arch)
|
||||
return env
|
||||
|
||||
def postbuild_arch(self, arch):
|
||||
super().postbuild_arch(arch)
|
||||
|
||||
info("Copying java files")
|
||||
dest_dir = self.ctx.javaclass_dir
|
||||
path = join(
|
||||
self.get_build_dir(arch.arch), "bleak", "backends", "p4android", "java", "."
|
||||
)
|
||||
|
||||
shprint(sh.cp, "-a", path, dest_dir)
|
||||
|
||||
|
||||
recipe = BleakRecipe()
|
10
bleak/backends/p4android/recipes/bleak/fix_setup.py
Normal file
10
bleak/backends/p4android/recipes/bleak/fix_setup.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from setuptools import find_packages, setup
|
||||
|
||||
VERSION = "[VERSION]" # Version will be filled in by the bleak recipe
|
||||
NAME = "bleak"
|
||||
|
||||
setup(
|
||||
name=NAME,
|
||||
version=VERSION,
|
||||
packages=find_packages(exclude=("tests", "examples", "docs")),
|
||||
)
|
297
bleak/backends/p4android/scanner.py
Normal file
297
bleak/backends/p4android/scanner.py
Normal file
|
@ -0,0 +1,297 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import warnings
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from async_timeout import timeout as async_timeout
|
||||
else:
|
||||
from asyncio import timeout as async_timeout
|
||||
|
||||
from android.broadcast import BroadcastReceiver
|
||||
from android.permissions import Permission, request_permissions
|
||||
from jnius import cast, java_method
|
||||
|
||||
from ...exc import BleakError
|
||||
from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner
|
||||
from . import defs, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleakScannerP4Android(BaseBleakScanner):
|
||||
"""
|
||||
The python-for-android Bleak BLE Scanner.
|
||||
|
||||
Args:
|
||||
detection_callback:
|
||||
Optional function that will be called each time a device is
|
||||
discovered or advertising data has changed.
|
||||
service_uuids:
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received. Specifying this
|
||||
also enables scanning while the screen is off on Android.
|
||||
scanning_mode:
|
||||
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
|
||||
"""
|
||||
|
||||
__scanner = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detection_callback: Optional[AdvertisementDataCallback],
|
||||
service_uuids: Optional[List[str]],
|
||||
scanning_mode: Literal["active", "passive"],
|
||||
**kwargs,
|
||||
):
|
||||
super(BleakScannerP4Android, self).__init__(detection_callback, service_uuids)
|
||||
|
||||
if scanning_mode == "passive":
|
||||
self.__scan_mode = defs.ScanSettings.SCAN_MODE_OPPORTUNISTIC
|
||||
else:
|
||||
self.__scan_mode = defs.ScanSettings.SCAN_MODE_LOW_LATENCY
|
||||
|
||||
self.__adapter = None
|
||||
self.__javascanner = None
|
||||
self.__callback = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.__stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
if BleakScannerP4Android.__scanner is not None:
|
||||
raise BleakError("A BleakScanner is already scanning on this adapter.")
|
||||
|
||||
logger.debug("Starting BTLE scan")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if self.__javascanner is None:
|
||||
if self.__callback is None:
|
||||
self.__callback = _PythonScanCallback(self, loop)
|
||||
|
||||
permission_acknowledged = loop.create_future()
|
||||
|
||||
def handle_permissions(permissions, grantResults):
|
||||
if any(grantResults):
|
||||
loop.call_soon_threadsafe(
|
||||
permission_acknowledged.set_result, grantResults
|
||||
)
|
||||
else:
|
||||
loop.call_soon_threadsafe(
|
||||
permission_acknowledged.set_exception(
|
||||
BleakError("User denied access to " + str(permissions))
|
||||
)
|
||||
)
|
||||
|
||||
request_permissions(
|
||||
[
|
||||
Permission.ACCESS_FINE_LOCATION,
|
||||
Permission.ACCESS_COARSE_LOCATION,
|
||||
"android.permission.ACCESS_BACKGROUND_LOCATION",
|
||||
],
|
||||
handle_permissions,
|
||||
)
|
||||
await permission_acknowledged
|
||||
|
||||
self.__adapter = defs.BluetoothAdapter.getDefaultAdapter()
|
||||
if self.__adapter is None:
|
||||
raise BleakError("Bluetooth is not supported on this hardware platform")
|
||||
if self.__adapter.getState() != defs.BluetoothAdapter.STATE_ON:
|
||||
raise BleakError("Bluetooth is not turned on")
|
||||
|
||||
self.__javascanner = self.__adapter.getBluetoothLeScanner()
|
||||
|
||||
BleakScannerP4Android.__scanner = self
|
||||
|
||||
filters = cast("java.util.List", defs.List())
|
||||
if self._service_uuids:
|
||||
for uuid in self._service_uuids:
|
||||
filters.add(
|
||||
defs.ScanFilterBuilder()
|
||||
.setServiceUuid(defs.ParcelUuid.fromString(uuid))
|
||||
.build()
|
||||
)
|
||||
|
||||
scanfuture = self.__callback.perform_and_wait(
|
||||
dispatchApi=self.__javascanner.startScan,
|
||||
dispatchParams=(
|
||||
filters,
|
||||
defs.ScanSettingsBuilder()
|
||||
.setScanMode(self.__scan_mode)
|
||||
.setReportDelay(0)
|
||||
.setPhy(defs.ScanSettings.PHY_LE_ALL_SUPPORTED)
|
||||
.setNumOfMatches(defs.ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||
.setMatchMode(defs.ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||
.setCallbackType(defs.ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||
.build(),
|
||||
self.__callback.java,
|
||||
),
|
||||
resultApi="onScan",
|
||||
return_indicates_status=False,
|
||||
)
|
||||
self.__javascanner.flushPendingScanResults(self.__callback.java)
|
||||
|
||||
try:
|
||||
async with async_timeout(0.2):
|
||||
await scanfuture
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
pass
|
||||
except BleakError as bleakerror:
|
||||
await self.stop()
|
||||
if bleakerror.args != (
|
||||
"onScan",
|
||||
"SCAN_FAILED_APPLICATION_REGISTRATION_FAILED",
|
||||
):
|
||||
raise bleakerror
|
||||
else:
|
||||
# there might be a clearer solution to this if android source and vendor
|
||||
# documentation are reviewed for the meaning of the error
|
||||
# https://stackoverflow.com/questions/27516399/solution-for-ble-scans-scan-failed-application-registration-failed
|
||||
warnings.warn(
|
||||
"BT API gave SCAN_FAILED_APPLICATION_REGISTRATION_FAILED. Resetting adapter."
|
||||
)
|
||||
|
||||
def handlerWaitingForState(state, stateFuture):
|
||||
def handleAdapterStateChanged(context, intent):
|
||||
adapter_state = intent.getIntExtra(
|
||||
defs.BluetoothAdapter.EXTRA_STATE,
|
||||
defs.BluetoothAdapter.STATE_ERROR,
|
||||
)
|
||||
if adapter_state == defs.BluetoothAdapter.STATE_ERROR:
|
||||
loop.call_soon_threadsafe(
|
||||
stateOffFuture.set_exception,
|
||||
BleakError(f"Unexpected adapter state {adapter_state}"),
|
||||
)
|
||||
elif adapter_state == state:
|
||||
loop.call_soon_threadsafe(
|
||||
stateFuture.set_result, adapter_state
|
||||
)
|
||||
|
||||
return handleAdapterStateChanged
|
||||
|
||||
logger.info(
|
||||
"disabling bluetooth adapter to handle SCAN_FAILED_APPLICATION_REGSTRATION_FAILED ..."
|
||||
)
|
||||
stateOffFuture = loop.create_future()
|
||||
receiver = BroadcastReceiver(
|
||||
handlerWaitingForState(
|
||||
defs.BluetoothAdapter.STATE_OFF, stateOffFuture
|
||||
),
|
||||
actions=[defs.BluetoothAdapter.ACTION_STATE_CHANGED],
|
||||
)
|
||||
receiver.start()
|
||||
try:
|
||||
self.__adapter.disable()
|
||||
await stateOffFuture
|
||||
finally:
|
||||
receiver.stop()
|
||||
|
||||
logger.info("re-enabling bluetooth adapter ...")
|
||||
stateOnFuture = loop.create_future()
|
||||
receiver = BroadcastReceiver(
|
||||
handlerWaitingForState(
|
||||
defs.BluetoothAdapter.STATE_ON, stateOnFuture
|
||||
),
|
||||
actions=[defs.BluetoothAdapter.ACTION_STATE_CHANGED],
|
||||
)
|
||||
receiver.start()
|
||||
try:
|
||||
self.__adapter.enable()
|
||||
await stateOnFuture
|
||||
finally:
|
||||
receiver.stop()
|
||||
logger.debug("restarting scan ...")
|
||||
|
||||
return await self.start()
|
||||
|
||||
def __stop(self) -> None:
|
||||
if self.__javascanner is not None:
|
||||
logger.debug("Stopping BTLE scan")
|
||||
self.__javascanner.stopScan(self.__callback.java)
|
||||
BleakScannerP4Android.__scanner = None
|
||||
self.__javascanner = None
|
||||
else:
|
||||
logger.debug("BTLE scan already stopped")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.__stop()
|
||||
|
||||
def set_scanning_filter(self, **kwargs) -> None:
|
||||
# If we do end up implementing this, this should accept List<ScanFilter>
|
||||
# and ScanSettings java objects to pass to startScan().
|
||||
raise NotImplementedError("not implemented in Android backend")
|
||||
|
||||
def _handle_scan_result(self, result) -> None:
|
||||
native_device = result.getDevice()
|
||||
record = result.getScanRecord()
|
||||
|
||||
service_uuids = record.getServiceUuids()
|
||||
if service_uuids is not None:
|
||||
service_uuids = [service_uuid.toString() for service_uuid in service_uuids]
|
||||
|
||||
if not self.is_allowed_uuid(service_uuids):
|
||||
return
|
||||
|
||||
manufacturer_data = record.getManufacturerSpecificData()
|
||||
manufacturer_data = {
|
||||
manufacturer_data.keyAt(index): bytes(manufacturer_data.valueAt(index))
|
||||
for index in range(manufacturer_data.size())
|
||||
}
|
||||
|
||||
service_data = {
|
||||
entry.getKey().toString(): bytes(entry.getValue())
|
||||
for entry in record.getServiceData().entrySet()
|
||||
}
|
||||
tx_power = record.getTxPowerLevel()
|
||||
|
||||
# change "not present" value to None to match other backends
|
||||
if tx_power == -2147483648: # Integer#MIN_VALUE
|
||||
tx_power = None
|
||||
|
||||
advertisement = AdvertisementData(
|
||||
local_name=record.getDeviceName(),
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_data=service_data,
|
||||
service_uuids=service_uuids,
|
||||
tx_power=tx_power,
|
||||
rssi=result.getRssi(),
|
||||
platform_data=(result,),
|
||||
)
|
||||
|
||||
device = self.create_or_update_device(
|
||||
native_device.getAddress(),
|
||||
native_device.getName(),
|
||||
native_device,
|
||||
advertisement,
|
||||
)
|
||||
|
||||
self.call_detection_callbacks(device, advertisement)
|
||||
|
||||
|
||||
class _PythonScanCallback(utils.AsyncJavaCallbacks):
|
||||
__javainterfaces__ = ["com.github.hbldh.bleak.PythonScanCallback$Interface"]
|
||||
|
||||
def __init__(self, scanner: BleakScannerP4Android, loop: asyncio.AbstractEventLoop):
|
||||
super().__init__(loop)
|
||||
self._scanner = scanner
|
||||
self.java = defs.PythonScanCallback(self)
|
||||
|
||||
def result_state(self, status_str, name, *data):
|
||||
self._loop.call_soon_threadsafe(
|
||||
self._result_state_unthreadsafe, status_str, name, data
|
||||
)
|
||||
|
||||
@java_method("(I)V")
|
||||
def onScanFailed(self, errorCode):
|
||||
self.result_state(defs.ScanFailed(errorCode).name, "onScan")
|
||||
|
||||
@java_method("(Landroid/bluetooth/le/ScanResult;)V")
|
||||
def onScanResult(self, result):
|
||||
self._loop.call_soon_threadsafe(self._scanner._handle_scan_result, result)
|
||||
|
||||
if "onScan" not in self.states:
|
||||
self.result_state(None, "onScan", result)
|
36
bleak/backends/p4android/service.py
Normal file
36
bleak/backends/p4android/service.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from typing import List
|
||||
|
||||
from ..service import BleakGATTService
|
||||
from .characteristic import BleakGATTCharacteristicP4Android
|
||||
|
||||
|
||||
class BleakGATTServiceP4Android(BleakGATTService):
|
||||
"""GATT Service implementation for the python-for-android backend"""
|
||||
|
||||
def __init__(self, java):
|
||||
super().__init__(java)
|
||||
self.__uuid = self.obj.getUuid().toString()
|
||||
self.__handle = self.obj.getInstanceId()
|
||||
self.__characteristics = []
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""The UUID to this service"""
|
||||
return self.__uuid
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""A unique identifier for this service"""
|
||||
return self.__handle
|
||||
|
||||
@property
|
||||
def characteristics(self) -> List[BleakGATTCharacteristicP4Android]:
|
||||
"""List of characteristics for this service"""
|
||||
return self.__characteristics
|
||||
|
||||
def add_characteristic(self, characteristic: BleakGATTCharacteristicP4Android):
|
||||
"""Add a :py:class:`~BleakGATTCharacteristicP4Android` to the service.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__characteristics.append(characteristic)
|
94
bleak/backends/p4android/utils.py
Normal file
94
bleak/backends/p4android/utils.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from jnius import PythonJavaClass
|
||||
|
||||
from ...exc import BleakError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncJavaCallbacks(PythonJavaClass):
|
||||
__javacontext__ = "app"
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop):
|
||||
self._loop = loop
|
||||
self.states = {}
|
||||
self.futures = {}
|
||||
|
||||
@staticmethod
|
||||
def _if_expected(result, expected):
|
||||
if result[: len(expected)] == expected[:]:
|
||||
return result[len(expected) :]
|
||||
else:
|
||||
return None
|
||||
|
||||
async def perform_and_wait(
|
||||
self,
|
||||
dispatchApi,
|
||||
dispatchParams,
|
||||
resultApi,
|
||||
resultExpected=(),
|
||||
unless_already=False,
|
||||
return_indicates_status=True,
|
||||
):
|
||||
result2 = None
|
||||
if unless_already:
|
||||
if resultApi in self.states:
|
||||
result2 = self._if_expected(self.states[resultApi][1:], resultExpected)
|
||||
result1 = True
|
||||
|
||||
if result2 is not None:
|
||||
logger.debug(
|
||||
f"Not waiting for android api {resultApi} because found {resultExpected}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Waiting for android api {resultApi}")
|
||||
|
||||
state = self._loop.create_future()
|
||||
self.futures[resultApi] = state
|
||||
result1 = dispatchApi(*dispatchParams)
|
||||
if return_indicates_status and not result1:
|
||||
del self.futures[resultApi]
|
||||
raise BleakError(f"api call failed, not waiting for {resultApi}")
|
||||
data = await state
|
||||
result2 = self._if_expected(data, resultExpected)
|
||||
if result2 is None:
|
||||
raise BleakError("Expected", resultExpected, "got", data)
|
||||
|
||||
logger.debug(f"{resultApi} succeeded {result2}")
|
||||
|
||||
if return_indicates_status:
|
||||
return result2
|
||||
else:
|
||||
return (result1, *result2)
|
||||
|
||||
def _result_state_unthreadsafe(self, failure_str, source, data):
|
||||
logger.debug(f"Java state transfer {source} error={failure_str} data={data}")
|
||||
self.states[source] = (failure_str, *data)
|
||||
future = self.futures.get(source, None)
|
||||
if future is not None and not future.done():
|
||||
if failure_str is None:
|
||||
future.set_result(data)
|
||||
else:
|
||||
future.set_exception(BleakError(source, failure_str, *data))
|
||||
else:
|
||||
if failure_str is not None:
|
||||
# an error happened with nothing waiting for it
|
||||
exception = BleakError(source, failure_str, *data)
|
||||
namedfutures = [
|
||||
namedfuture
|
||||
for namedfuture in self.futures.items()
|
||||
if not namedfuture[1].done()
|
||||
]
|
||||
if len(namedfutures):
|
||||
# send it on existing requests
|
||||
for name, future in namedfutures:
|
||||
warnings.warn(f"Redirecting error without home to {name}")
|
||||
future.set_exception(exception)
|
||||
else:
|
||||
# send it on the event thread
|
||||
raise exception
|
335
bleak/backends/scanner.py
Normal file
335
bleak/backends/scanner.py
Normal file
|
@ -0,0 +1,335 @@
|
|||
import abc
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import platform
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
Hashable,
|
||||
List,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
)
|
||||
|
||||
from ..exc import BleakError
|
||||
from .device import BLEDevice
|
||||
|
||||
# prevent tasks from being garbage collected
|
||||
_background_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
|
||||
class AdvertisementData(NamedTuple):
|
||||
"""
|
||||
Wrapper around the advertisement data that each platform returns upon discovery
|
||||
"""
|
||||
|
||||
local_name: Optional[str]
|
||||
"""
|
||||
The local name of the device or ``None`` if not included in advertising data.
|
||||
"""
|
||||
|
||||
manufacturer_data: Dict[int, bytes]
|
||||
"""
|
||||
Dictionary of manufacturer data in bytes from the received advertisement data or empty dict if not present.
|
||||
|
||||
The keys are Bluetooth SIG assigned Company Identifiers and the values are bytes.
|
||||
|
||||
https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/
|
||||
"""
|
||||
|
||||
service_data: Dict[str, bytes]
|
||||
"""
|
||||
Dictionary of service data from the received advertisement data or empty dict if not present.
|
||||
"""
|
||||
|
||||
service_uuids: List[str]
|
||||
"""
|
||||
List of service UUIDs from the received advertisement data or empty list if not present.
|
||||
"""
|
||||
|
||||
tx_power: Optional[int]
|
||||
"""
|
||||
TX Power Level of the remote device from the received advertising data or ``None`` if not present.
|
||||
|
||||
.. versionadded:: 0.17
|
||||
"""
|
||||
|
||||
rssi: int
|
||||
"""
|
||||
The Radio Receive Signal Strength (RSSI) in dBm.
|
||||
|
||||
.. versionadded:: 0.19
|
||||
"""
|
||||
|
||||
platform_data: Tuple
|
||||
"""
|
||||
Tuple of platform specific data.
|
||||
|
||||
This is not a stable API. The actual values may change between releases.
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
kwargs = []
|
||||
if self.local_name:
|
||||
kwargs.append(f"local_name={repr(self.local_name)}")
|
||||
if self.manufacturer_data:
|
||||
kwargs.append(f"manufacturer_data={repr(self.manufacturer_data)}")
|
||||
if self.service_data:
|
||||
kwargs.append(f"service_data={repr(self.service_data)}")
|
||||
if self.service_uuids:
|
||||
kwargs.append(f"service_uuids={repr(self.service_uuids)}")
|
||||
if self.tx_power is not None:
|
||||
kwargs.append(f"tx_power={repr(self.tx_power)}")
|
||||
kwargs.append(f"rssi={repr(self.rssi)}")
|
||||
return f"AdvertisementData({', '.join(kwargs)})"
|
||||
|
||||
|
||||
AdvertisementDataCallback = Callable[
|
||||
[BLEDevice, AdvertisementData],
|
||||
Optional[Coroutine[Any, Any, None]],
|
||||
]
|
||||
"""
|
||||
Type alias for callback called when advertisement data is received.
|
||||
"""
|
||||
|
||||
AdvertisementDataFilter = Callable[
|
||||
[BLEDevice, AdvertisementData],
|
||||
bool,
|
||||
]
|
||||
"""
|
||||
Type alias for an advertisement data filter function.
|
||||
|
||||
Implementations should return ``True`` for matches, otherwise ``False``.
|
||||
"""
|
||||
|
||||
|
||||
class BaseBleakScanner(abc.ABC):
|
||||
"""
|
||||
Interface for Bleak Bluetooth LE Scanners
|
||||
|
||||
Args:
|
||||
detection_callback:
|
||||
Optional function that will be called each time a device is
|
||||
discovered or advertising data has changed.
|
||||
service_uuids:
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received.
|
||||
"""
|
||||
|
||||
seen_devices: Dict[str, Tuple[BLEDevice, AdvertisementData]]
|
||||
"""
|
||||
Map of device identifier to BLEDevice and most recent advertisement data.
|
||||
|
||||
This map must be cleared when scanning starts.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detection_callback: Optional[AdvertisementDataCallback],
|
||||
service_uuids: Optional[List[str]],
|
||||
):
|
||||
super(BaseBleakScanner, self).__init__()
|
||||
|
||||
self._ad_callbacks: Dict[
|
||||
Hashable, Callable[[BLEDevice, AdvertisementData], None]
|
||||
] = {}
|
||||
"""
|
||||
List of callbacks to call when an advertisement is received.
|
||||
"""
|
||||
|
||||
if detection_callback is not None:
|
||||
self.register_detection_callback(detection_callback)
|
||||
|
||||
self._service_uuids: Optional[List[str]] = (
|
||||
[u.lower() for u in service_uuids] if service_uuids is not None else None
|
||||
)
|
||||
|
||||
self.seen_devices = {}
|
||||
|
||||
def register_detection_callback(
|
||||
self, callback: Optional[AdvertisementDataCallback]
|
||||
) -> Callable[[], None]:
|
||||
"""
|
||||
Register a callback that is called when an advertisement event from the
|
||||
OS is received.
|
||||
|
||||
The ``callback`` is a function or coroutine that takes two arguments: :class:`BLEDevice`
|
||||
and :class:`AdvertisementData`.
|
||||
|
||||
Args:
|
||||
callback: A function, coroutine or ``None``.
|
||||
|
||||
Returns:
|
||||
A method that can be called to unregister the callback.
|
||||
"""
|
||||
error_text = "callback must be callable with 2 parameters"
|
||||
|
||||
if not callable(callback):
|
||||
raise TypeError(error_text)
|
||||
|
||||
handler_signature = inspect.signature(callback)
|
||||
|
||||
if len(handler_signature.parameters) != 2:
|
||||
raise TypeError(error_text)
|
||||
|
||||
if inspect.iscoroutinefunction(callback):
|
||||
|
||||
def detection_callback(s: BLEDevice, d: AdvertisementData) -> None:
|
||||
task = asyncio.create_task(callback(s, d))
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
else:
|
||||
detection_callback = callback
|
||||
|
||||
token = object()
|
||||
|
||||
self._ad_callbacks[token] = detection_callback
|
||||
|
||||
def remove() -> None:
|
||||
self._ad_callbacks.pop(token, None)
|
||||
|
||||
return remove
|
||||
|
||||
def is_allowed_uuid(self, service_uuids: Optional[List[str]]) -> bool:
|
||||
"""
|
||||
Check if the advertisement data contains any of the service UUIDs
|
||||
matching the filter. If no filter is set, this will always return
|
||||
``True``.
|
||||
|
||||
Args:
|
||||
service_uuids: The service UUIDs from the advertisement data.
|
||||
|
||||
Returns:
|
||||
``True`` if the advertisement data should be allowed or ``False``
|
||||
if the advertisement data should be filtered out.
|
||||
"""
|
||||
# Backends will make best effort to filter out advertisements that
|
||||
# don't match the service UUIDs, but if other apps are scanning at the
|
||||
# same time or something like that, we may still receive advertisements
|
||||
# that don't match. So we need to do more filtering here to get the
|
||||
# expected behavior.
|
||||
|
||||
if not self._service_uuids:
|
||||
# if there is no filter, everything is allowed
|
||||
return True
|
||||
|
||||
if not service_uuids:
|
||||
# if there is a filter the advertisement data doesn't contain any
|
||||
# service UUIDs, filter it out
|
||||
return False
|
||||
|
||||
for uuid in service_uuids:
|
||||
if uuid in self._service_uuids:
|
||||
# match was found, keep this advertisement
|
||||
return True
|
||||
|
||||
# there were no matching service uuids, filter this one out
|
||||
return False
|
||||
|
||||
def call_detection_callbacks(
|
||||
self, device: BLEDevice, advertisement_data: AdvertisementData
|
||||
) -> None:
|
||||
"""
|
||||
Calls all registered detection callbacks.
|
||||
|
||||
Backend implementations should call this method when an advertisement
|
||||
event is received from the OS.
|
||||
"""
|
||||
|
||||
for callback in self._ad_callbacks.values():
|
||||
callback(device, advertisement_data)
|
||||
|
||||
def create_or_update_device(
|
||||
self, address: str, name: str, details: Any, adv: AdvertisementData
|
||||
) -> BLEDevice:
|
||||
"""
|
||||
Creates or updates a device in :attr:`seen_devices`.
|
||||
|
||||
Args:
|
||||
address: The Bluetooth address of the device (UUID on macOS).
|
||||
name: The OS display name for the device.
|
||||
details: The platform-specific handle for the device.
|
||||
adv: The most recent advertisement data received.
|
||||
|
||||
Returns:
|
||||
The updated device.
|
||||
"""
|
||||
|
||||
# for backwards compatibility, see https://github.com/hbldh/bleak/issues/1025
|
||||
metadata = dict(
|
||||
uuids=adv.service_uuids,
|
||||
manufacturer_data=adv.manufacturer_data,
|
||||
)
|
||||
|
||||
try:
|
||||
device, _ = self.seen_devices[address]
|
||||
|
||||
device.name = name
|
||||
device._rssi = adv.rssi
|
||||
device._metadata = metadata
|
||||
except KeyError:
|
||||
device = BLEDevice(
|
||||
address,
|
||||
name,
|
||||
details,
|
||||
adv.rssi,
|
||||
**metadata,
|
||||
)
|
||||
|
||||
self.seen_devices[address] = (device, adv)
|
||||
|
||||
return device
|
||||
|
||||
@abc.abstractmethod
|
||||
async def start(self) -> None:
|
||||
"""Start scanning for devices"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
async def stop(self) -> None:
|
||||
"""Stop scanning for devices"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_scanning_filter(self, **kwargs) -> None:
|
||||
"""Set scanning filter for the BleakScanner.
|
||||
|
||||
Args:
|
||||
**kwargs: The filter details. This will differ a lot between backend implementations.
|
||||
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def get_platform_scanner_backend_type() -> Type[BaseBleakScanner]:
|
||||
"""
|
||||
Gets the platform-specific :class:`BaseBleakScanner` type.
|
||||
"""
|
||||
if os.environ.get("P4A_BOOTSTRAP") is not None:
|
||||
from bleak.backends.p4android.scanner import BleakScannerP4Android
|
||||
|
||||
return BleakScannerP4Android
|
||||
|
||||
if platform.system() == "Linux":
|
||||
from bleak.backends.bluezdbus.scanner import BleakScannerBlueZDBus
|
||||
|
||||
return BleakScannerBlueZDBus
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
from bleak.backends.corebluetooth.scanner import BleakScannerCoreBluetooth
|
||||
|
||||
return BleakScannerCoreBluetooth
|
||||
|
||||
if platform.system() == "Windows":
|
||||
from bleak.backends.winrt.scanner import BleakScannerWinRT
|
||||
|
||||
return BleakScannerWinRT
|
||||
|
||||
raise BleakError(f"Unsupported platform: {platform.system()}")
|
214
bleak/backends/service.py
Normal file
214
bleak/backends/service.py
Normal file
|
@ -0,0 +1,214 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Gatt Service Collection class and interface class for the Bleak representation of a GATT Service.
|
||||
|
||||
Created on 2019-03-19 by hbldh <henrik.blidh@nedomkull.com>
|
||||
|
||||
"""
|
||||
import abc
|
||||
import logging
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from ..exc import BleakError
|
||||
from ..uuids import normalize_uuid_str, uuidstr_to_str
|
||||
from .characteristic import BleakGATTCharacteristic
|
||||
from .descriptor import BleakGATTDescriptor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleakGATTService(abc.ABC):
|
||||
"""Interface for the Bleak representation of a GATT Service."""
|
||||
|
||||
def __init__(self, obj: Any) -> None:
|
||||
self.obj = obj
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.uuid} (Handle: {self.handle}): {self.description}"
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def handle(self) -> int:
|
||||
"""The handle of this service"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def uuid(self) -> str:
|
||||
"""The UUID to this service"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""String description for this service"""
|
||||
return uuidstr_to_str(self.uuid)
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def characteristics(self) -> List[BleakGATTCharacteristic]:
|
||||
"""List of characteristics for this service"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None:
|
||||
"""Add a :py:class:`~BleakGATTCharacteristic` to the service.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_characteristic(
|
||||
self, uuid: Union[str, UUID]
|
||||
) -> Union[BleakGATTCharacteristic, None]:
|
||||
"""Get a characteristic by UUID.
|
||||
|
||||
Args:
|
||||
uuid: The UUID to match.
|
||||
|
||||
Returns:
|
||||
The first characteristic matching ``uuid`` or ``None`` if no
|
||||
matching characteristic was found.
|
||||
"""
|
||||
uuid = normalize_uuid_str(str(uuid))
|
||||
|
||||
try:
|
||||
return next(filter(lambda x: x.uuid == uuid, self.characteristics))
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
|
||||
class BleakGATTServiceCollection:
|
||||
"""Simple data container for storing the peripheral's service complement."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.__services = {}
|
||||
self.__characteristics = {}
|
||||
self.__descriptors = {}
|
||||
|
||||
def __getitem__(
|
||||
self, item: Union[str, int, UUID]
|
||||
) -> Optional[
|
||||
Union[BleakGATTService, BleakGATTCharacteristic, BleakGATTDescriptor]
|
||||
]:
|
||||
"""Get a service, characteristic or descriptor from uuid or handle"""
|
||||
return (
|
||||
self.get_service(item)
|
||||
or self.get_characteristic(item)
|
||||
or self.get_descriptor(item)
|
||||
)
|
||||
|
||||
def __iter__(self) -> Iterator[BleakGATTService]:
|
||||
"""Returns an iterator over all BleakGATTService objects"""
|
||||
return iter(self.services.values())
|
||||
|
||||
@property
|
||||
def services(self) -> Dict[int, BleakGATTService]:
|
||||
"""Returns dictionary of handles mapping to BleakGATTService"""
|
||||
return self.__services
|
||||
|
||||
@property
|
||||
def characteristics(self) -> Dict[int, BleakGATTCharacteristic]:
|
||||
"""Returns dictionary of handles mapping to BleakGATTCharacteristic"""
|
||||
return self.__characteristics
|
||||
|
||||
@property
|
||||
def descriptors(self) -> Dict[int, BleakGATTDescriptor]:
|
||||
"""Returns a dictionary of integer handles mapping to BleakGATTDescriptor"""
|
||||
return self.__descriptors
|
||||
|
||||
def add_service(self, service: BleakGATTService) -> None:
|
||||
"""Add a :py:class:`~BleakGATTService` to the service collection.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
if service.handle not in self.__services:
|
||||
self.__services[service.handle] = service
|
||||
else:
|
||||
logger.error(
|
||||
"The service '%s' is already present in this BleakGATTServiceCollection!",
|
||||
service.handle,
|
||||
)
|
||||
|
||||
def get_service(
|
||||
self, specifier: Union[int, str, UUID]
|
||||
) -> Optional[BleakGATTService]:
|
||||
"""Get a service by handle (int) or UUID (str or uuid.UUID)"""
|
||||
if isinstance(specifier, int):
|
||||
return self.services.get(specifier)
|
||||
|
||||
uuid = normalize_uuid_str(str(specifier))
|
||||
|
||||
x = list(
|
||||
filter(
|
||||
lambda x: x.uuid == uuid,
|
||||
self.services.values(),
|
||||
)
|
||||
)
|
||||
|
||||
if len(x) > 1:
|
||||
raise BleakError(
|
||||
"Multiple Services with this UUID, refer to your desired service by the `handle` attribute instead."
|
||||
)
|
||||
|
||||
return x[0] if x else None
|
||||
|
||||
def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None:
|
||||
"""Add a :py:class:`~BleakGATTCharacteristic` to the service collection.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
if characteristic.handle not in self.__characteristics:
|
||||
self.__characteristics[characteristic.handle] = characteristic
|
||||
self.__services[characteristic.service_handle].add_characteristic(
|
||||
characteristic
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"The characteristic '%s' is already present in this BleakGATTServiceCollection!",
|
||||
characteristic.handle,
|
||||
)
|
||||
|
||||
def get_characteristic(
|
||||
self, specifier: Union[int, str, UUID]
|
||||
) -> Optional[BleakGATTCharacteristic]:
|
||||
"""Get a characteristic by handle (int) or UUID (str or uuid.UUID)"""
|
||||
if isinstance(specifier, int):
|
||||
return self.characteristics.get(specifier)
|
||||
|
||||
uuid = normalize_uuid_str(str(specifier))
|
||||
|
||||
# Assume uuid usage.
|
||||
x = list(
|
||||
filter(
|
||||
lambda x: x.uuid == uuid,
|
||||
self.characteristics.values(),
|
||||
)
|
||||
)
|
||||
|
||||
if len(x) > 1:
|
||||
raise BleakError(
|
||||
"Multiple Characteristics with this UUID, refer to your desired characteristic by the `handle` attribute instead."
|
||||
)
|
||||
|
||||
return x[0] if x else None
|
||||
|
||||
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
|
||||
"""Add a :py:class:`~BleakGATTDescriptor` to the service collection.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
if descriptor.handle not in self.__descriptors:
|
||||
self.__descriptors[descriptor.handle] = descriptor
|
||||
self.__characteristics[descriptor.characteristic_handle].add_descriptor(
|
||||
descriptor
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"The descriptor '%s' is already present in this BleakGATTServiceCollection!",
|
||||
descriptor.handle,
|
||||
)
|
||||
|
||||
def get_descriptor(self, handle: int) -> Optional[BleakGATTDescriptor]:
|
||||
"""Get a descriptor by integer handle"""
|
||||
return self.descriptors.get(handle)
|
0
bleak/backends/winrt/__init__.py
Normal file
0
bleak/backends/winrt/__init__.py
Normal file
142
bleak/backends/winrt/characteristic.py
Normal file
142
bleak/backends/winrt/characteristic.py
Normal file
|
@ -0,0 +1,142 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from typing import Callable, List, Union
|
||||
from uuid import UUID
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from winrt.windows.devices.bluetooth.genericattributeprofile import (
|
||||
GattCharacteristic,
|
||||
GattCharacteristicProperties,
|
||||
)
|
||||
else:
|
||||
from bleak_winrt.windows.devices.bluetooth.genericattributeprofile import (
|
||||
GattCharacteristic,
|
||||
GattCharacteristicProperties,
|
||||
)
|
||||
|
||||
from ..characteristic import BleakGATTCharacteristic
|
||||
from ..descriptor import BleakGATTDescriptor
|
||||
|
||||
_GattCharacteristicsPropertiesMap = {
|
||||
GattCharacteristicProperties.NONE: (
|
||||
"None",
|
||||
"The characteristic doesn’t have any properties that apply",
|
||||
),
|
||||
GattCharacteristicProperties.BROADCAST: (
|
||||
"Broadcast".lower(),
|
||||
"The characteristic supports broadcasting",
|
||||
),
|
||||
GattCharacteristicProperties.READ: (
|
||||
"Read".lower(),
|
||||
"The characteristic is readable",
|
||||
),
|
||||
GattCharacteristicProperties.WRITE_WITHOUT_RESPONSE: (
|
||||
"Write-Without-Response".lower(),
|
||||
"The characteristic supports Write Without Response",
|
||||
),
|
||||
GattCharacteristicProperties.WRITE: (
|
||||
"Write".lower(),
|
||||
"The characteristic is writable",
|
||||
),
|
||||
GattCharacteristicProperties.NOTIFY: (
|
||||
"Notify".lower(),
|
||||
"The characteristic is notifiable",
|
||||
),
|
||||
GattCharacteristicProperties.INDICATE: (
|
||||
"Indicate".lower(),
|
||||
"The characteristic is indicatable",
|
||||
),
|
||||
GattCharacteristicProperties.AUTHENTICATED_SIGNED_WRITES: (
|
||||
"Authenticated-Signed-Writes".lower(),
|
||||
"The characteristic supports signed writes",
|
||||
),
|
||||
GattCharacteristicProperties.EXTENDED_PROPERTIES: (
|
||||
"Extended-Properties".lower(),
|
||||
"The ExtendedProperties Descriptor is present",
|
||||
),
|
||||
GattCharacteristicProperties.RELIABLE_WRITES: (
|
||||
"Reliable-Writes".lower(),
|
||||
"The characteristic supports reliable writes",
|
||||
),
|
||||
GattCharacteristicProperties.WRITABLE_AUXILIARIES: (
|
||||
"Writable-Auxiliaries".lower(),
|
||||
"The characteristic has writable auxiliaries",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BleakGATTCharacteristicWinRT(BleakGATTCharacteristic):
|
||||
"""GATT Characteristic implementation for the .NET backend, implemented with WinRT"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
obj: GattCharacteristic,
|
||||
max_write_without_response_size: Callable[[], int],
|
||||
):
|
||||
super().__init__(obj, max_write_without_response_size)
|
||||
self.__descriptors = []
|
||||
self.__props = [
|
||||
_GattCharacteristicsPropertiesMap[v][0]
|
||||
for v in [2**n for n in range(10)]
|
||||
if (self.obj.characteristic_properties & v)
|
||||
]
|
||||
|
||||
@property
|
||||
def service_uuid(self) -> str:
|
||||
"""The uuid of the Service containing this characteristic"""
|
||||
return str(self.obj.service.uuid)
|
||||
|
||||
@property
|
||||
def service_handle(self) -> int:
|
||||
"""The integer handle of the Service containing this characteristic"""
|
||||
return int(self.obj.service.attribute_handle)
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""The handle of this characteristic"""
|
||||
return int(self.obj.attribute_handle)
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""The uuid of this characteristic"""
|
||||
return str(self.obj.uuid)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Description for this characteristic"""
|
||||
return (
|
||||
self.obj.user_description
|
||||
if self.obj.user_description
|
||||
else super().description
|
||||
)
|
||||
|
||||
@property
|
||||
def properties(self) -> List[str]:
|
||||
"""Properties of this characteristic"""
|
||||
return self.__props
|
||||
|
||||
@property
|
||||
def descriptors(self) -> List[BleakGATTDescriptor]:
|
||||
"""List of descriptors for this characteristic"""
|
||||
return self.__descriptors
|
||||
|
||||
def get_descriptor(
|
||||
self, specifier: Union[int, str, UUID]
|
||||
) -> Union[BleakGATTDescriptor, None]:
|
||||
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)"""
|
||||
try:
|
||||
if isinstance(specifier, int):
|
||||
return next(filter(lambda x: x.handle == specifier, self.descriptors))
|
||||
else:
|
||||
return next(
|
||||
filter(lambda x: x.uuid == str(specifier), self.descriptors)
|
||||
)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def add_descriptor(self, descriptor: BleakGATTDescriptor):
|
||||
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__descriptors.append(descriptor)
|
1134
bleak/backends/winrt/client.py
Normal file
1134
bleak/backends/winrt/client.py
Normal file
File diff suppressed because it is too large
Load diff
43
bleak/backends/winrt/descriptor.py
Normal file
43
bleak/backends/winrt/descriptor.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from winrt.windows.devices.bluetooth.genericattributeprofile import GattDescriptor
|
||||
else:
|
||||
from bleak_winrt.windows.devices.bluetooth.genericattributeprofile import (
|
||||
GattDescriptor,
|
||||
)
|
||||
|
||||
from ..descriptor import BleakGATTDescriptor
|
||||
|
||||
|
||||
class BleakGATTDescriptorWinRT(BleakGATTDescriptor):
|
||||
"""GATT Descriptor implementation for .NET backend, implemented with WinRT"""
|
||||
|
||||
def __init__(
|
||||
self, obj: GattDescriptor, characteristic_uuid: str, characteristic_handle: int
|
||||
):
|
||||
super(BleakGATTDescriptorWinRT, self).__init__(obj)
|
||||
self.obj = obj
|
||||
self.__characteristic_uuid = characteristic_uuid
|
||||
self.__characteristic_handle = characteristic_handle
|
||||
|
||||
@property
|
||||
def characteristic_handle(self) -> int:
|
||||
"""handle for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_handle
|
||||
|
||||
@property
|
||||
def characteristic_uuid(self) -> str:
|
||||
"""UUID for the characteristic that this descriptor belongs to"""
|
||||
return self.__characteristic_uuid
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
"""UUID for this descriptor"""
|
||||
return str(self.obj.uuid)
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
"""Integer handle for this descriptor"""
|
||||
return self.obj.attribute_handle
|
300
bleak/backends/winrt/scanner.py
Normal file
300
bleak/backends/winrt/scanner.py
Normal file
|
@ -0,0 +1,300 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import Dict, List, Literal, NamedTuple, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from .util import assert_mta
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from winrt.windows.devices.bluetooth.advertisement import (
|
||||
BluetoothLEAdvertisementReceivedEventArgs,
|
||||
BluetoothLEAdvertisementType,
|
||||
BluetoothLEAdvertisementWatcher,
|
||||
BluetoothLEAdvertisementWatcherStatus,
|
||||
BluetoothLEScanningMode,
|
||||
)
|
||||
else:
|
||||
from bleak_winrt.windows.devices.bluetooth.advertisement import (
|
||||
BluetoothLEAdvertisementReceivedEventArgs,
|
||||
BluetoothLEAdvertisementType,
|
||||
BluetoothLEAdvertisementWatcher,
|
||||
BluetoothLEAdvertisementWatcherStatus,
|
||||
BluetoothLEScanningMode,
|
||||
)
|
||||
|
||||
from ...assigned_numbers import AdvertisementDataType
|
||||
from ...exc import BleakError
|
||||
from ...uuids import normalize_uuid_str
|
||||
from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_bdaddr(a: int) -> str:
|
||||
return ":".join(f"{x:02X}" for x in a.to_bytes(6, byteorder="big"))
|
||||
|
||||
|
||||
def _format_event_args(e: BluetoothLEAdvertisementReceivedEventArgs) -> str:
|
||||
try:
|
||||
return f"{_format_bdaddr(e.bluetooth_address)}: {e.advertisement.local_name}"
|
||||
except Exception:
|
||||
return _format_bdaddr(e.bluetooth_address)
|
||||
|
||||
|
||||
class _RawAdvData(NamedTuple):
|
||||
"""
|
||||
Platform-specific advertisement data.
|
||||
|
||||
Windows does not combine advertising data with type SCAN_RSP with other
|
||||
advertising data like other platforms, so se have to do it ourselves.
|
||||
"""
|
||||
|
||||
adv: Optional[BluetoothLEAdvertisementReceivedEventArgs]
|
||||
"""
|
||||
The advertisement data received from the BluetoothLEAdvertisementWatcher.Received event.
|
||||
"""
|
||||
scan: Optional[BluetoothLEAdvertisementReceivedEventArgs]
|
||||
"""
|
||||
The scan response for the same device as *adv*.
|
||||
"""
|
||||
|
||||
|
||||
class BleakScannerWinRT(BaseBleakScanner):
|
||||
"""The native Windows Bleak BLE Scanner.
|
||||
|
||||
Implemented using `Python/WinRT <https://github.com/Microsoft/xlang/tree/master/src/package/pywinrt/projection/>`_.
|
||||
|
||||
Args:
|
||||
detection_callback:
|
||||
Optional function that will be called each time a device is
|
||||
discovered or advertising data has changed.
|
||||
service_uuids:
|
||||
Optional list of service UUIDs to filter on. Only advertisements
|
||||
containing this advertising data will be received.
|
||||
scanning_mode:
|
||||
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detection_callback: Optional[AdvertisementDataCallback],
|
||||
service_uuids: Optional[List[str]],
|
||||
scanning_mode: Literal["active", "passive"],
|
||||
**kwargs,
|
||||
):
|
||||
super(BleakScannerWinRT, self).__init__(detection_callback, service_uuids)
|
||||
|
||||
self.watcher: Optional[BluetoothLEAdvertisementWatcher] = None
|
||||
self._advertisement_pairs: Dict[int, _RawAdvData] = {}
|
||||
self._stopped_event = None
|
||||
|
||||
# case insensitivity is for backwards compatibility on Windows only
|
||||
if scanning_mode.lower() == "passive":
|
||||
self._scanning_mode = BluetoothLEScanningMode.PASSIVE
|
||||
else:
|
||||
self._scanning_mode = BluetoothLEScanningMode.ACTIVE
|
||||
|
||||
# Unfortunately, due to the way Windows handles filtering, we can't
|
||||
# make use of the service_uuids filter here. If we did we would only
|
||||
# get the advertisement data or the scan data, but not both, so would
|
||||
# miss out on other essential data. Advanced users can pass their own
|
||||
# filters though if they want to.
|
||||
self._signal_strength_filter = kwargs.get("SignalStrengthFilter", None)
|
||||
self._advertisement_filter = kwargs.get("AdvertisementFilter", None)
|
||||
|
||||
self._received_token = None
|
||||
self._stopped_token = None
|
||||
|
||||
def _received_handler(
|
||||
self,
|
||||
sender: BluetoothLEAdvertisementWatcher,
|
||||
event_args: BluetoothLEAdvertisementReceivedEventArgs,
|
||||
):
|
||||
"""Callback for AdvertisementWatcher.Received"""
|
||||
# TODO: Cannot check for if sender == self.watcher in winrt?
|
||||
logger.debug("Received %s.", _format_event_args(event_args))
|
||||
|
||||
# REVISIT: if scanning filters with BluetoothSignalStrengthFilter.OutOfRangeTimeout
|
||||
# are in place, an RSSI of -127 means that the device has gone out of range and should
|
||||
# be removed from the list of seen devices instead of processing the advertisement data.
|
||||
# https://learn.microsoft.com/en-us/uwp/api/windows.devices.bluetooth.bluetoothsignalstrengthfilter.outofrangetimeout
|
||||
|
||||
bdaddr = _format_bdaddr(event_args.bluetooth_address)
|
||||
|
||||
# Unlike other platforms, Windows does not combine advertising data for
|
||||
# us (regular advertisement + scan response) so we have to do it manually.
|
||||
|
||||
# get the previous advertising data/scan response pair or start a new one
|
||||
raw_data = self._advertisement_pairs.get(bdaddr, _RawAdvData(None, None))
|
||||
|
||||
# update the advertising data depending on the advertising data type
|
||||
if event_args.advertisement_type == BluetoothLEAdvertisementType.SCAN_RESPONSE:
|
||||
raw_data = _RawAdvData(raw_data.adv, event_args)
|
||||
else:
|
||||
raw_data = _RawAdvData(event_args, raw_data.scan)
|
||||
|
||||
self._advertisement_pairs[bdaddr] = raw_data
|
||||
|
||||
uuids = []
|
||||
mfg_data = {}
|
||||
service_data = {}
|
||||
local_name = None
|
||||
tx_power = None
|
||||
|
||||
for args in filter(lambda d: d is not None, raw_data):
|
||||
for u in args.advertisement.service_uuids:
|
||||
uuids.append(str(u))
|
||||
|
||||
for m in args.advertisement.manufacturer_data:
|
||||
mfg_data[m.company_id] = bytes(m.data)
|
||||
|
||||
# local name is empty string rather than None if not present
|
||||
if args.advertisement.local_name:
|
||||
local_name = args.advertisement.local_name
|
||||
|
||||
try:
|
||||
if args.transmit_power_level_in_d_bm is not None:
|
||||
tx_power = args.transmit_power_level_in_d_bm
|
||||
except AttributeError:
|
||||
# the transmit_power_level_in_d_bm property was introduce in
|
||||
# Windows build 19041 so we have a fallback for older versions
|
||||
for section in args.advertisement.get_sections_by_type(
|
||||
AdvertisementDataType.TX_POWER_LEVEL
|
||||
):
|
||||
tx_power = bytes(section.data)[0]
|
||||
|
||||
# Decode service data
|
||||
for section in args.advertisement.get_sections_by_type(
|
||||
AdvertisementDataType.SERVICE_DATA_UUID16
|
||||
):
|
||||
data = bytes(section.data)
|
||||
service_data[normalize_uuid_str(f"{data[1]:02x}{data[0]:02x}")] = data[
|
||||
2:
|
||||
]
|
||||
for section in args.advertisement.get_sections_by_type(
|
||||
AdvertisementDataType.SERVICE_DATA_UUID32
|
||||
):
|
||||
data = bytes(section.data)
|
||||
service_data[
|
||||
normalize_uuid_str(
|
||||
f"{data[3]:02x}{data[2]:02x}{data[1]:02x}{data[0]:02x}"
|
||||
)
|
||||
] = data[4:]
|
||||
for section in args.advertisement.get_sections_by_type(
|
||||
AdvertisementDataType.SERVICE_DATA_UUID128
|
||||
):
|
||||
data = bytes(section.data)
|
||||
service_data[str(UUID(bytes=bytes(data[15::-1])))] = data[16:]
|
||||
|
||||
if not self.is_allowed_uuid(uuids):
|
||||
return
|
||||
|
||||
# Use the BLEDevice to populate all the fields for the advertisement data to return
|
||||
advertisement_data = AdvertisementData(
|
||||
local_name=local_name,
|
||||
manufacturer_data=mfg_data,
|
||||
service_data=service_data,
|
||||
service_uuids=uuids,
|
||||
tx_power=tx_power,
|
||||
rssi=event_args.raw_signal_strength_in_d_bm,
|
||||
platform_data=(sender, raw_data),
|
||||
)
|
||||
|
||||
device = self.create_or_update_device(
|
||||
bdaddr, local_name, raw_data, advertisement_data
|
||||
)
|
||||
|
||||
self.call_detection_callbacks(device, advertisement_data)
|
||||
|
||||
def _stopped_handler(self, sender, e):
|
||||
logger.debug(
|
||||
"%s devices found. Watcher status: %r.",
|
||||
len(self.seen_devices),
|
||||
sender.status,
|
||||
)
|
||||
self._stopped_event.set()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.watcher:
|
||||
raise BleakError("Scanner already started")
|
||||
|
||||
# Callbacks for WinRT async methods will never happen in STA mode if
|
||||
# there is nothing pumping a Windows message loop.
|
||||
await assert_mta()
|
||||
|
||||
# start with fresh list of discovered devices
|
||||
self.seen_devices = {}
|
||||
self._advertisement_pairs.clear()
|
||||
|
||||
self.watcher = BluetoothLEAdvertisementWatcher()
|
||||
self.watcher.scanning_mode = self._scanning_mode
|
||||
|
||||
event_loop = asyncio.get_running_loop()
|
||||
self._stopped_event = asyncio.Event()
|
||||
|
||||
self._received_token = self.watcher.add_received(
|
||||
lambda s, e: event_loop.call_soon_threadsafe(self._received_handler, s, e)
|
||||
)
|
||||
self._stopped_token = self.watcher.add_stopped(
|
||||
lambda s, e: event_loop.call_soon_threadsafe(self._stopped_handler, s, e)
|
||||
)
|
||||
|
||||
if self._signal_strength_filter is not None:
|
||||
self.watcher.signal_strength_filter = self._signal_strength_filter
|
||||
if self._advertisement_filter is not None:
|
||||
self.watcher.advertisement_filter = self._advertisement_filter
|
||||
|
||||
self.watcher.start()
|
||||
|
||||
# no events for status changes, so we have to poll :-(
|
||||
while self.watcher.status == BluetoothLEAdvertisementWatcherStatus.CREATED:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
if self.watcher.status == BluetoothLEAdvertisementWatcherStatus.ABORTED:
|
||||
raise BleakError("Failed to start scanner. Is Bluetooth turned on?")
|
||||
|
||||
if self.watcher.status != BluetoothLEAdvertisementWatcherStatus.STARTED:
|
||||
raise BleakError(f"Unexpected watcher status: {self.watcher.status.name}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.watcher.stop()
|
||||
|
||||
if self.watcher.status == BluetoothLEAdvertisementWatcherStatus.STOPPING:
|
||||
await self._stopped_event.wait()
|
||||
else:
|
||||
logger.debug(
|
||||
"skipping waiting for stop because status is %r",
|
||||
self.watcher.status,
|
||||
)
|
||||
|
||||
try:
|
||||
self.watcher.remove_received(self._received_token)
|
||||
self.watcher.remove_stopped(self._stopped_token)
|
||||
except Exception as e:
|
||||
logger.debug("Could not remove event handlers: %s", e)
|
||||
|
||||
self._stopped_token = None
|
||||
self._received_token = None
|
||||
|
||||
self.watcher = None
|
||||
|
||||
def set_scanning_filter(self, **kwargs) -> None:
|
||||
"""Set a scanning filter for the BleakScanner.
|
||||
|
||||
Keyword Args:
|
||||
SignalStrengthFilter (``Windows.Devices.Bluetooth.BluetoothSignalStrengthFilter``): A
|
||||
BluetoothSignalStrengthFilter object used for configuration of Bluetooth
|
||||
LE advertisement filtering that uses signal strength-based filtering.
|
||||
AdvertisementFilter (Windows.Devices.Bluetooth.Advertisement.BluetoothLEAdvertisementFilter): A
|
||||
BluetoothLEAdvertisementFilter object used for configuration of Bluetooth LE
|
||||
advertisement filtering that uses payload section-based filtering.
|
||||
|
||||
"""
|
||||
if "SignalStrengthFilter" in kwargs:
|
||||
# TODO: Handle SignalStrengthFilter parameters
|
||||
self._signal_strength_filter = kwargs["SignalStrengthFilter"]
|
||||
if "AdvertisementFilter" in kwargs:
|
||||
# TODO: Handle AdvertisementFilter parameters
|
||||
self._advertisement_filter = kwargs["AdvertisementFilter"]
|
42
bleak/backends/winrt/service.py
Normal file
42
bleak/backends/winrt/service.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import sys
|
||||
from typing import List
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from winrt.windows.devices.bluetooth.genericattributeprofile import (
|
||||
GattDeviceService,
|
||||
)
|
||||
else:
|
||||
from bleak_winrt.windows.devices.bluetooth.genericattributeprofile import (
|
||||
GattDeviceService,
|
||||
)
|
||||
|
||||
from ..service import BleakGATTService
|
||||
from ..winrt.characteristic import BleakGATTCharacteristicWinRT
|
||||
|
||||
|
||||
class BleakGATTServiceWinRT(BleakGATTService):
|
||||
"""GATT Characteristic implementation for the .NET backend, implemented with WinRT"""
|
||||
|
||||
def __init__(self, obj: GattDeviceService):
|
||||
super().__init__(obj)
|
||||
self.__characteristics = []
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
return str(self.obj.uuid)
|
||||
|
||||
@property
|
||||
def handle(self) -> int:
|
||||
return self.obj.attribute_handle
|
||||
|
||||
@property
|
||||
def characteristics(self) -> List[BleakGATTCharacteristicWinRT]:
|
||||
"""List of characteristics for this service"""
|
||||
return self.__characteristics
|
||||
|
||||
def add_characteristic(self, characteristic: BleakGATTCharacteristicWinRT):
|
||||
"""Add a :py:class:`~BleakGATTCharacteristicWinRT` to the service.
|
||||
|
||||
Should not be used by end user, but rather by `bleak` itself.
|
||||
"""
|
||||
self.__characteristics.append(characteristic)
|
223
bleak/backends/winrt/util.py
Normal file
223
bleak/backends/winrt/util.py
Normal file
|
@ -0,0 +1,223 @@
|
|||
import asyncio
|
||||
import ctypes
|
||||
import sys
|
||||
from ctypes import wintypes
|
||||
from enum import IntEnum
|
||||
from typing import Tuple
|
||||
|
||||
from ...exc import BleakError
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from async_timeout import timeout as async_timeout
|
||||
else:
|
||||
from asyncio import timeout as async_timeout
|
||||
|
||||
|
||||
def _check_result(result, func, args):
|
||||
if not result:
|
||||
raise ctypes.WinError()
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def _check_hresult(result, func, args):
|
||||
if result:
|
||||
raise ctypes.WinError(result)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
# not defined in wintypes
|
||||
_UINT_PTR = wintypes.WPARAM
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc
|
||||
_TIMERPROC = ctypes.WINFUNCTYPE(
|
||||
None, wintypes.HWND, _UINT_PTR, wintypes.UINT, wintypes.DWORD
|
||||
)
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-settimer
|
||||
_SET_TIMER_PROTOTYPE = ctypes.WINFUNCTYPE(
|
||||
_UINT_PTR, wintypes.HWND, _UINT_PTR, wintypes.UINT, _TIMERPROC
|
||||
)
|
||||
_SET_TIMER_PARAM_FLAGS = (
|
||||
(1, "hwnd", None),
|
||||
(1, "nidevent"),
|
||||
(1, "uelapse"),
|
||||
(1, "lptimerfunc", None),
|
||||
)
|
||||
_SetTimer = _SET_TIMER_PROTOTYPE(
|
||||
("SetTimer", ctypes.windll.user32), _SET_TIMER_PARAM_FLAGS
|
||||
)
|
||||
_SetTimer.errcheck = _check_result
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-killtimer
|
||||
_KILL_TIMER_PROTOTYPE = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, _UINT_PTR)
|
||||
_KILL_TIMER_PARAM_FLAGS = (
|
||||
(1, "hwnd", None),
|
||||
(1, "uidevent"),
|
||||
)
|
||||
_KillTimer = _KILL_TIMER_PROTOTYPE(
|
||||
("KillTimer", ctypes.windll.user32), _KILL_TIMER_PARAM_FLAGS
|
||||
)
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cogetapartmenttype
|
||||
_CO_GET_APARTMENT_TYPE_PROTOTYPE = ctypes.WINFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
)
|
||||
_CO_GET_APARTMENT_TYPE_PARAM_FLAGS = (
|
||||
(1, "papttype", None),
|
||||
(1, "paptqualifier", None),
|
||||
)
|
||||
_CoGetApartmentType = _CO_GET_APARTMENT_TYPE_PROTOTYPE(
|
||||
("CoGetApartmentType", ctypes.windll.ole32), _CO_GET_APARTMENT_TYPE_PARAM_FLAGS
|
||||
)
|
||||
_CoGetApartmentType.errcheck = _check_hresult
|
||||
|
||||
_CO_E_NOTINITIALIZED = -2147221008
|
||||
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/objidl/ne-objidl-apttype
|
||||
class _AptType(IntEnum):
|
||||
CURRENT = -1
|
||||
STA = 0
|
||||
MTA = 1
|
||||
NA = 2
|
||||
MAIN_STA = 3
|
||||
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/objidl/ne-objidl-apttypequalifier
|
||||
class _AptQualifierType(IntEnum):
|
||||
NONE = 0
|
||||
IMPLICIT_MTA = 1
|
||||
NA_ON_MTA = 2
|
||||
NA_ON_STA = 3
|
||||
NA_ON_IMPLICIT_STA = 4
|
||||
NA_ON_MAIN_STA = 5
|
||||
APPLICATION_STA = 6
|
||||
RESERVED_1 = 7
|
||||
|
||||
|
||||
def _get_apartment_type() -> Tuple[_AptType, _AptQualifierType]:
|
||||
"""
|
||||
Calls CoGetApartmentType to get the current apartment type and qualifier.
|
||||
|
||||
Returns:
|
||||
The current apartment type and qualifier.
|
||||
Raises:
|
||||
OSError: If the call to CoGetApartmentType fails.
|
||||
"""
|
||||
api_type = ctypes.c_int()
|
||||
api_type_qualifier = ctypes.c_int()
|
||||
_CoGetApartmentType(ctypes.byref(api_type), ctypes.byref(api_type_qualifier))
|
||||
return _AptType(api_type.value), _AptQualifierType(api_type_qualifier.value)
|
||||
|
||||
|
||||
async def assert_mta() -> None:
|
||||
"""
|
||||
Asserts that the current apartment type is MTA.
|
||||
|
||||
Raises:
|
||||
BleakError:
|
||||
If the current apartment type is not MTA and there is no Windows
|
||||
message loop running.
|
||||
|
||||
.. versionadded:: 0.22
|
||||
|
||||
.. versionchanged:: 0.22.2
|
||||
|
||||
Function is now async and will not raise if the current apartment type
|
||||
is STA and the Windows message loop is running.
|
||||
"""
|
||||
if hasattr(allow_sta, "_allowed"):
|
||||
return
|
||||
|
||||
try:
|
||||
apt_type, _ = _get_apartment_type()
|
||||
except OSError as e:
|
||||
# All is OK if not initialized yet. WinRT will initialize it.
|
||||
if e.winerror == _CO_E_NOTINITIALIZED:
|
||||
return
|
||||
|
||||
raise
|
||||
|
||||
if apt_type == _AptType.MTA:
|
||||
# if we get here, WinRT probably set the apartment type to MTA and all
|
||||
# is well, we don't need to check again
|
||||
setattr(allow_sta, "_allowed", True)
|
||||
return
|
||||
|
||||
event = asyncio.Event()
|
||||
|
||||
def wait_event(*_):
|
||||
event.set()
|
||||
|
||||
# have to keep a reference to the callback or it will be garbage collected
|
||||
# before it is called
|
||||
callback = _TIMERPROC(wait_event)
|
||||
|
||||
# set a timer to see if we get a callback to ensure the windows event loop
|
||||
# is running
|
||||
timer = _SetTimer(None, 1, 0, callback)
|
||||
|
||||
try:
|
||||
async with async_timeout(0.5):
|
||||
await event.wait()
|
||||
except asyncio.TimeoutError:
|
||||
raise BleakError(
|
||||
"Thread is configured for Windows GUI but callbacks are not working."
|
||||
+ (
|
||||
" Suspect unwanted side effects from importing 'pythoncom'."
|
||||
if "pythoncom" in sys.modules
|
||||
else ""
|
||||
)
|
||||
)
|
||||
else:
|
||||
# if the windows event loop is running, we assume it is going to keep
|
||||
# running and we don't need to check again
|
||||
setattr(allow_sta, "_allowed", True)
|
||||
finally:
|
||||
_KillTimer(None, timer)
|
||||
|
||||
|
||||
def allow_sta():
|
||||
"""
|
||||
Suppress check for MTA thread type and allow STA.
|
||||
|
||||
Bleak will hang forever if the current thread is not MTA - unless there is
|
||||
a Windows event loop running that is properly integrated with asyncio in
|
||||
Python.
|
||||
|
||||
If your program meets that condition, you must call this function do disable
|
||||
the check for MTA. If your program doesn't have a graphical user interface
|
||||
you probably shouldn't call this function. and use ``uninitialize_sta()``
|
||||
instead.
|
||||
|
||||
.. versionadded:: 0.22.1
|
||||
"""
|
||||
allow_sta._allowed = True
|
||||
|
||||
|
||||
def uninitialize_sta():
|
||||
"""
|
||||
Uninitialize the COM library on the current thread if it was not initialized
|
||||
as MTA.
|
||||
|
||||
This is intended to undo the implicit initialization of the COM library as STA
|
||||
by packages like pywin32.
|
||||
|
||||
It should be called as early as possible in your application after the
|
||||
offending package has been imported.
|
||||
|
||||
.. versionadded:: 0.22
|
||||
"""
|
||||
|
||||
try:
|
||||
_get_apartment_type()
|
||||
except OSError as e:
|
||||
# All is OK if not initialized yet. WinRT will initialize it.
|
||||
if e.winerror == _CO_E_NOTINITIALIZED:
|
||||
return
|
||||
else:
|
||||
ctypes.windll.ole32.CoUninitialize()
|
184
bleak/exc.py
Normal file
184
bleak/exc.py
Normal file
|
@ -0,0 +1,184 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import uuid
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class BleakError(Exception):
|
||||
"""Base Exception for bleak."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BleakCharacteristicNotFoundError(BleakError):
|
||||
"""
|
||||
Exception which is raised if a device does not support a characteristic.
|
||||
|
||||
.. versionadded: 0.22
|
||||
"""
|
||||
|
||||
char_specifier: Union[int, str, uuid.UUID]
|
||||
|
||||
def __init__(self, char_specifier: Union[int, str, uuid.UUID]) -> None:
|
||||
"""
|
||||
Args:
|
||||
characteristic (str): handle or UUID of the characteristic which was not found
|
||||
"""
|
||||
super().__init__(f"Characteristic {char_specifier} was not found!")
|
||||
self.char_specifier = char_specifier
|
||||
|
||||
|
||||
class BleakDeviceNotFoundError(BleakError):
|
||||
"""
|
||||
Exception which is raised if a device can not be found by ``connect``, ``pair`` and ``unpair``.
|
||||
This is the case if the OS Bluetooth stack has never seen this device or it was removed and forgotten.
|
||||
|
||||
.. versionadded: 0.19
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
|
||||
def __init__(self, identifier: str, *args: object) -> None:
|
||||
"""
|
||||
Args:
|
||||
identifier (str): device identifier (Bluetooth address or UUID) of the device which was not found
|
||||
"""
|
||||
super().__init__(*args)
|
||||
self.identifier = identifier
|
||||
|
||||
|
||||
class BleakDBusError(BleakError):
|
||||
"""Specialized exception type for D-Bus errors."""
|
||||
|
||||
def __init__(self, dbus_error: str, error_body: list):
|
||||
"""
|
||||
Args:
|
||||
dbus_error (str): The D-Bus error, e.g. ``org.freedesktop.DBus.Error.UnknownObject``.
|
||||
error_body (list): Body of the D-Bus error, sometimes containing error description or details.
|
||||
"""
|
||||
super().__init__(dbus_error, *error_body)
|
||||
|
||||
@property
|
||||
def dbus_error(self) -> str:
|
||||
"""Gets the D-Bus error name, e.g. ``org.freedesktop.DBus.Error.UnknownObject``."""
|
||||
return self.args[0]
|
||||
|
||||
@property
|
||||
def dbus_error_details(self) -> Optional[str]:
|
||||
"""Gets the optional D-Bus error details, e.g. 'Invalid UUID'."""
|
||||
if len(self.args) > 1:
|
||||
details = self.args[1]
|
||||
# Some error descriptions can be further parsed to be even more helpful
|
||||
if "ATT error: 0x" in details:
|
||||
more_detail = PROTOCOL_ERROR_CODES.get(
|
||||
int(details.rsplit("x")[1], 16), "Unknown code"
|
||||
)
|
||||
details += f" ({more_detail})"
|
||||
return details
|
||||
return None
|
||||
|
||||
def __str__(self) -> str:
|
||||
name = f"[{self.dbus_error}]"
|
||||
details = self.dbus_error_details
|
||||
return (name + " " + details) if details else name
|
||||
|
||||
|
||||
CONTROLLER_ERROR_CODES = {
|
||||
0x00: "Success",
|
||||
0x01: "Unknown HCI Command",
|
||||
0x02: "Unknown Connection Identifier",
|
||||
0x03: "Hardware Failure",
|
||||
0x04: "Page Timeout",
|
||||
0x05: "Authentication Failure",
|
||||
0x06: "PIN or Key Missing",
|
||||
0x07: "Memory Capacity Exceeded",
|
||||
0x08: "Connection Timeout",
|
||||
0x09: "Connection Limit Exceeded",
|
||||
0x0A: "Synchronous Connection Limit To A Device Exceeded",
|
||||
0x0B: "Connection Already Exists",
|
||||
0x0C: "Command Disallowed",
|
||||
0x0D: "Connection Rejected due to Limited Resources",
|
||||
0x0E: "Connection Rejected Due To Security Reasons",
|
||||
0x0F: "Connection Rejected due to Unacceptable BD_ADDR",
|
||||
0x10: "Connection Accept Timeout Exceeded",
|
||||
0x11: "Unsupported Feature or Parameter Value",
|
||||
0x12: "Invalid HCI Command Parameters",
|
||||
0x13: "Remote User Terminated Connection",
|
||||
0x14: "Remote Device Terminated Connection due to Low Resources",
|
||||
0x15: "Remote Device Terminated Connection due to Power Off",
|
||||
0x16: "Connection Terminated By Local Host",
|
||||
0x17: "Repeated Attempts",
|
||||
0x18: "Pairing Not Allowed",
|
||||
0x19: "Unknown LMP PDU",
|
||||
0x1A: "Unsupported Remote Feature / Unsupported LMP Feature",
|
||||
0x1B: "SCO Offset Rejected",
|
||||
0x1C: "SCO Interval Rejected",
|
||||
0x1D: "SCO Air Mode Rejected",
|
||||
0x1E: "Invalid LMP Parameters / Invalid LL Parameters",
|
||||
0x1F: "Unspecified Error",
|
||||
0x20: "Unsupported LMP Parameter Value / Unsupported LL Parameter Value",
|
||||
0x21: "Role Change Not Allowed",
|
||||
0x22: "LMP Response Timeout / LL Response Timeout",
|
||||
0x23: "LMP Error Transaction Collision / LL Procedure Collision",
|
||||
0x24: "LMP PDU Not Allowed",
|
||||
0x25: "Encryption Mode Not Acceptable",
|
||||
0x26: "Link Key cannot be Changed",
|
||||
0x27: "Requested QoS Not Supported",
|
||||
0x28: "Instant Passed",
|
||||
0x29: "Pairing With Unit Key Not Supported",
|
||||
0x2A: "Different Transaction Collision",
|
||||
0x2B: "Reserved for future use",
|
||||
0x2C: "QoS Unacceptable Parameter",
|
||||
0x2D: "QoS Rejected",
|
||||
0x2E: "Channel Classification Not Supported",
|
||||
0x2F: "Insufficient Security",
|
||||
0x30: "Parameter Out Of Mandatory Range",
|
||||
0x31: "Reserved for future use",
|
||||
0x32: "Role Switch Pending",
|
||||
0x33: "Reserved for future use",
|
||||
0x34: "Reserved Slot Violation",
|
||||
0x35: "Role Switch Failed",
|
||||
0x36: "Extended Inquiry Response Too Large",
|
||||
0x37: "Secure Simple Pairing Not Supported By Host",
|
||||
0x38: "Host Busy - Pairing",
|
||||
0x39: "Connection Rejected due to No Suitable Channel Found",
|
||||
0x3A: "Controller Busy",
|
||||
0x3B: "Unacceptable Connection Parameters",
|
||||
0x3C: "Advertising Timeout",
|
||||
0x3D: "Connection Terminated due to MIC Failure",
|
||||
0x3E: "Connection Failed to be Established / Synchronization Timeout",
|
||||
0x3F: "MAC Connection Failed",
|
||||
0x40: "Coarse Clock Adjustment Rejected but Will Try to Adjust Using Clock",
|
||||
0x41: "Type0 Submap Not Defined",
|
||||
0x42: "Unknown Advertising Identifier",
|
||||
0x43: "Limit Reached",
|
||||
0x44: "Operation Cancelled by Host",
|
||||
0x45: "Packet Too Long",
|
||||
}
|
||||
|
||||
# as defined in Bluetooth Core Specification v5.2, volume 3, part F, section 3.4.1.1, table 3.4.
|
||||
PROTOCOL_ERROR_CODES = {
|
||||
0x01: "Invalid Handle",
|
||||
0x02: "Read Not Permitted",
|
||||
0x03: "Write Not Permitted",
|
||||
0x04: "Invalid PDU",
|
||||
0x05: "Insufficient Authentication",
|
||||
0x06: "Request Not Supported",
|
||||
0x07: "Invalid Offset",
|
||||
0x08: "Insufficient Authorization",
|
||||
0x09: "Prepare Queue Full",
|
||||
0x0A: "Attribute Not Found",
|
||||
0x0B: "Attribute Not Long",
|
||||
0x0C: "Insufficient Encryption Key Size",
|
||||
0x0D: "Invalid Attribute Value Length",
|
||||
0x0E: "Unlikely Error",
|
||||
0x0F: "Insufficient Authentication",
|
||||
0x10: "Unsupported Group Type",
|
||||
0x11: "Insufficient Resource",
|
||||
0x12: "Database Out Of Sync",
|
||||
0x13: "Value Not Allowed",
|
||||
# REVISIT: do we need Application Errors 0x80-0x9F?
|
||||
0xFC: "Write Request Rejected",
|
||||
0xFD: "Client Characteristic Configuration Descriptor Improperly Configured",
|
||||
0xFE: "Procedure Already in Progress",
|
||||
0xFF: "Out of Range",
|
||||
}
|
0
bleak/py.typed
Normal file
0
bleak/py.typed
Normal file
1274
bleak/uuids.py
Normal file
1274
bleak/uuids.py
Normal file
File diff suppressed because it is too large
Load diff
82
dbus_fast/__init__.py
Normal file
82
dbus_fast/__init__.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
from . import introspection, message_bus, proxy_object, service
|
||||
from .constants import (
|
||||
ArgDirection,
|
||||
BusType,
|
||||
ErrorType,
|
||||
MessageFlag,
|
||||
MessageType,
|
||||
NameFlag,
|
||||
PropertyAccess,
|
||||
ReleaseNameReply,
|
||||
RequestNameReply,
|
||||
)
|
||||
from .errors import (
|
||||
AuthError,
|
||||
DBusError,
|
||||
InterfaceNotFoundError,
|
||||
InvalidAddressError,
|
||||
InvalidBusNameError,
|
||||
InvalidInterfaceNameError,
|
||||
InvalidIntrospectionError,
|
||||
InvalidMemberNameError,
|
||||
InvalidMessageError,
|
||||
InvalidObjectPathError,
|
||||
InvalidSignatureError,
|
||||
SignalDisabledError,
|
||||
SignatureBodyMismatchError,
|
||||
)
|
||||
from .message import Message
|
||||
from .signature import SignatureTree, SignatureType, Variant
|
||||
from .unpack import unpack_variants
|
||||
from .validators import (
|
||||
assert_bus_name_valid,
|
||||
assert_interface_name_valid,
|
||||
assert_member_name_valid,
|
||||
assert_object_path_valid,
|
||||
is_bus_name_valid,
|
||||
is_interface_name_valid,
|
||||
is_member_name_valid,
|
||||
is_object_path_valid,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"introspection",
|
||||
"message_bus",
|
||||
"proxy_object",
|
||||
"service",
|
||||
"ArgDirection",
|
||||
"BusType",
|
||||
"ErrorType",
|
||||
"MessageFlag",
|
||||
"MessageType",
|
||||
"NameFlag",
|
||||
"PropertyAccess",
|
||||
"ReleaseNameReply",
|
||||
"RequestNameReply",
|
||||
"AuthError",
|
||||
"DBusError",
|
||||
"InterfaceNotFoundError",
|
||||
"InvalidAddressError",
|
||||
"InvalidBusNameError",
|
||||
"InvalidInterfaceNameError",
|
||||
"InvalidIntrospectionError",
|
||||
"InvalidMemberNameError",
|
||||
"InvalidMessageError",
|
||||
"InvalidObjectPathError",
|
||||
"InvalidSignatureError",
|
||||
"SignalDisabledError",
|
||||
"SignatureBodyMismatchError",
|
||||
"Message",
|
||||
"SignatureTree",
|
||||
"SignatureType",
|
||||
"Variant",
|
||||
"assert_bus_name_valid",
|
||||
"assert_interface_name_valid",
|
||||
"assert_member_name_valid",
|
||||
"assert_object_path_valid",
|
||||
"is_bus_name_valid",
|
||||
"is_interface_name_valid",
|
||||
"is_member_name_valid",
|
||||
"is_object_path_valid",
|
||||
"unpack_variants",
|
||||
]
|
10
dbus_fast/__version__.py
Normal file
10
dbus_fast/__version__.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
__title__ = "dbus_fast"
|
||||
__description__ = (
|
||||
"A performant zero-dependency DBus library for Python with asyncio support"
|
||||
)
|
||||
__url__ = "https://github.com/bluetooth-devices/dbus-fast"
|
||||
__version__ = "2.24.4"
|
||||
__author__ = "Bluetooth Devices authors, Tony Crisci"
|
||||
__author_email__ = "bluetooth@koston.org"
|
||||
__license__ = "MIT"
|
||||
__copyright__ = "Copyright 2022 Bluetooth Devices authors, 2019 Tony Crisci"
|
0
dbus_fast/_private/__init__.py
Normal file
0
dbus_fast/_private/__init__.py
Normal file
12
dbus_fast/_private/_cython_compat.py
Normal file
12
dbus_fast/_private/_cython_compat.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
"""Stub for when Cython is not available."""
|
||||
|
||||
|
||||
class FakeCython:
|
||||
"""Stub for when Cython is not available."""
|
||||
|
||||
@property
|
||||
def compiled(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
FAKE_CYTHON = FakeCython()
|
15
dbus_fast/_private/address.pxd
Normal file
15
dbus_fast/_private/address.pxd
Normal file
|
@ -0,0 +1,15 @@
|
|||
"""cdefs for address.py"""
|
||||
|
||||
import cython
|
||||
|
||||
|
||||
cdef object unquote
|
||||
|
||||
@cython.locals(kv=cython.str, opt_string=cython.str, address=cython.str)
|
||||
cpdef parse_address(cython.str address_str)
|
||||
|
||||
cpdef get_bus_address(object bus_type)
|
||||
|
||||
cpdef get_session_bus_address()
|
||||
|
||||
cpdef get_system_bus_address()
|
116
dbus_fast/_private/address.py
Normal file
116
dbus_fast/_private/address.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
from ..constants import BusType
|
||||
from ..errors import InvalidAddressError
|
||||
|
||||
invalid_address_chars_re = re.compile(r"[^-0-9A-Za-z_/.%]")
|
||||
|
||||
str_ = str
|
||||
|
||||
|
||||
def parse_address(address_str: str_) -> List[Tuple[str, Dict[str, str]]]:
|
||||
"""Parse a dbus address string into a list of addresses."""
|
||||
addresses: List[Tuple[str, Dict[str, str]]] = []
|
||||
|
||||
for address in address_str.split(";"):
|
||||
if not address:
|
||||
continue
|
||||
if address.find(":") == -1:
|
||||
raise InvalidAddressError("address did not contain a transport")
|
||||
|
||||
transport, opt_string = address.split(":", 1)
|
||||
options: Dict[str, str] = {}
|
||||
|
||||
for kv in opt_string.split(","):
|
||||
if not kv:
|
||||
continue
|
||||
if kv.find("=") == -1:
|
||||
raise InvalidAddressError("address option did not contain a value")
|
||||
k, v = kv.split("=", 1)
|
||||
if invalid_address_chars_re.search(v):
|
||||
raise InvalidAddressError("address contains invalid characters")
|
||||
# XXX the actual unquote rules are simpler than this
|
||||
options[k] = unquote(v)
|
||||
|
||||
addresses.append((transport, options))
|
||||
|
||||
if not addresses:
|
||||
raise InvalidAddressError(
|
||||
f'address string contained no addresses: "{address_str}"'
|
||||
)
|
||||
|
||||
return addresses
|
||||
|
||||
|
||||
def get_system_bus_address() -> str:
|
||||
"""Get the system bus address from the environment or return the default."""
|
||||
return (
|
||||
os.environ.get("DBUS_SYSTEM_BUS_ADDRESS")
|
||||
or "unix:path=/var/run/dbus/system_bus_socket"
|
||||
)
|
||||
|
||||
|
||||
display_re = re.compile(r".*:([0-9]+)\.?.*")
|
||||
remove_quotes_re = re.compile(r"""^['"]?(.*?)['"]?$""")
|
||||
|
||||
|
||||
def get_session_bus_address() -> str:
|
||||
"""Get the session bus address from the environment or return the default."""
|
||||
dbus_session_bus_address = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
||||
if dbus_session_bus_address:
|
||||
return dbus_session_bus_address
|
||||
|
||||
home = os.environ["HOME"]
|
||||
if "DISPLAY" not in os.environ:
|
||||
raise InvalidAddressError(
|
||||
"DBUS_SESSION_BUS_ADDRESS not set and could not get DISPLAY environment variable to get bus address"
|
||||
)
|
||||
|
||||
display = os.environ["DISPLAY"]
|
||||
try:
|
||||
display = display_re.search(display).group(1)
|
||||
except Exception:
|
||||
raise InvalidAddressError(
|
||||
f"DBUS_SESSION_BUS_ADDRESS not set and could not parse DISPLAY environment variable to get bus address: {display}"
|
||||
)
|
||||
|
||||
# XXX: this will block but they're very small files and fs operations
|
||||
# should be fairly reliable. fix this by passing in an async func to read
|
||||
# the file for each io backend.
|
||||
machine_id = None
|
||||
with open("/var/lib/dbus/machine-id") as f:
|
||||
machine_id = f.read().rstrip()
|
||||
|
||||
dbus_info_file_name = f"{home}/.dbus/session-bus/{machine_id}-{display}"
|
||||
dbus_info: Optional[str] = None
|
||||
try:
|
||||
with open(dbus_info_file_name) as f:
|
||||
dbus_info = f.read().rstrip()
|
||||
except Exception:
|
||||
raise InvalidAddressError(
|
||||
f"could not open dbus info file: {dbus_info_file_name}"
|
||||
)
|
||||
|
||||
for line in dbus_info.split("\n"):
|
||||
if line.strip().startswith("DBUS_SESSION_BUS_ADDRESS="):
|
||||
_, addr = line.split("=", 1)
|
||||
if not addr:
|
||||
raise InvalidAddressError(
|
||||
f"DBUS_SESSION_BUS_ADDRESS variable not set correctly in dbus info file: {dbus_info_file_name}"
|
||||
)
|
||||
addr = remove_quotes_re.search(addr).group(1)
|
||||
return addr
|
||||
|
||||
raise InvalidAddressError("could not find dbus session bus address")
|
||||
|
||||
|
||||
def get_bus_address(bus_type: BusType) -> str:
|
||||
"""Get the address of the bus specified by the bus type."""
|
||||
if bus_type == BusType.SESSION:
|
||||
return get_session_bus_address()
|
||||
if bus_type == BusType.SYSTEM:
|
||||
return get_system_bus_address()
|
||||
raise Exception(f"got unknown bus type: {bus_type}")
|
18
dbus_fast/_private/constants.py
Normal file
18
dbus_fast/_private/constants.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from enum import Enum
|
||||
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
LITTLE_ENDIAN = ord("l")
|
||||
BIG_ENDIAN = ord("B")
|
||||
|
||||
|
||||
class HeaderField(Enum):
|
||||
PATH = 1
|
||||
INTERFACE = 2
|
||||
MEMBER = 3
|
||||
ERROR_NAME = 4
|
||||
REPLY_SERIAL = 5
|
||||
DESTINATION = 6
|
||||
SENDER = 7
|
||||
SIGNATURE = 8
|
||||
UNIX_FDS = 9
|
110
dbus_fast/_private/marshaller.pxd
Normal file
110
dbus_fast/_private/marshaller.pxd
Normal file
|
@ -0,0 +1,110 @@
|
|||
"""cdefs for marshaller.py"""
|
||||
|
||||
import cython
|
||||
|
||||
from ..signature cimport SignatureTree, SignatureType, Variant
|
||||
|
||||
|
||||
cdef object PACK_UINT32
|
||||
|
||||
cdef bytes PACKED_UINT32_ZERO
|
||||
cdef bytes PACKED_BOOL_TRUE
|
||||
cdef bytes PACKED_BOOL_FALSE
|
||||
|
||||
cdef get_signature_tree
|
||||
|
||||
cdef class Marshaller:
|
||||
|
||||
cdef SignatureTree signature_tree
|
||||
cdef bytearray _buf
|
||||
cdef cython.list body
|
||||
|
||||
cdef _buffer(self)
|
||||
|
||||
cpdef align(self, unsigned int n)
|
||||
|
||||
@cython.locals(
|
||||
offset=cython.ulong,
|
||||
)
|
||||
cdef unsigned int _align(self, unsigned int n)
|
||||
|
||||
cpdef write_boolean(self, object boolean, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
written=cython.uint,
|
||||
)
|
||||
cdef unsigned int _write_boolean(self, object boolean)
|
||||
|
||||
cpdef write_string(self, object value, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
buf=cython.bytearray,
|
||||
value_len=cython.uint,
|
||||
signature_len=cython.uint,
|
||||
written=cython.uint,
|
||||
)
|
||||
cdef unsigned int _write_string(self, object value)
|
||||
|
||||
@cython.locals(
|
||||
signature_len=cython.uint,
|
||||
)
|
||||
cdef unsigned int _write_signature(self, bytes signature_bytes)
|
||||
|
||||
cpdef write_array(self, object array, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
array_len=cython.uint,
|
||||
buf=cython.bytearray,
|
||||
written=cython.uint,
|
||||
token=cython.str,
|
||||
child_type=SignatureType,
|
||||
array_len_packed=cython.bytes,
|
||||
size=cython.uint,
|
||||
writer=cython.object,
|
||||
packer=cython.object,
|
||||
i=cython.uint,
|
||||
)
|
||||
cdef unsigned int _write_array(self, object array, SignatureType type_)
|
||||
|
||||
cpdef write_struct(self, object array, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
written=cython.uint,
|
||||
i=cython.uint,
|
||||
)
|
||||
cdef unsigned int _write_struct(self, object array, SignatureType type_)
|
||||
|
||||
cpdef write_variant(self, Variant variant, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
written=cython.uint,
|
||||
signature=cython.str,
|
||||
signature_bytes=cython.bytes,
|
||||
)
|
||||
cdef unsigned int _write_variant(self, Variant variant, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
written=cython.uint,
|
||||
size=cython.uint,
|
||||
)
|
||||
cdef unsigned int _write_single(self, SignatureType type_, object body)
|
||||
|
||||
@cython.locals(
|
||||
written=cython.uint,
|
||||
t=cython.str,
|
||||
)
|
||||
cpdef write_dict_entry(self, cython.list dict_entry, SignatureType type_)
|
||||
|
||||
cpdef marshall(self)
|
||||
|
||||
cdef _marshall(self)
|
||||
|
||||
@cython.locals(
|
||||
offset=cython.ulong,
|
||||
t=cython.str,
|
||||
size=cython.uint,
|
||||
writer=cython.object,
|
||||
packer=cython.object,
|
||||
type_=SignatureType,
|
||||
)
|
||||
cdef _construct_buffer(self)
|
229
dbus_fast/_private/marshaller.py
Normal file
229
dbus_fast/_private/marshaller.py
Normal file
|
@ -0,0 +1,229 @@
|
|||
from struct import Struct, error
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..signature import SignatureType, Variant, get_signature_tree
|
||||
|
||||
PACK_LITTLE_ENDIAN = "<"
|
||||
|
||||
PACK_UINT32 = Struct(f"{PACK_LITTLE_ENDIAN}I").pack
|
||||
PACKED_UINT32_ZERO = PACK_UINT32(0)
|
||||
PACKED_BOOL_FALSE = PACK_UINT32(int(0))
|
||||
PACKED_BOOL_TRUE = PACK_UINT32(int(1))
|
||||
|
||||
_int = int
|
||||
_bytes = bytes
|
||||
_str = str
|
||||
|
||||
|
||||
class Marshaller:
|
||||
"""Marshall data for Dbus."""
|
||||
|
||||
__slots__ = ("signature_tree", "_buf", "body")
|
||||
|
||||
def __init__(self, signature: str, body: List[Any]) -> None:
|
||||
"""Marshaller constructor."""
|
||||
self.signature_tree = get_signature_tree(signature)
|
||||
self._buf = bytearray()
|
||||
self.body = body
|
||||
|
||||
@property
|
||||
def buffer(self) -> bytearray:
|
||||
return self._buf
|
||||
|
||||
def _buffer(self) -> bytearray:
|
||||
return self._buf
|
||||
|
||||
def align(self, n: _int) -> int:
|
||||
return self._align(n)
|
||||
|
||||
def _align(self, n: _int) -> _int:
|
||||
offset = n - len(self._buf) % n
|
||||
if offset == 0 or offset == n:
|
||||
return 0
|
||||
for _ in range(offset):
|
||||
self._buf.append(0)
|
||||
return offset
|
||||
|
||||
def write_boolean(self, boolean: bool, type_: SignatureType) -> int:
|
||||
return self._write_boolean(boolean)
|
||||
|
||||
def _write_boolean(self, boolean: bool) -> int:
|
||||
written = self._align(4)
|
||||
self._buf += PACKED_BOOL_TRUE if boolean else PACKED_BOOL_FALSE
|
||||
return written + 4
|
||||
|
||||
def write_signature(self, signature: str, type_: SignatureType) -> int:
|
||||
return self._write_signature(signature.encode())
|
||||
|
||||
def _write_signature(self, signature_bytes: _bytes) -> int:
|
||||
signature_len = len(signature_bytes)
|
||||
buf = self._buf
|
||||
buf.append(signature_len)
|
||||
buf += signature_bytes
|
||||
buf.append(0)
|
||||
return signature_len + 2
|
||||
|
||||
def write_string(self, value: _str, type_: SignatureType) -> int:
|
||||
return self._write_string(value)
|
||||
|
||||
def _write_string(self, value: _str) -> int:
|
||||
value_bytes = value.encode()
|
||||
value_len = len(value_bytes)
|
||||
written = self._align(4) + 4
|
||||
buf = self._buf
|
||||
buf += PACK_UINT32(value_len)
|
||||
buf += value_bytes
|
||||
written += value_len
|
||||
buf.append(0)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
def write_variant(self, variant: Variant, type_: SignatureType) -> int:
|
||||
return self._write_variant(variant, type_)
|
||||
|
||||
def _write_variant(self, variant: Variant, type_: SignatureType) -> int:
|
||||
signature = variant.signature
|
||||
signature_bytes = signature.encode()
|
||||
written = self._write_signature(signature_bytes)
|
||||
written += self._write_single(variant.type, variant.value) # type: ignore[has-type]
|
||||
return written
|
||||
|
||||
def write_array(
|
||||
self, array: Union[List[Any], Dict[Any, Any]], type_: SignatureType
|
||||
) -> int:
|
||||
return self._write_array(array, type_)
|
||||
|
||||
def _write_array(
|
||||
self, array: Union[List[Any], Dict[Any, Any]], type_: SignatureType
|
||||
) -> int:
|
||||
# TODO max array size is 64MiB (67108864 bytes)
|
||||
written = self._align(4)
|
||||
# length placeholder
|
||||
buf = self._buf
|
||||
offset = len(buf)
|
||||
written += self._align(4) + 4
|
||||
buf += PACKED_UINT32_ZERO
|
||||
child_type = type_.children[0]
|
||||
token = child_type.token
|
||||
|
||||
if token in "xtd{(":
|
||||
# the first alignment is not included in array size
|
||||
written += self._align(8)
|
||||
|
||||
array_len = 0
|
||||
if token == "{":
|
||||
for key, value in array.items(): # type: ignore[union-attr]
|
||||
array_len += self.write_dict_entry([key, value], child_type)
|
||||
elif token == "y":
|
||||
array_len = len(array)
|
||||
buf += array
|
||||
elif token == "(":
|
||||
for value in array:
|
||||
array_len += self._write_struct(value, child_type)
|
||||
else:
|
||||
writer, packer, size = self._writers[token]
|
||||
if not writer:
|
||||
for value in array:
|
||||
array_len += self._align(size) + size
|
||||
buf += packer(value) # type: ignore[misc]
|
||||
else:
|
||||
for value in array:
|
||||
array_len += writer(self, value, child_type)
|
||||
|
||||
array_len_packed = PACK_UINT32(array_len)
|
||||
for i in range(offset, offset + 4):
|
||||
buf[i] = array_len_packed[i - offset]
|
||||
|
||||
return written + array_len
|
||||
|
||||
def write_struct(
|
||||
self, array: Union[Tuple[Any], List[Any]], type_: SignatureType
|
||||
) -> int:
|
||||
return self._write_struct(array, type_)
|
||||
|
||||
def _write_struct(
|
||||
self, array: Union[Tuple[Any], List[Any]], type_: SignatureType
|
||||
) -> int:
|
||||
written = self._align(8)
|
||||
for i, value in enumerate(array):
|
||||
written += self._write_single(type_.children[i], value)
|
||||
return written
|
||||
|
||||
def write_dict_entry(self, dict_entry: List[Any], type_: SignatureType) -> int:
|
||||
written = self._align(8)
|
||||
written += self._write_single(type_.children[0], dict_entry[0])
|
||||
written += self._write_single(type_.children[1], dict_entry[1])
|
||||
return written
|
||||
|
||||
def _write_single(self, type_: SignatureType, body: Any) -> int:
|
||||
t = type_.token
|
||||
if t == "y":
|
||||
self._buf.append(body)
|
||||
return 1
|
||||
elif t == "u":
|
||||
written = self._align(4)
|
||||
self._buf += PACK_UINT32(body)
|
||||
return written + 4
|
||||
elif t == "a":
|
||||
return self._write_array(body, type_)
|
||||
elif t == "s" or t == "o":
|
||||
return self._write_string(body)
|
||||
elif t == "v":
|
||||
return self._write_variant(body, type_)
|
||||
elif t == "b":
|
||||
return self._write_boolean(body)
|
||||
else:
|
||||
writer, packer, size = self._writers[t]
|
||||
if not writer:
|
||||
written = self._align(size)
|
||||
self._buf += packer(body) # type: ignore[misc]
|
||||
return written + size
|
||||
return writer(self, body, type_)
|
||||
|
||||
def marshall(self) -> bytearray:
|
||||
"""Marshalls the body into a byte array"""
|
||||
return self._marshall()
|
||||
|
||||
def _marshall(self) -> bytearray:
|
||||
"""Marshalls the body into a byte array"""
|
||||
try:
|
||||
return self._construct_buffer()
|
||||
except KeyError as ex:
|
||||
raise NotImplementedError(f'type is not implemented yet: "{ex.args}"')
|
||||
except error:
|
||||
self.signature_tree.verify(self.body)
|
||||
raise RuntimeError("should not reach here")
|
||||
|
||||
def _construct_buffer(self) -> bytearray:
|
||||
self._buf.clear()
|
||||
body = self.body
|
||||
for i, type_ in enumerate(self.signature_tree.types):
|
||||
self._write_single(type_, body[i])
|
||||
return self._buf
|
||||
|
||||
_writers: Dict[
|
||||
str,
|
||||
Tuple[
|
||||
Optional[Callable[[Any, Any, SignatureType], int]],
|
||||
Optional[Callable[[Any], bytes]],
|
||||
int,
|
||||
],
|
||||
] = {
|
||||
"y": (None, Struct(f"{PACK_LITTLE_ENDIAN}B").pack, 1),
|
||||
"b": (write_boolean, None, 0),
|
||||
"n": (None, Struct(f"{PACK_LITTLE_ENDIAN}h").pack, 2),
|
||||
"q": (None, Struct(f"{PACK_LITTLE_ENDIAN}H").pack, 2),
|
||||
"i": (None, Struct(f"{PACK_LITTLE_ENDIAN}i").pack, 4),
|
||||
"u": (None, PACK_UINT32, 4),
|
||||
"x": (None, Struct(f"{PACK_LITTLE_ENDIAN}q").pack, 8),
|
||||
"t": (None, Struct(f"{PACK_LITTLE_ENDIAN}Q").pack, 8),
|
||||
"d": (None, Struct(f"{PACK_LITTLE_ENDIAN}d").pack, 8),
|
||||
"h": (None, Struct(f"{PACK_LITTLE_ENDIAN}I").pack, 4),
|
||||
"o": (write_string, None, 0),
|
||||
"s": (write_string, None, 0),
|
||||
"g": (write_signature, None, 0),
|
||||
"a": (write_array, None, 0),
|
||||
"(": (write_struct, None, 0),
|
||||
"{": (write_dict_entry, None, 0),
|
||||
"v": (write_variant, None, 0),
|
||||
}
|
241
dbus_fast/_private/unmarshaller.pxd
Normal file
241
dbus_fast/_private/unmarshaller.pxd
Normal file
|
@ -0,0 +1,241 @@
|
|||
"""cdefs for unmarshaller.py"""
|
||||
|
||||
import cython
|
||||
|
||||
from ..message cimport Message
|
||||
from ..signature cimport SignatureTree, SignatureType, Variant
|
||||
|
||||
|
||||
cdef object MAX_UNIX_FDS_SIZE
|
||||
cdef object ARRAY
|
||||
cdef object UNIX_FDS_CMSG_LENGTH
|
||||
cdef object SOL_SOCKET
|
||||
cdef object SCM_RIGHTS
|
||||
cdef object MESSAGE_FLAG_INTENUM
|
||||
|
||||
cdef unsigned int UINT32_SIZE
|
||||
cdef unsigned int INT16_SIZE
|
||||
cdef unsigned int UINT16_SIZE
|
||||
|
||||
cdef unsigned int HEADER_ARRAY_OF_STRUCT_SIGNATURE_POSITION
|
||||
cdef unsigned int HEADER_SIGNATURE_SIZE
|
||||
cdef unsigned int LITTLE_ENDIAN
|
||||
cdef unsigned int BIG_ENDIAN
|
||||
cdef unsigned int PROTOCOL_VERSION
|
||||
cdef unsigned int HEADER_UNIX_FDS_IDX
|
||||
cdef cython.list HEADER_IDX_TO_ARG_NAME
|
||||
|
||||
cdef str UINT32_CAST
|
||||
cdef str INT16_CAST
|
||||
cdef str UINT16_CAST
|
||||
|
||||
cdef bint SYS_IS_LITTLE_ENDIAN
|
||||
cdef bint SYS_IS_BIG_ENDIAN
|
||||
|
||||
cdef object UNPACK_HEADER_LITTLE_ENDIAN
|
||||
cdef object UNPACK_HEADER_BIG_ENDIAN
|
||||
|
||||
cdef object UINT32_UNPACK_LITTLE_ENDIAN
|
||||
cdef object UINT32_UNPACK_BIG_ENDIAN
|
||||
|
||||
cdef object INT16_UNPACK_LITTLE_ENDIAN
|
||||
cdef object INT16_UNPACK_BIG_ENDIAN
|
||||
|
||||
cdef object UINT16_UNPACK_LITTLE_ENDIAN
|
||||
cdef object UINT16_UNPACK_BIG_ENDIAN
|
||||
|
||||
cdef cython.dict MESSAGE_TYPE_MAP
|
||||
cdef cython.dict MESSAGE_FLAG_MAP
|
||||
cdef dict HEADER_MESSAGE_ARG_NAME
|
||||
|
||||
cdef SignatureTree SIGNATURE_TREE_EMPTY
|
||||
cdef SignatureTree SIGNATURE_TREE_B
|
||||
cdef SignatureTree SIGNATURE_TREE_N
|
||||
cdef SignatureTree SIGNATURE_TREE_O
|
||||
cdef SignatureTree SIGNATURE_TREE_S
|
||||
cdef SignatureTree SIGNATURE_TREE_U
|
||||
cdef SignatureTree SIGNATURE_TREE_Y
|
||||
|
||||
cdef SignatureTree SIGNATURE_TREE_AS
|
||||
cdef SignatureType SIGNATURE_TREE_AS_TYPES_0
|
||||
cdef SignatureTree SIGNATURE_TREE_AO
|
||||
cdef SignatureType SIGNATURE_TREE_AO_TYPES_0
|
||||
cdef SignatureTree SIGNATURE_TREE_A_SV
|
||||
cdef SignatureType SIGNATURE_TREE_A_SV_TYPES_0
|
||||
cdef SignatureTree SIGNATURE_TREE_SA_SV_AS
|
||||
cdef SignatureType SIGNATURE_TREE_SA_SV_AS_TYPES_1
|
||||
cdef SignatureType SIGNATURE_TREE_SA_SV_AS_TYPES_2
|
||||
cdef SignatureTree SIGNATURE_TREE_OAS
|
||||
cdef SignatureType SIGNATURE_TREE_OAS_TYPES_1
|
||||
cdef SignatureTree SIGNATURE_TREE_OA_SA_SV
|
||||
cdef SignatureType SIGNATURE_TREE_OA_SA_SV_TYPES_1
|
||||
cdef SignatureTree SIGNATURE_TREE_AY
|
||||
cdef SignatureType SIGNATURE_TREE_AY_TYPES_0
|
||||
cdef SignatureTree SIGNATURE_TREE_A_QV
|
||||
cdef SignatureType SIGNATURE_TREE_A_QV_TYPES_0
|
||||
cdef SignatureTree SIGNATURE_TREE_A_OA_SA_SV
|
||||
cdef SignatureType SIGNATURE_TREE_A_OA_SA_SV_TYPES_0
|
||||
|
||||
cdef unsigned int TOKEN_B_AS_INT
|
||||
cdef unsigned int TOKEN_U_AS_INT
|
||||
cdef unsigned int TOKEN_Y_AS_INT
|
||||
cdef unsigned int TOKEN_A_AS_INT
|
||||
cdef unsigned int TOKEN_O_AS_INT
|
||||
cdef unsigned int TOKEN_S_AS_INT
|
||||
cdef unsigned int TOKEN_G_AS_INT
|
||||
cdef unsigned int TOKEN_N_AS_INT
|
||||
cdef unsigned int TOKEN_X_AS_INT
|
||||
cdef unsigned int TOKEN_T_AS_INT
|
||||
cdef unsigned int TOKEN_D_AS_INT
|
||||
cdef unsigned int TOKEN_Q_AS_INT
|
||||
cdef unsigned int TOKEN_V_AS_INT
|
||||
cdef unsigned int TOKEN_LEFT_CURLY_AS_INT
|
||||
cdef unsigned int TOKEN_LEFT_PAREN_AS_INT
|
||||
|
||||
cdef object MARSHALL_STREAM_END_ERROR
|
||||
cdef object DEFAULT_BUFFER_SIZE
|
||||
|
||||
cdef cython.uint EAGAIN
|
||||
cdef cython.uint EWOULDBLOCK
|
||||
|
||||
cdef get_signature_tree
|
||||
|
||||
|
||||
cdef inline unsigned long _cast_uint32_native(const char * payload, unsigned int offset):
|
||||
cdef unsigned long *u32p = <unsigned long *> &payload[offset]
|
||||
return u32p[0]
|
||||
|
||||
cdef inline short _cast_int16_native(const char * payload, unsigned int offset):
|
||||
cdef short *s16p = <short *> &payload[offset]
|
||||
return s16p[0]
|
||||
|
||||
cdef inline unsigned short _cast_uint16_native(const char * payload, unsigned int offset):
|
||||
cdef unsigned short *u16p = <unsigned short *> &payload[offset]
|
||||
return u16p[0]
|
||||
|
||||
|
||||
|
||||
cdef class Unmarshaller:
|
||||
|
||||
cdef object _unix_fds
|
||||
cdef bytearray _buf
|
||||
cdef unsigned int _pos
|
||||
cdef object _stream
|
||||
cdef object _sock
|
||||
cdef object _message
|
||||
cdef object _readers
|
||||
cdef unsigned int _body_len
|
||||
cdef unsigned int _serial
|
||||
cdef unsigned int _header_len
|
||||
cdef object _message_type
|
||||
cdef object _flag
|
||||
cdef unsigned int _msg_len
|
||||
cdef unsigned int _is_native
|
||||
cdef object _uint32_unpack
|
||||
cdef object _int16_unpack
|
||||
cdef object _uint16_unpack
|
||||
cdef object _stream_reader
|
||||
cdef object _sock_reader
|
||||
cdef bint _negotiate_unix_fd
|
||||
cdef bint _read_complete
|
||||
cdef unsigned int _endian
|
||||
|
||||
cdef _next_message(self)
|
||||
|
||||
cdef bint _has_another_message_in_buffer(self)
|
||||
|
||||
@cython.locals(
|
||||
msg=cython.bytes,
|
||||
recv=cython.tuple,
|
||||
errno=cython.uint
|
||||
)
|
||||
cdef void _read_sock_with_fds(self, unsigned int pos, unsigned int missing_bytes)
|
||||
|
||||
@cython.locals(
|
||||
data=cython.bytes,
|
||||
errno=cython.uint
|
||||
)
|
||||
cdef void _read_sock_without_fds(self, unsigned int pos)
|
||||
|
||||
@cython.locals(
|
||||
data=cython.bytes
|
||||
)
|
||||
cdef void _read_stream(self, unsigned int pos, unsigned int missing_bytes)
|
||||
|
||||
cdef void _read_to_pos(self, unsigned int pos)
|
||||
|
||||
cpdef read_boolean(self, SignatureType type_)
|
||||
|
||||
cdef _read_boolean(self)
|
||||
|
||||
cpdef read_uint32_unpack(self, SignatureType type_)
|
||||
|
||||
cdef unsigned int _read_uint32_unpack(self)
|
||||
|
||||
cpdef read_int16_unpack(self, SignatureType type_)
|
||||
|
||||
cdef int _read_int16_unpack(self)
|
||||
|
||||
cpdef read_uint16_unpack(self, SignatureType type_)
|
||||
|
||||
cdef unsigned int _read_uint16_unpack(self)
|
||||
|
||||
cpdef read_string_unpack(self, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
str_start=cython.uint,
|
||||
)
|
||||
cdef str _read_string_unpack(self)
|
||||
|
||||
@cython.locals(
|
||||
tree=SignatureTree,
|
||||
token_as_int=cython.uint,
|
||||
)
|
||||
cdef Variant _read_variant(self)
|
||||
|
||||
@cython.locals(
|
||||
beginning_pos=cython.ulong,
|
||||
array_length=cython.uint,
|
||||
children=cython.list,
|
||||
child_type=SignatureType,
|
||||
child_0=SignatureType,
|
||||
child_1=SignatureType,
|
||||
token_as_int=cython.uint,
|
||||
)
|
||||
cpdef object read_array(self, SignatureType type_)
|
||||
|
||||
cpdef read_signature(self, SignatureType type_)
|
||||
|
||||
@cython.locals(
|
||||
o=cython.ulong,
|
||||
signature_len=cython.uint,
|
||||
)
|
||||
cdef str _read_signature(self)
|
||||
|
||||
@cython.locals(
|
||||
endian=cython.uint,
|
||||
buffer=cython.bytearray,
|
||||
protocol_version=cython.uint,
|
||||
key=cython.str,
|
||||
)
|
||||
cdef _read_header(self)
|
||||
|
||||
@cython.locals(
|
||||
body=cython.list,
|
||||
header_fields=cython.dict,
|
||||
token_as_int=cython.uint,
|
||||
signature=cython.str,
|
||||
)
|
||||
cdef _read_body(self)
|
||||
|
||||
cdef _unmarshall(self)
|
||||
|
||||
cpdef unmarshall(self)
|
||||
|
||||
@cython.locals(
|
||||
beginning_pos=cython.ulong,
|
||||
o=cython.ulong,
|
||||
token_as_int=cython.uint,
|
||||
signature_len=cython.uint,
|
||||
)
|
||||
cdef cython.dict _header_fields(self, unsigned int header_length)
|
811
dbus_fast/_private/unmarshaller.py
Normal file
811
dbus_fast/_private/unmarshaller.py
Normal file
|
@ -0,0 +1,811 @@
|
|||
import array
|
||||
import errno
|
||||
import io
|
||||
import socket
|
||||
import sys
|
||||
from struct import Struct
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
|
||||
|
||||
from ..constants import MESSAGE_FLAG_MAP, MESSAGE_TYPE_MAP, MessageFlag
|
||||
from ..errors import InvalidMessageError
|
||||
from ..message import Message
|
||||
from ..signature import SignatureType, Variant, get_signature_tree
|
||||
from .constants import BIG_ENDIAN, LITTLE_ENDIAN, PROTOCOL_VERSION
|
||||
|
||||
MESSAGE_FLAG_INTENUM = MessageFlag
|
||||
|
||||
MAX_UNIX_FDS = 16
|
||||
MAX_UNIX_FDS_SIZE = array.array("i").itemsize
|
||||
UNIX_FDS_CMSG_LENGTH = socket.CMSG_LEN(MAX_UNIX_FDS_SIZE)
|
||||
|
||||
UNPACK_SYMBOL = {LITTLE_ENDIAN: "<", BIG_ENDIAN: ">"}
|
||||
|
||||
UINT32_CAST = "I"
|
||||
UINT32_SIZE = 4
|
||||
UINT32_DBUS_TYPE = "u"
|
||||
|
||||
INT16_CAST = "h"
|
||||
INT16_SIZE = 2
|
||||
INT16_DBUS_TYPE = "n"
|
||||
|
||||
UINT16_CAST = "H"
|
||||
UINT16_SIZE = 2
|
||||
UINT16_DBUS_TYPE = "q"
|
||||
|
||||
SYS_IS_LITTLE_ENDIAN = sys.byteorder == "little"
|
||||
SYS_IS_BIG_ENDIAN = sys.byteorder == "big"
|
||||
|
||||
DBUS_TO_CTYPE = {
|
||||
"y": ("B", 1), # byte
|
||||
INT16_DBUS_TYPE: (INT16_CAST, INT16_SIZE), # int16
|
||||
UINT16_DBUS_TYPE: (UINT16_CAST, UINT16_SIZE), # uint16
|
||||
"i": ("i", 4), # int32
|
||||
UINT32_DBUS_TYPE: (UINT32_CAST, UINT32_SIZE), # uint32
|
||||
"x": ("q", 8), # int64
|
||||
"t": ("Q", 8), # uint64
|
||||
"d": ("d", 8), # double
|
||||
"h": (UINT32_CAST, UINT32_SIZE), # uint32
|
||||
}
|
||||
|
||||
UNPACK_HEADER_LITTLE_ENDIAN = Struct("<III").unpack_from
|
||||
UNPACK_HEADER_BIG_ENDIAN = Struct(">III").unpack_from
|
||||
|
||||
UINT32_UNPACK_LITTLE_ENDIAN = Struct(f"<{UINT32_CAST}").unpack_from
|
||||
UINT32_UNPACK_BIG_ENDIAN = Struct(f">{UINT32_CAST}").unpack_from
|
||||
|
||||
INT16_UNPACK_LITTLE_ENDIAN = Struct(f"<{INT16_CAST}").unpack_from
|
||||
INT16_UNPACK_BIG_ENDIAN = Struct(f">{INT16_CAST}").unpack_from
|
||||
|
||||
UINT16_UNPACK_LITTLE_ENDIAN = Struct(f"<{UINT16_CAST}").unpack_from
|
||||
UINT16_UNPACK_BIG_ENDIAN = Struct(f">{UINT16_CAST}").unpack_from
|
||||
|
||||
HEADER_SIGNATURE_SIZE = 16
|
||||
HEADER_ARRAY_OF_STRUCT_SIGNATURE_POSITION = 12
|
||||
|
||||
|
||||
SIGNATURE_TREE_EMPTY = get_signature_tree("")
|
||||
SIGNATURE_TREE_B = get_signature_tree("b")
|
||||
SIGNATURE_TREE_N = get_signature_tree("n")
|
||||
SIGNATURE_TREE_S = get_signature_tree("s")
|
||||
SIGNATURE_TREE_O = get_signature_tree("o")
|
||||
SIGNATURE_TREE_U = get_signature_tree("u")
|
||||
SIGNATURE_TREE_Y = get_signature_tree("y")
|
||||
|
||||
SIGNATURE_TREE_AY = get_signature_tree("ay")
|
||||
SIGNATURE_TREE_AS = get_signature_tree("as")
|
||||
SIGNATURE_TREE_AS_TYPES_0 = SIGNATURE_TREE_AS.types[0]
|
||||
SIGNATURE_TREE_A_SV = get_signature_tree("a{sv}")
|
||||
SIGNATURE_TREE_A_SV_TYPES_0 = SIGNATURE_TREE_A_SV.types[0]
|
||||
|
||||
SIGNATURE_TREE_AO = get_signature_tree("ao")
|
||||
SIGNATURE_TREE_AO_TYPES_0 = SIGNATURE_TREE_AO.types[0]
|
||||
|
||||
SIGNATURE_TREE_OAS = get_signature_tree("oas")
|
||||
SIGNATURE_TREE_OAS_TYPES_1 = SIGNATURE_TREE_OAS.types[1]
|
||||
|
||||
SIGNATURE_TREE_AY_TYPES_0 = SIGNATURE_TREE_AY.types[0]
|
||||
SIGNATURE_TREE_A_QV = get_signature_tree("a{qv}")
|
||||
SIGNATURE_TREE_A_QV_TYPES_0 = SIGNATURE_TREE_A_QV.types[0]
|
||||
|
||||
SIGNATURE_TREE_SA_SV_AS = get_signature_tree("sa{sv}as")
|
||||
SIGNATURE_TREE_SA_SV_AS_TYPES_1 = SIGNATURE_TREE_SA_SV_AS.types[1]
|
||||
SIGNATURE_TREE_SA_SV_AS_TYPES_2 = SIGNATURE_TREE_SA_SV_AS.types[2]
|
||||
|
||||
SIGNATURE_TREE_OA_SA_SV = get_signature_tree("oa{sa{sv}}")
|
||||
SIGNATURE_TREE_OA_SA_SV_TYPES_1 = SIGNATURE_TREE_OA_SA_SV.types[1]
|
||||
|
||||
SIGNATURE_TREE_A_OA_SA_SV = get_signature_tree("a{oa{sa{sv}}}")
|
||||
SIGNATURE_TREE_A_OA_SA_SV_TYPES_0 = SIGNATURE_TREE_A_OA_SA_SV.types[0]
|
||||
|
||||
TOKEN_B_AS_INT = ord("b")
|
||||
TOKEN_U_AS_INT = ord("u")
|
||||
TOKEN_Y_AS_INT = ord("y")
|
||||
TOKEN_A_AS_INT = ord("a")
|
||||
TOKEN_O_AS_INT = ord("o")
|
||||
TOKEN_S_AS_INT = ord("s")
|
||||
TOKEN_G_AS_INT = ord("g")
|
||||
TOKEN_N_AS_INT = ord("n")
|
||||
TOKEN_X_AS_INT = ord("x")
|
||||
TOKEN_T_AS_INT = ord("t")
|
||||
TOKEN_D_AS_INT = ord("d")
|
||||
TOKEN_Q_AS_INT = ord("q")
|
||||
TOKEN_V_AS_INT = ord("v")
|
||||
TOKEN_LEFT_CURLY_AS_INT = ord("{")
|
||||
TOKEN_LEFT_PAREN_AS_INT = ord("(")
|
||||
|
||||
|
||||
ARRAY = array.array
|
||||
SOL_SOCKET = socket.SOL_SOCKET
|
||||
SCM_RIGHTS = socket.SCM_RIGHTS
|
||||
|
||||
EAGAIN = errno.EAGAIN
|
||||
EWOULDBLOCK = errno.EWOULDBLOCK
|
||||
|
||||
HEADER_IDX_TO_ARG_NAME = [
|
||||
"",
|
||||
"path",
|
||||
"interface",
|
||||
"member",
|
||||
"error_name",
|
||||
"reply_serial",
|
||||
"destination",
|
||||
"sender",
|
||||
"signature",
|
||||
"unix_fds",
|
||||
]
|
||||
HEADER_UNIX_FDS_IDX = HEADER_IDX_TO_ARG_NAME.index("unix_fds")
|
||||
|
||||
_SignatureType = SignatureType
|
||||
_int = int
|
||||
|
||||
READER_TYPE = Callable[["Unmarshaller", SignatureType], Any]
|
||||
|
||||
MARSHALL_STREAM_END_ERROR = BlockingIOError
|
||||
|
||||
DEFAULT_BUFFER_SIZE = io.DEFAULT_BUFFER_SIZE
|
||||
|
||||
|
||||
def unpack_parser_factory(unpack_from: Callable, size: int) -> READER_TYPE:
|
||||
"""Build a parser that unpacks the bytes using the given unpack_from function."""
|
||||
|
||||
def _unpack_from_parser(self: "Unmarshaller", signature: SignatureType) -> Any:
|
||||
self._pos += size + (-self._pos & (size - 1)) # align
|
||||
return unpack_from(self._buf, self._pos - size)[0]
|
||||
|
||||
return _unpack_from_parser
|
||||
|
||||
|
||||
def build_simple_parsers(
|
||||
endian: int,
|
||||
) -> Dict[str, Callable[["Unmarshaller", SignatureType], Any]]:
|
||||
"""Build a dict of parsers for simple types."""
|
||||
parsers: Dict[str, READER_TYPE] = {}
|
||||
for dbus_type, ctype_size in DBUS_TO_CTYPE.items():
|
||||
ctype, size = ctype_size
|
||||
size = ctype_size[1]
|
||||
parsers[dbus_type] = unpack_parser_factory(
|
||||
Struct(f"{UNPACK_SYMBOL[endian]}{ctype}").unpack_from, size
|
||||
)
|
||||
return parsers
|
||||
|
||||
|
||||
try:
|
||||
import cython
|
||||
except ImportError:
|
||||
from ._cython_compat import FAKE_CYTHON as cython
|
||||
|
||||
|
||||
#
|
||||
# Alignment padding is handled with the following formula below
|
||||
#
|
||||
# For any align value, the correct padding formula is:
|
||||
#
|
||||
# (align - (pos % align)) % align
|
||||
#
|
||||
# However, if align is a power of 2 (always the case here), the slow MOD
|
||||
# operator can be replaced by a bitwise AND:
|
||||
#
|
||||
# (align - (pos & (align - 1))) & (align - 1)
|
||||
#
|
||||
# Which can be simplified to:
|
||||
#
|
||||
# (-pos) & (align - 1)
|
||||
#
|
||||
#
|
||||
class Unmarshaller:
|
||||
"""Unmarshall messages from a stream.
|
||||
|
||||
When calling with sock and _negotiate_unix_fd False, the unmashaller must
|
||||
be called continuously for each new message as it will buffer the data
|
||||
until a complete message is available.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_unix_fds",
|
||||
"_buf",
|
||||
"_pos",
|
||||
"_stream",
|
||||
"_sock",
|
||||
"_message",
|
||||
"_readers",
|
||||
"_body_len",
|
||||
"_serial",
|
||||
"_header_len",
|
||||
"_message_type",
|
||||
"_flag",
|
||||
"_msg_len",
|
||||
"_uint32_unpack",
|
||||
"_int16_unpack",
|
||||
"_uint16_unpack",
|
||||
"_is_native",
|
||||
"_stream_reader",
|
||||
"_sock_reader",
|
||||
"_negotiate_unix_fd",
|
||||
"_read_complete",
|
||||
"_endian",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: Optional[io.BufferedRWPair] = None,
|
||||
sock: Optional[socket.socket] = None,
|
||||
negotiate_unix_fd: bool = True,
|
||||
) -> None:
|
||||
self._unix_fds: List[int] = []
|
||||
self._buf = bytearray() # Actual buffer
|
||||
self._stream = stream
|
||||
self._sock = sock
|
||||
self._message: Optional[Message] = None
|
||||
self._readers: Dict[str, READER_TYPE] = {}
|
||||
self._pos = 0
|
||||
self._body_len = 0
|
||||
self._serial = 0
|
||||
self._header_len = 0
|
||||
self._message_type = 0
|
||||
self._flag = 0
|
||||
self._msg_len = 0
|
||||
self._is_native = 0
|
||||
self._uint32_unpack: Optional[Callable] = None
|
||||
self._int16_unpack: Optional[Callable] = None
|
||||
self._uint16_unpack: Optional[Callable] = None
|
||||
self._stream_reader: Optional[Callable] = None
|
||||
self._negotiate_unix_fd = negotiate_unix_fd
|
||||
self._read_complete = False
|
||||
if stream:
|
||||
if isinstance(stream, io.BufferedRWPair) and hasattr(stream, "reader"):
|
||||
self._stream_reader = stream.reader.read # type: ignore[attr-defined]
|
||||
self._stream_reader = stream.read
|
||||
elif self._negotiate_unix_fd:
|
||||
self._sock_reader = self._sock.recvmsg
|
||||
else:
|
||||
self._sock_reader = self._sock.recv
|
||||
self._endian = 0
|
||||
|
||||
def _next_message(self) -> None:
|
||||
"""Reset the unmarshaller to its initial state.
|
||||
|
||||
Call this before processing a new message.
|
||||
"""
|
||||
self._unix_fds = []
|
||||
to_clear = HEADER_SIGNATURE_SIZE + self._msg_len
|
||||
if len(self._buf) == to_clear:
|
||||
self._buf = bytearray()
|
||||
else:
|
||||
del self._buf[:to_clear]
|
||||
self._msg_len = 0 # used to check if we have ready the header
|
||||
self._read_complete = False # used to check if we have ready the message
|
||||
# No need to reset the unpack functions, they are set in _read_header
|
||||
# every time a new message is processed.
|
||||
|
||||
@property
|
||||
def message(self) -> Optional[Message]:
|
||||
"""Return the message that has been unmarshalled."""
|
||||
if self._read_complete:
|
||||
return self._message
|
||||
return None
|
||||
|
||||
def _has_another_message_in_buffer(self) -> bool:
|
||||
"""Check if there is another message in the buffer."""
|
||||
return len(self._buf) > HEADER_SIGNATURE_SIZE + self._msg_len
|
||||
|
||||
def _read_sock_with_fds(self, pos: _int, missing_bytes: _int) -> None:
|
||||
"""reads from the socket, storing any fds sent and handling errors
|
||||
from the read itself.
|
||||
|
||||
This function is greedy and will read as much data as possible
|
||||
from the underlying socket.
|
||||
"""
|
||||
# This will raise BlockingIOError if there is no data to read
|
||||
# which we store in the MARSHALL_STREAM_END_ERROR object
|
||||
try:
|
||||
recv = self._sock_reader(missing_bytes, UNIX_FDS_CMSG_LENGTH) # type: ignore[union-attr]
|
||||
except OSError as e:
|
||||
errno = e.errno
|
||||
if errno == EAGAIN or errno == EWOULDBLOCK:
|
||||
raise MARSHALL_STREAM_END_ERROR
|
||||
raise
|
||||
msg = recv[0]
|
||||
ancdata = recv[1]
|
||||
if ancdata:
|
||||
for level, type_, data in ancdata:
|
||||
if not (level == SOL_SOCKET and type_ == SCM_RIGHTS):
|
||||
continue
|
||||
self._unix_fds.extend(
|
||||
ARRAY("i", data[: len(data) - (len(data) % MAX_UNIX_FDS_SIZE)])
|
||||
)
|
||||
if not msg:
|
||||
raise EOFError()
|
||||
self._buf += msg
|
||||
if len(self._buf) < pos:
|
||||
raise MARSHALL_STREAM_END_ERROR
|
||||
|
||||
def _read_sock_without_fds(self, pos: _int) -> None:
|
||||
"""reads from the socket and handling errors from the read itself.
|
||||
|
||||
This function is greedy and will read as much data as possible
|
||||
from the underlying socket.
|
||||
"""
|
||||
# This will raise BlockingIOError if there is no data to read
|
||||
# which we store in the MARSHALL_STREAM_END_ERROR object
|
||||
while True:
|
||||
try:
|
||||
data = self._sock_reader(DEFAULT_BUFFER_SIZE) # type: ignore[union-attr]
|
||||
except OSError as e:
|
||||
errno = e.errno
|
||||
if errno == EAGAIN or errno == EWOULDBLOCK:
|
||||
raise MARSHALL_STREAM_END_ERROR
|
||||
raise
|
||||
if not data:
|
||||
raise EOFError()
|
||||
self._buf += data
|
||||
if len(self._buf) >= pos:
|
||||
return
|
||||
|
||||
def _read_stream(self, pos: _int, missing_bytes: _int) -> bytes:
|
||||
"""Read from the stream."""
|
||||
data = self._stream_reader(missing_bytes) # type: ignore[misc]
|
||||
if data is None:
|
||||
raise MARSHALL_STREAM_END_ERROR
|
||||
if not data:
|
||||
raise EOFError()
|
||||
self._buf += data
|
||||
if len(self._buf) < pos:
|
||||
raise MARSHALL_STREAM_END_ERROR
|
||||
|
||||
def _read_to_pos(self, pos: _int) -> None:
|
||||
"""
|
||||
Read from underlying socket into buffer.
|
||||
|
||||
Raises BlockingIOError if there is not enough data to be read.
|
||||
|
||||
:arg pos:
|
||||
The pos to read to. If not enough bytes are available in the
|
||||
buffer, read more from it.
|
||||
|
||||
:returns:
|
||||
None
|
||||
"""
|
||||
missing_bytes = pos - len(self._buf)
|
||||
if missing_bytes <= 0:
|
||||
return
|
||||
if self._sock is None:
|
||||
self._read_stream(pos, missing_bytes)
|
||||
elif self._negotiate_unix_fd:
|
||||
self._read_sock_with_fds(pos, missing_bytes)
|
||||
else:
|
||||
self._read_sock_without_fds(pos)
|
||||
|
||||
def read_uint32_unpack(self, type_: _SignatureType) -> int:
|
||||
return self._read_uint32_unpack()
|
||||
|
||||
def _read_uint32_unpack(self) -> int:
|
||||
self._pos += UINT32_SIZE + (-self._pos & (UINT32_SIZE - 1)) # align
|
||||
if self._is_native and cython.compiled:
|
||||
return _cast_uint32_native( # type: ignore[name-defined] # pragma: no cover
|
||||
self._buf, self._pos - UINT32_SIZE
|
||||
)
|
||||
return self._uint32_unpack(self._buf, self._pos - UINT32_SIZE)[0] # type: ignore[misc]
|
||||
|
||||
def read_uint16_unpack(self, type_: _SignatureType) -> int:
|
||||
return self._read_uint16_unpack()
|
||||
|
||||
def _read_uint16_unpack(self) -> int:
|
||||
self._pos += UINT16_SIZE + (-self._pos & (UINT16_SIZE - 1)) # align
|
||||
if self._is_native and cython.compiled:
|
||||
return _cast_uint16_native( # type: ignore[name-defined] # pragma: no cover
|
||||
self._buf, self._pos - UINT16_SIZE
|
||||
)
|
||||
return self._uint16_unpack(self._buf, self._pos - UINT16_SIZE)[0] # type: ignore[misc]
|
||||
|
||||
def read_int16_unpack(self, type_: _SignatureType) -> int:
|
||||
return self._read_int16_unpack()
|
||||
|
||||
def _read_int16_unpack(self) -> int:
|
||||
self._pos += INT16_SIZE + (-self._pos & (INT16_SIZE - 1)) # align
|
||||
if self._is_native and cython.compiled:
|
||||
return _cast_int16_native( # type: ignore[name-defined] # pragma: no cover
|
||||
self._buf, self._pos - INT16_SIZE
|
||||
)
|
||||
return self._int16_unpack(self._buf, self._pos - INT16_SIZE)[0] # type: ignore[misc]
|
||||
|
||||
def read_boolean(self, type_: _SignatureType) -> bool:
|
||||
return self._read_boolean()
|
||||
|
||||
def _read_boolean(self) -> bool:
|
||||
return bool(self._read_uint32_unpack())
|
||||
|
||||
def read_string_unpack(self, type_: _SignatureType) -> str:
|
||||
return self._read_string_unpack()
|
||||
|
||||
def _read_string_unpack(self) -> str:
|
||||
"""Read a string using unpack."""
|
||||
self._pos += UINT32_SIZE + (-self._pos & (UINT32_SIZE - 1)) # align
|
||||
str_start = self._pos
|
||||
# read terminating '\0' byte as well (str_length + 1)
|
||||
if self._is_native and cython.compiled:
|
||||
self._pos += ( # pragma: no cover
|
||||
_cast_uint32_native(self._buf, str_start - UINT32_SIZE) + 1 # type: ignore[name-defined]
|
||||
)
|
||||
else:
|
||||
self._pos += self._uint32_unpack(self._buf, str_start - UINT32_SIZE)[0] + 1 # type: ignore[misc]
|
||||
return self._buf[str_start : self._pos - 1].decode()
|
||||
|
||||
def read_signature(self, type_: _SignatureType) -> str:
|
||||
return self._read_signature()
|
||||
|
||||
def _read_signature(self) -> str:
|
||||
signature_len = self._buf[self._pos] # byte
|
||||
o = self._pos + 1
|
||||
# read terminating '\0' byte as well (str_length + 1)
|
||||
self._pos = o + signature_len + 1
|
||||
return self._buf[o : o + signature_len].decode()
|
||||
|
||||
def read_variant(self, type_: _SignatureType) -> Variant:
|
||||
return self._read_variant()
|
||||
|
||||
def _read_variant(self) -> Variant:
|
||||
signature = self._read_signature()
|
||||
token_as_int = ord(signature[0])
|
||||
# verify in Variant is only useful on construction not unmarshalling
|
||||
if len(signature) == 1:
|
||||
if token_as_int == TOKEN_N_AS_INT:
|
||||
return Variant(SIGNATURE_TREE_N, self._read_int16_unpack(), False)
|
||||
if token_as_int == TOKEN_S_AS_INT:
|
||||
return Variant(SIGNATURE_TREE_S, self._read_string_unpack(), False)
|
||||
if token_as_int == TOKEN_B_AS_INT:
|
||||
return Variant(SIGNATURE_TREE_B, self._read_boolean(), False)
|
||||
if token_as_int == TOKEN_O_AS_INT:
|
||||
return Variant(SIGNATURE_TREE_O, self._read_string_unpack(), False)
|
||||
if token_as_int == TOKEN_U_AS_INT:
|
||||
return Variant(SIGNATURE_TREE_U, self._read_uint32_unpack(), False)
|
||||
if token_as_int == TOKEN_Y_AS_INT:
|
||||
self._pos += 1
|
||||
return Variant(SIGNATURE_TREE_Y, self._buf[self._pos - 1], False)
|
||||
elif token_as_int == TOKEN_A_AS_INT:
|
||||
if signature == "ay":
|
||||
return Variant(
|
||||
SIGNATURE_TREE_AY, self.read_array(SIGNATURE_TREE_AY_TYPES_0), False
|
||||
)
|
||||
if signature == "a{qv}":
|
||||
return Variant(
|
||||
SIGNATURE_TREE_A_QV,
|
||||
self.read_array(SIGNATURE_TREE_A_QV_TYPES_0),
|
||||
False,
|
||||
)
|
||||
if signature == "as":
|
||||
return Variant(
|
||||
SIGNATURE_TREE_AS, self.read_array(SIGNATURE_TREE_AS_TYPES_0), False
|
||||
)
|
||||
if signature == "a{sv}":
|
||||
return Variant(
|
||||
SIGNATURE_TREE_A_SV,
|
||||
self.read_array(SIGNATURE_TREE_A_SV_TYPES_0),
|
||||
False,
|
||||
)
|
||||
if signature == "ao":
|
||||
return Variant(
|
||||
SIGNATURE_TREE_AO, self.read_array(SIGNATURE_TREE_AO_TYPES_0), False
|
||||
)
|
||||
tree = get_signature_tree(signature)
|
||||
signature_type = tree.types[0]
|
||||
return Variant(
|
||||
tree,
|
||||
self._readers[signature_type.token](self, signature_type),
|
||||
False,
|
||||
)
|
||||
|
||||
def read_struct(self, type_: _SignatureType) -> List[Any]:
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
readers = self._readers
|
||||
return [
|
||||
readers[child_type.token](self, child_type) for child_type in type_.children
|
||||
]
|
||||
|
||||
def read_dict_entry(self, type_: _SignatureType) -> Tuple[Any, Any]:
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
return self._readers[type_.children[0].token](
|
||||
self, type_.children[0]
|
||||
), self._readers[type_.children[1].token](self, type_.children[1])
|
||||
|
||||
def read_array(self, type_: _SignatureType) -> Iterable[Any]:
|
||||
self._pos += -self._pos & 3 # align 4 for the array
|
||||
self._pos += (
|
||||
-self._pos & (UINT32_SIZE - 1)
|
||||
) + UINT32_SIZE # align for the uint32
|
||||
if self._is_native and cython.compiled:
|
||||
array_length = _cast_uint32_native( # type: ignore[name-defined] # pragma: no cover
|
||||
self._buf, self._pos - UINT32_SIZE
|
||||
)
|
||||
else:
|
||||
array_length = self._uint32_unpack(self._buf, self._pos - UINT32_SIZE)[0] # type: ignore[misc]
|
||||
|
||||
child_type: SignatureType = type_.children[0]
|
||||
token_as_int = ord(child_type.token[0])
|
||||
|
||||
if (
|
||||
token_as_int == TOKEN_X_AS_INT
|
||||
or token_as_int == TOKEN_T_AS_INT
|
||||
or token_as_int == TOKEN_D_AS_INT
|
||||
or token_as_int == TOKEN_LEFT_CURLY_AS_INT
|
||||
or token_as_int == TOKEN_LEFT_PAREN_AS_INT
|
||||
):
|
||||
# the first alignment is not included in the array size
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
|
||||
if token_as_int == TOKEN_Y_AS_INT:
|
||||
self._pos += array_length
|
||||
return self._buf[self._pos - array_length : self._pos]
|
||||
|
||||
if token_as_int == TOKEN_LEFT_CURLY_AS_INT:
|
||||
result_dict: Dict[Any, Any] = {}
|
||||
beginning_pos = self._pos
|
||||
children = child_type.children
|
||||
child_0 = children[0]
|
||||
child_1 = children[1]
|
||||
child_0_token_as_int = ord(child_0.token[0])
|
||||
child_1_token_as_int = ord(child_1.token[0])
|
||||
# Strings with variant values are the most common case
|
||||
# so we optimize for that by inlining the string reading
|
||||
# and the variant reading here
|
||||
if (
|
||||
child_0_token_as_int == TOKEN_O_AS_INT
|
||||
or child_0_token_as_int == TOKEN_S_AS_INT
|
||||
) and child_1_token_as_int == TOKEN_V_AS_INT:
|
||||
while self._pos - beginning_pos < array_length:
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
key: Union[str, int] = self._read_string_unpack()
|
||||
result_dict[key] = self._read_variant()
|
||||
elif (
|
||||
child_0_token_as_int == TOKEN_Q_AS_INT
|
||||
and child_1_token_as_int == TOKEN_V_AS_INT
|
||||
):
|
||||
while self._pos - beginning_pos < array_length:
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
key = self._read_uint16_unpack()
|
||||
result_dict[key] = self._read_variant()
|
||||
if (
|
||||
child_0_token_as_int == TOKEN_O_AS_INT
|
||||
or child_0_token_as_int == TOKEN_S_AS_INT
|
||||
) and child_1_token_as_int == TOKEN_A_AS_INT:
|
||||
while self._pos - beginning_pos < array_length:
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
key = self._read_string_unpack()
|
||||
result_dict[key] = self.read_array(child_1)
|
||||
else:
|
||||
reader_1 = self._readers[child_1.token]
|
||||
reader_0 = self._readers[child_0.token]
|
||||
while self._pos - beginning_pos < array_length:
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
key = reader_0(self, child_0)
|
||||
result_dict[key] = reader_1(self, child_1)
|
||||
|
||||
return result_dict
|
||||
|
||||
if array_length == 0:
|
||||
return []
|
||||
|
||||
result_list = []
|
||||
beginning_pos = self._pos
|
||||
if token_as_int == TOKEN_O_AS_INT or token_as_int == TOKEN_S_AS_INT:
|
||||
while self._pos - beginning_pos < array_length:
|
||||
result_list.append(self._read_string_unpack())
|
||||
return result_list
|
||||
reader = self._readers[child_type.token]
|
||||
while self._pos - beginning_pos < array_length:
|
||||
result_list.append(reader(self, child_type))
|
||||
return result_list
|
||||
|
||||
def _header_fields(self, header_length: _int) -> Dict[str, Any]:
|
||||
"""Header fields are always a(yv)."""
|
||||
beginning_pos = self._pos
|
||||
headers = {}
|
||||
buf = self._buf
|
||||
readers = self._readers
|
||||
while self._pos - beginning_pos < header_length:
|
||||
# Now read the y (byte) of struct (yv)
|
||||
self._pos += (-self._pos & 7) + 1 # align 8 + 1 for 'y' byte
|
||||
field_0 = buf[self._pos - 1]
|
||||
|
||||
# Now read the v (variant) of struct (yv)
|
||||
# first we read the signature
|
||||
signature_len = buf[self._pos] # byte
|
||||
o = self._pos + 1
|
||||
self._pos += signature_len + 2 # one for the byte, one for the '\0'
|
||||
if field_0 == HEADER_UNIX_FDS_IDX: # defined by self._unix_fds
|
||||
continue
|
||||
token_as_int = buf[o]
|
||||
# Now that we have the token we can read the variant value
|
||||
key = HEADER_IDX_TO_ARG_NAME[field_0]
|
||||
# Strings and signatures are the most common types
|
||||
# so we inline them for performance
|
||||
if token_as_int == TOKEN_O_AS_INT or token_as_int == TOKEN_S_AS_INT:
|
||||
headers[key] = self._read_string_unpack()
|
||||
elif token_as_int == TOKEN_G_AS_INT:
|
||||
headers[key] = self._read_signature()
|
||||
else:
|
||||
token = buf[o : o + signature_len].decode()
|
||||
# There shouldn't be any other types in the header
|
||||
# but just in case, we'll read it using the slow path
|
||||
headers[key] = readers[token](self, get_signature_tree(token).types[0])
|
||||
return headers
|
||||
|
||||
def _read_header(self) -> None:
|
||||
"""Read the header of the message."""
|
||||
# Signature is of the header is
|
||||
# BYTE, BYTE, BYTE, BYTE, UINT32, UINT32, ARRAY of STRUCT of (BYTE,VARIANT)
|
||||
self._read_to_pos(HEADER_SIGNATURE_SIZE)
|
||||
buffer = self._buf
|
||||
endian = buffer[0]
|
||||
self._message_type = buffer[1]
|
||||
self._flag = buffer[2]
|
||||
protocol_version = buffer[3]
|
||||
|
||||
if protocol_version != PROTOCOL_VERSION:
|
||||
raise InvalidMessageError(
|
||||
f"got unknown protocol version: {protocol_version}"
|
||||
)
|
||||
|
||||
if cython.compiled and (
|
||||
(endian == LITTLE_ENDIAN and SYS_IS_LITTLE_ENDIAN)
|
||||
or (endian == BIG_ENDIAN and SYS_IS_BIG_ENDIAN)
|
||||
):
|
||||
self._is_native = 1 # pragma: no cover
|
||||
self._body_len = _cast_uint32_native( # type: ignore[name-defined] # pragma: no cover
|
||||
buffer, 4
|
||||
)
|
||||
self._serial = _cast_uint32_native( # type: ignore[name-defined] # pragma: no cover
|
||||
buffer, 8
|
||||
)
|
||||
self._header_len = _cast_uint32_native( # type: ignore[name-defined] # pragma: no cover
|
||||
buffer, 12
|
||||
)
|
||||
elif endian == LITTLE_ENDIAN:
|
||||
(
|
||||
self._body_len,
|
||||
self._serial,
|
||||
self._header_len,
|
||||
) = UNPACK_HEADER_LITTLE_ENDIAN(buffer, 4)
|
||||
self._uint32_unpack = UINT32_UNPACK_LITTLE_ENDIAN
|
||||
self._int16_unpack = INT16_UNPACK_LITTLE_ENDIAN
|
||||
self._uint16_unpack = UINT16_UNPACK_LITTLE_ENDIAN
|
||||
elif endian == BIG_ENDIAN:
|
||||
self._body_len, self._serial, self._header_len = UNPACK_HEADER_BIG_ENDIAN(
|
||||
buffer, 4
|
||||
)
|
||||
self._uint32_unpack = UINT32_UNPACK_BIG_ENDIAN
|
||||
self._int16_unpack = INT16_UNPACK_BIG_ENDIAN
|
||||
self._uint16_unpack = UINT16_UNPACK_BIG_ENDIAN
|
||||
else:
|
||||
raise InvalidMessageError(
|
||||
f"Expecting endianness as the first byte, got {endian} from {buffer}"
|
||||
)
|
||||
|
||||
self._msg_len = (
|
||||
self._header_len + (-self._header_len & 7) + self._body_len
|
||||
) # align 8
|
||||
if self._endian != endian:
|
||||
self._readers = self._readers_by_type[endian]
|
||||
self._endian = endian
|
||||
|
||||
def _read_body(self) -> None:
|
||||
"""Read the body of the message."""
|
||||
self._read_to_pos(HEADER_SIGNATURE_SIZE + self._msg_len)
|
||||
self._pos = HEADER_ARRAY_OF_STRUCT_SIGNATURE_POSITION
|
||||
header_fields = self._header_fields(self._header_len)
|
||||
self._pos += -self._pos & 7 # align 8
|
||||
signature = header_fields.pop("signature", "")
|
||||
if not self._body_len:
|
||||
tree = SIGNATURE_TREE_EMPTY
|
||||
body: List[Any] = []
|
||||
else:
|
||||
token_as_int = ord(signature[0])
|
||||
if len(signature) == 1:
|
||||
if token_as_int == TOKEN_O_AS_INT:
|
||||
tree = SIGNATURE_TREE_O
|
||||
body = [self._read_string_unpack()]
|
||||
elif token_as_int == TOKEN_S_AS_INT:
|
||||
tree = SIGNATURE_TREE_S
|
||||
body = [self._read_string_unpack()]
|
||||
else:
|
||||
tree = get_signature_tree(signature)
|
||||
body = [self._readers[t.token](self, t) for t in tree.types]
|
||||
elif token_as_int == TOKEN_S_AS_INT and signature == "sa{sv}as":
|
||||
tree = SIGNATURE_TREE_SA_SV_AS
|
||||
body = [
|
||||
self._read_string_unpack(),
|
||||
self.read_array(SIGNATURE_TREE_SA_SV_AS_TYPES_1),
|
||||
self.read_array(SIGNATURE_TREE_SA_SV_AS_TYPES_2),
|
||||
]
|
||||
elif token_as_int == TOKEN_O_AS_INT and signature == "oa{sa{sv}}":
|
||||
tree = SIGNATURE_TREE_OA_SA_SV
|
||||
body = [
|
||||
self._read_string_unpack(),
|
||||
self.read_array(SIGNATURE_TREE_OA_SA_SV_TYPES_1),
|
||||
]
|
||||
elif token_as_int == TOKEN_O_AS_INT and signature == "oas":
|
||||
tree = SIGNATURE_TREE_OAS
|
||||
body = [
|
||||
self._read_string_unpack(),
|
||||
self.read_array(SIGNATURE_TREE_OAS_TYPES_1),
|
||||
]
|
||||
elif token_as_int == TOKEN_A_AS_INT and signature == "a{oa{sa{sv}}}":
|
||||
tree = SIGNATURE_TREE_A_OA_SA_SV
|
||||
body = [self.read_array(SIGNATURE_TREE_A_OA_SA_SV_TYPES_0)]
|
||||
else:
|
||||
tree = get_signature_tree(signature)
|
||||
body = [self._readers[t.token](self, t) for t in tree.types]
|
||||
|
||||
flags = MESSAGE_FLAG_MAP.get(self._flag)
|
||||
if flags is None:
|
||||
flags = MESSAGE_FLAG_INTENUM(self._flag)
|
||||
self._message = Message(
|
||||
message_type=MESSAGE_TYPE_MAP[self._message_type],
|
||||
flags=flags,
|
||||
unix_fds=self._unix_fds,
|
||||
signature=tree,
|
||||
body=body,
|
||||
serial=self._serial,
|
||||
# The D-Bus implementation already validates the message,
|
||||
# so we don't need to do it again.
|
||||
validate=False,
|
||||
**header_fields,
|
||||
)
|
||||
self._read_complete = True
|
||||
|
||||
def unmarshall(self) -> Optional[Message]:
|
||||
"""Unmarshall the message.
|
||||
|
||||
The underlying read function will raise BlockingIOError if the
|
||||
if there are not enough bytes in the buffer. This allows unmarshall
|
||||
to be resumed when more data comes in over the wire.
|
||||
"""
|
||||
return self._unmarshall()
|
||||
|
||||
def _unmarshall(self) -> Optional[Message]:
|
||||
"""Unmarshall the message.
|
||||
|
||||
The underlying read function will raise BlockingIOError if the
|
||||
if there are not enough bytes in the buffer. This allows unmarshall
|
||||
to be resumed when more data comes in over the wire.
|
||||
"""
|
||||
if self._read_complete:
|
||||
self._next_message()
|
||||
try:
|
||||
if not self._msg_len:
|
||||
self._read_header()
|
||||
self._read_body()
|
||||
except MARSHALL_STREAM_END_ERROR:
|
||||
return None
|
||||
return self._message
|
||||
|
||||
_complex_parsers_unpack: Dict[
|
||||
str, Callable[["Unmarshaller", SignatureType], Any]
|
||||
] = {
|
||||
"b": read_boolean,
|
||||
"o": read_string_unpack,
|
||||
"s": read_string_unpack,
|
||||
"g": read_signature,
|
||||
"a": read_array,
|
||||
"(": read_struct,
|
||||
"{": read_dict_entry,
|
||||
"v": read_variant,
|
||||
"h": read_uint32_unpack,
|
||||
UINT32_DBUS_TYPE: read_uint32_unpack,
|
||||
INT16_DBUS_TYPE: read_int16_unpack,
|
||||
UINT16_DBUS_TYPE: read_uint16_unpack,
|
||||
}
|
||||
|
||||
_ctype_by_endian: Dict[int, Dict[str, READER_TYPE]] = {
|
||||
endian: build_simple_parsers(endian) for endian in (LITTLE_ENDIAN, BIG_ENDIAN)
|
||||
}
|
||||
|
||||
_readers_by_type: Dict[int, Dict[str, READER_TYPE]] = {
|
||||
LITTLE_ENDIAN: {
|
||||
**_ctype_by_endian[LITTLE_ENDIAN],
|
||||
**_complex_parsers_unpack,
|
||||
},
|
||||
BIG_ENDIAN: {
|
||||
**_ctype_by_endian[BIG_ENDIAN],
|
||||
**_complex_parsers_unpack,
|
||||
},
|
||||
}
|
172
dbus_fast/_private/util.py
Normal file
172
dbus_fast/_private/util.py
Normal file
|
@ -0,0 +1,172 @@
|
|||
import ast
|
||||
import inspect
|
||||
from typing import Any, List, Tuple, Union
|
||||
|
||||
from ..signature import SignatureTree, Variant, get_signature_tree
|
||||
|
||||
|
||||
def signature_contains_type(
|
||||
signature: Union[str, SignatureTree], body: List[Any], token: str
|
||||
) -> bool:
|
||||
"""For a given signature and body, check to see if it contains any members
|
||||
with the given token"""
|
||||
if type(signature) is str:
|
||||
signature = get_signature_tree(signature)
|
||||
|
||||
queue = []
|
||||
contains_variants = False
|
||||
for st in signature.types:
|
||||
queue.append(st)
|
||||
|
||||
while True:
|
||||
if not queue:
|
||||
break
|
||||
st = queue.pop()
|
||||
if st.token == token:
|
||||
return True
|
||||
elif st.token == "v":
|
||||
contains_variants = True
|
||||
queue.extend(st.children)
|
||||
|
||||
if not contains_variants:
|
||||
return False
|
||||
|
||||
for member in body:
|
||||
queue.append(member)
|
||||
|
||||
while True:
|
||||
if not queue:
|
||||
return False
|
||||
member = queue.pop()
|
||||
if type(member) is Variant and signature_contains_type(
|
||||
member.signature, [member.value], token
|
||||
):
|
||||
return True
|
||||
elif type(member) is list:
|
||||
queue.extend(member)
|
||||
elif type(member) is dict:
|
||||
queue.extend(member.values())
|
||||
|
||||
|
||||
def replace_fds_with_idx(
|
||||
signature: Union[str, SignatureTree], body: List[Any]
|
||||
) -> Tuple[List[Any], List[int]]:
|
||||
"""Take the high level body format and convert it into the low level body
|
||||
format. Type 'h' refers directly to the fd in the body. Replace that with
|
||||
an index and return the corresponding list of unix fds that can be set on
|
||||
the Message"""
|
||||
if type(signature) is str:
|
||||
signature = get_signature_tree(signature)
|
||||
|
||||
if not signature_contains_type(signature, body, "h"):
|
||||
return body, []
|
||||
|
||||
unix_fds = []
|
||||
|
||||
def _replace(fd):
|
||||
try:
|
||||
return unix_fds.index(fd)
|
||||
except ValueError:
|
||||
unix_fds.append(fd)
|
||||
return len(unix_fds) - 1
|
||||
|
||||
_replace_fds(body, signature.types, _replace)
|
||||
|
||||
return body, unix_fds
|
||||
|
||||
|
||||
def replace_idx_with_fds(
|
||||
signature: Union[str, SignatureTree], body: List[Any], unix_fds: List[int]
|
||||
) -> List[Any]:
|
||||
"""Take the low level body format and return the high level body format.
|
||||
Type 'h' refers to an index in the unix_fds array. Replace those with the
|
||||
actual file descriptor or `None` if one does not exist."""
|
||||
if type(signature) is str:
|
||||
signature = get_signature_tree(signature)
|
||||
|
||||
if not signature_contains_type(signature, body, "h"):
|
||||
return body
|
||||
|
||||
def _replace(idx):
|
||||
try:
|
||||
return unix_fds[idx]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
_replace_fds(body, signature.types, _replace)
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def parse_annotation(annotation: str) -> str:
|
||||
"""
|
||||
Because of PEP 563, if `from __future__ import annotations` is used in code
|
||||
or on Python version >=3.10 where this is the default, return annotations
|
||||
from the `inspect` module will return annotations as "forward definitions".
|
||||
In this case, we must eval the result which we do only when given a string
|
||||
constant.
|
||||
"""
|
||||
|
||||
def raise_value_error():
|
||||
raise ValueError(
|
||||
f"service annotations must be a string constant (got {annotation})"
|
||||
)
|
||||
|
||||
if not annotation or annotation is inspect.Signature.empty:
|
||||
return ""
|
||||
if type(annotation) is not str:
|
||||
raise_value_error()
|
||||
try:
|
||||
body = ast.parse(annotation).body
|
||||
if len(body) == 1 and type(body[0].value) is ast.Constant:
|
||||
if type(body[0].value.value) is not str:
|
||||
raise_value_error()
|
||||
return body[0].value.value
|
||||
except SyntaxError:
|
||||
pass
|
||||
|
||||
return annotation
|
||||
|
||||
|
||||
def _replace_fds(body_obj: List[Any], children, replace_fn):
|
||||
"""Replace any type 'h' with the value returned by replace_fn() given the
|
||||
value of the fd field. This is used by the high level interfaces which
|
||||
allow type 'h' to be the fd directly instead of an index in an external
|
||||
array such as in the spec."""
|
||||
for index, st in enumerate(children):
|
||||
if not any(sig in st.signature for sig in "hv"):
|
||||
continue
|
||||
if st.signature == "h":
|
||||
body_obj[index] = replace_fn(body_obj[index])
|
||||
elif st.token == "a":
|
||||
if st.children[0].token == "{":
|
||||
_replace_fds(body_obj[index], st.children, replace_fn)
|
||||
else:
|
||||
for i, child in enumerate(body_obj[index]):
|
||||
if st.signature == "ah":
|
||||
body_obj[index][i] = replace_fn(child)
|
||||
else:
|
||||
_replace_fds([child], st.children, replace_fn)
|
||||
elif st.token in "(":
|
||||
_replace_fds(body_obj[index], st.children, replace_fn)
|
||||
elif st.token in "{":
|
||||
for key, value in list(body_obj.items()):
|
||||
body_obj.pop(key)
|
||||
if st.children[0].signature == "h":
|
||||
key = replace_fn(key)
|
||||
if st.children[1].signature == "h":
|
||||
value = replace_fn(value)
|
||||
else:
|
||||
_replace_fds([value], [st.children[1]], replace_fn)
|
||||
body_obj[key] = value
|
||||
|
||||
elif st.signature == "v":
|
||||
if body_obj[index].signature == "h":
|
||||
body_obj[index].value = replace_fn(body_obj[index].value)
|
||||
else:
|
||||
_replace_fds(
|
||||
[body_obj[index].value], [body_obj[index].type], replace_fn
|
||||
)
|
||||
|
||||
elif st.children:
|
||||
_replace_fds(body_obj[index], st.children, replace_fn)
|
2
dbus_fast/aio/__init__.py
Normal file
2
dbus_fast/aio/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .message_bus import MessageBus
|
||||
from .proxy_object import ProxyInterface, ProxyObject
|
553
dbus_fast/aio/message_bus.py
Normal file
553
dbus_fast/aio/message_bus.py
Normal file
|
@ -0,0 +1,553 @@
|
|||
import array
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import socket
|
||||
from collections import deque
|
||||
from copy import copy
|
||||
from functools import partial
|
||||
from typing import Any, Callable, List, Optional, Set, Tuple
|
||||
|
||||
from .. import introspection as intr
|
||||
from ..auth import Authenticator, AuthExternal
|
||||
from ..constants import (
|
||||
BusType,
|
||||
MessageFlag,
|
||||
MessageType,
|
||||
NameFlag,
|
||||
ReleaseNameReply,
|
||||
RequestNameReply,
|
||||
)
|
||||
from ..errors import AuthError
|
||||
from ..message import Message
|
||||
from ..message_bus import BaseMessageBus, _block_unexpected_reply
|
||||
from ..service import ServiceInterface
|
||||
from .message_reader import build_message_reader
|
||||
from .proxy_object import ProxyObject
|
||||
|
||||
NO_REPLY_EXPECTED_VALUE = MessageFlag.NO_REPLY_EXPECTED.value
|
||||
|
||||
|
||||
def _generate_hello_serialized(next_serial: int) -> bytes:
|
||||
return Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
member="Hello",
|
||||
serial=next_serial,
|
||||
)._marshall(False)
|
||||
|
||||
|
||||
HELLO_1_SERIALIZED = _generate_hello_serialized(1)
|
||||
|
||||
|
||||
def _future_set_exception(fut: asyncio.Future, exc: Exception) -> None:
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_exception(exc)
|
||||
|
||||
|
||||
def _future_set_result(fut: asyncio.Future, result: Any) -> None:
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(result)
|
||||
|
||||
|
||||
class _MessageWriter:
|
||||
"""A class to handle writing messages to the message bus."""
|
||||
|
||||
def __init__(self, bus: "MessageBus") -> None:
|
||||
"""A class to handle writing messages to the message bus."""
|
||||
self.messages: deque[
|
||||
Tuple[bytearray, Optional[List[int]], Optional[asyncio.Future]]
|
||||
] = deque()
|
||||
self.negotiate_unix_fd = bus._negotiate_unix_fd
|
||||
self.bus = bus
|
||||
self.sock = bus._sock
|
||||
self.loop = bus._loop
|
||||
self.buf: Optional[memoryview] = None
|
||||
self.fd = bus._fd
|
||||
self.offset = 0
|
||||
self.unix_fds: Optional[List[int]] = None
|
||||
self.fut: Optional[asyncio.Future] = None
|
||||
|
||||
def write_callback(self, remove_writer: bool = True) -> None:
|
||||
"""The callback to write messages to the message bus."""
|
||||
sock = self.sock
|
||||
try:
|
||||
while True:
|
||||
if self.buf is None:
|
||||
# If there is no buffer, get the next message
|
||||
if not self.messages:
|
||||
# nothing more to write
|
||||
if remove_writer:
|
||||
self.loop.remove_writer(self.fd)
|
||||
return
|
||||
|
||||
# Get the next message
|
||||
buf, unix_fds, fut = self.messages.popleft()
|
||||
self.unix_fds = unix_fds
|
||||
self.buf = memoryview(buf)
|
||||
self.offset = 0
|
||||
self.fut = fut
|
||||
|
||||
if self.unix_fds and self.negotiate_unix_fd:
|
||||
ancdata = [
|
||||
(
|
||||
socket.SOL_SOCKET,
|
||||
socket.SCM_RIGHTS,
|
||||
array.array("i", self.unix_fds),
|
||||
)
|
||||
]
|
||||
self.offset += sock.sendmsg([self.buf[self.offset :]], ancdata)
|
||||
self.unix_fds = None
|
||||
else:
|
||||
self.offset += sock.send(self.buf[self.offset :])
|
||||
|
||||
if self.offset < len(self.buf):
|
||||
# wait for writable
|
||||
return
|
||||
|
||||
# finished writing
|
||||
self.buf = None
|
||||
_future_set_result(self.fut, None)
|
||||
except Exception as e:
|
||||
if self.bus._user_disconnect:
|
||||
_future_set_result(self.fut, None)
|
||||
else:
|
||||
_future_set_exception(self.fut, e)
|
||||
self.bus._finalize(e)
|
||||
|
||||
def buffer_message(
|
||||
self, msg: Message, future: Optional[asyncio.Future] = None
|
||||
) -> None:
|
||||
"""Buffer a message to be sent later."""
|
||||
unix_fds = msg.unix_fds
|
||||
self.messages.append(
|
||||
(
|
||||
msg._marshall(self.negotiate_unix_fd),
|
||||
copy(unix_fds) if unix_fds else None,
|
||||
future,
|
||||
)
|
||||
)
|
||||
|
||||
def _write_without_remove_writer(self) -> None:
|
||||
"""Call the write callback without removing the writer."""
|
||||
self.write_callback(remove_writer=False)
|
||||
|
||||
def schedule_write(
|
||||
self, msg: Optional[Message] = None, future: Optional[asyncio.Future] = None
|
||||
) -> None:
|
||||
"""Schedule a message to be written."""
|
||||
queue_is_empty = not self.messages
|
||||
if msg is not None:
|
||||
self.buffer_message(msg, future)
|
||||
|
||||
if self.bus.unique_name:
|
||||
# Optimization: try to send now if the queue
|
||||
# is empty. With bleak this usually means we
|
||||
# can send right away 99% of the time which
|
||||
# is a huge improvement in latency.
|
||||
if queue_is_empty:
|
||||
self._write_without_remove_writer()
|
||||
|
||||
if (
|
||||
self.buf is not None
|
||||
or self.messages
|
||||
or not self.fut
|
||||
or not self.fut.done()
|
||||
):
|
||||
self.loop.add_writer(self.fd, self.write_callback)
|
||||
|
||||
|
||||
class MessageBus(BaseMessageBus):
|
||||
"""The message bus implementation for use with asyncio.
|
||||
|
||||
The message bus class is the entry point into all the features of the
|
||||
library. It sets up a connection to the DBus daemon and exposes an
|
||||
interface to send and receive messages and expose services.
|
||||
|
||||
You must call :func:`connect() <dbus_fast.aio.MessageBus.connect>` before
|
||||
using this message bus.
|
||||
|
||||
:param bus_type: The type of bus to connect to. Affects the search path for
|
||||
the bus address.
|
||||
:type bus_type: :class:`BusType <dbus_fast.BusType>`
|
||||
:param bus_address: A specific bus address to connect to. Should not be
|
||||
used under normal circumstances.
|
||||
:param auth: The authenticator to use, defaults to an instance of
|
||||
:class:`AuthExternal <dbus_fast.auth.AuthExternal>`.
|
||||
:type auth: :class:`Authenticator <dbus_fast.auth.Authenticator>`
|
||||
:param negotiate_unix_fd: Allow the bus to send and receive Unix file
|
||||
descriptors (DBus type 'h'). This must be supported by the transport.
|
||||
:type negotiate_unix_fd: bool
|
||||
|
||||
:ivar unique_name: The unique name of the message bus connection. It will
|
||||
be :class:`None` until the message bus connects.
|
||||
:vartype unique_name: str
|
||||
:ivar connected: True if this message bus is expected to be able to send
|
||||
and receive messages.
|
||||
:vartype connected: bool
|
||||
"""
|
||||
|
||||
__slots__ = ("_loop", "_auth", "_writer", "_disconnect_future", "_pending_futures")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_address: Optional[str] = None,
|
||||
bus_type: BusType = BusType.SESSION,
|
||||
auth: Optional[Authenticator] = None,
|
||||
negotiate_unix_fd: bool = False,
|
||||
) -> None:
|
||||
super().__init__(bus_address, bus_type, ProxyObject, negotiate_unix_fd)
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
self._writer = _MessageWriter(self)
|
||||
|
||||
if auth is None:
|
||||
self._auth = AuthExternal()
|
||||
else:
|
||||
self._auth = auth
|
||||
|
||||
self._disconnect_future = self._loop.create_future()
|
||||
self._pending_futures: Set[asyncio.Future] = set()
|
||||
|
||||
async def connect(self) -> "MessageBus":
|
||||
"""Connect this message bus to the DBus daemon.
|
||||
|
||||
This method must be called before the message bus can be used.
|
||||
|
||||
:returns: This message bus for convenience.
|
||||
:rtype: :class:`MessageBus <dbus_fast.aio.MessageBus>`
|
||||
|
||||
:raises:
|
||||
- :class:`AuthError <dbus_fast.AuthError>` - If authorization to \
|
||||
the DBus daemon failed.
|
||||
- :class:`Exception` - If there was a connection error.
|
||||
"""
|
||||
await self._authenticate()
|
||||
|
||||
future = self._loop.create_future()
|
||||
|
||||
self._loop.add_reader(
|
||||
self._fd,
|
||||
build_message_reader(
|
||||
self._sock,
|
||||
self._process_message,
|
||||
self._finalize,
|
||||
self._negotiate_unix_fd,
|
||||
),
|
||||
)
|
||||
|
||||
def on_hello(reply, err):
|
||||
try:
|
||||
if err:
|
||||
raise err
|
||||
self.unique_name = reply.body[0]
|
||||
self._writer.schedule_write()
|
||||
_future_set_result(future, self)
|
||||
except Exception as e:
|
||||
_future_set_exception(future, e)
|
||||
self.disconnect()
|
||||
self._finalize(err)
|
||||
|
||||
next_serial = self.next_serial()
|
||||
self._method_return_handlers[next_serial] = on_hello
|
||||
if next_serial == 1:
|
||||
serialized = HELLO_1_SERIALIZED
|
||||
else:
|
||||
serialized = _generate_hello_serialized(next_serial)
|
||||
self._stream.write(serialized)
|
||||
self._stream.flush()
|
||||
return await future
|
||||
|
||||
async def introspect(
|
||||
self, bus_name: str, path: str, timeout: float = 30.0
|
||||
) -> intr.Node:
|
||||
"""Get introspection data for the node at the given path from the given
|
||||
bus name.
|
||||
|
||||
Calls the standard ``org.freedesktop.DBus.Introspectable.Introspect``
|
||||
on the bus for the path.
|
||||
|
||||
:param bus_name: The name to introspect.
|
||||
:type bus_name: str
|
||||
:param path: The path to introspect.
|
||||
:type path: str
|
||||
:param timeout: The timeout to introspect.
|
||||
:type timeout: float
|
||||
|
||||
:returns: The introspection data for the name at the path.
|
||||
:rtype: :class:`Node <dbus_fast.introspection.Node>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidObjectPathError <dbus_fast.InvalidObjectPathError>` \
|
||||
- If the given object path is not valid.
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If \
|
||||
the given bus name is not valid.
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
- :class:`asyncio.TimeoutError` - Waited for future but time run out.
|
||||
"""
|
||||
future = self._loop.create_future()
|
||||
|
||||
super().introspect(
|
||||
bus_name,
|
||||
path,
|
||||
partial(self._reply_handler, future),
|
||||
check_callback_type=False,
|
||||
)
|
||||
|
||||
timer_handle = self._loop.call_later(
|
||||
timeout, _future_set_exception, future, asyncio.TimeoutError
|
||||
)
|
||||
try:
|
||||
return await future
|
||||
finally:
|
||||
timer_handle.cancel()
|
||||
|
||||
async def request_name(
|
||||
self, name: str, flags: NameFlag = NameFlag.NONE
|
||||
) -> RequestNameReply:
|
||||
"""Request that this message bus owns the given name.
|
||||
|
||||
:param name: The name to request.
|
||||
:type name: str
|
||||
:param flags: Name flags that affect the behavior of the name request.
|
||||
:type flags: :class:`NameFlag <dbus_fast.NameFlag>`
|
||||
|
||||
:returns: The reply to the name request.
|
||||
:rtype: :class:`RequestNameReply <dbus_fast.RequestNameReply>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If \
|
||||
the given bus name is not valid.
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
future = self._loop.create_future()
|
||||
|
||||
super().request_name(
|
||||
name, flags, partial(self._reply_handler, future), check_callback_type=False
|
||||
)
|
||||
|
||||
return await future
|
||||
|
||||
async def release_name(self, name: str) -> ReleaseNameReply:
|
||||
"""Request that this message bus release the given name.
|
||||
|
||||
:param name: The name to release.
|
||||
:type name: str
|
||||
|
||||
:returns: The reply to the release request.
|
||||
:rtype: :class:`ReleaseNameReply <dbus_fast.ReleaseNameReply>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If \
|
||||
the given bus name is not valid.
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
future = self._loop.create_future()
|
||||
|
||||
super().release_name(
|
||||
name, partial(self._reply_handler, future), check_callback_type=False
|
||||
)
|
||||
|
||||
return await future
|
||||
|
||||
async def call(self, msg: Message) -> Optional[Message]:
|
||||
"""Send a method call and wait for a reply from the DBus daemon.
|
||||
|
||||
:param msg: The method call message to send.
|
||||
:type msg: :class:`Message <dbus_fast.Message>`
|
||||
|
||||
:returns: A message in reply to the message sent. If the message does
|
||||
not expect a reply based on the message flags or type, returns
|
||||
``None`` after the message is sent.
|
||||
:rtype: :class:`Message <dbus_fast.Message>` or :class:`None` if no reply is expected.
|
||||
|
||||
:raises:
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
if (
|
||||
msg.flags.value & NO_REPLY_EXPECTED_VALUE
|
||||
or msg.message_type is not MessageType.METHOD_CALL
|
||||
):
|
||||
await self.send(msg)
|
||||
return None
|
||||
|
||||
future = self._loop.create_future()
|
||||
|
||||
self._call(msg, partial(self._reply_handler, future))
|
||||
|
||||
await future
|
||||
|
||||
return future.result()
|
||||
|
||||
def send(self, msg: Message) -> asyncio.Future:
|
||||
"""Asynchronously send a message on the message bus.
|
||||
|
||||
.. note:: This method may change to a couroutine function in the 1.0
|
||||
release of the library.
|
||||
|
||||
:param msg: The message to send.
|
||||
:type msg: :class:`Message <dbus_fast.Message>`
|
||||
|
||||
:returns: A future that resolves when the message is sent or a
|
||||
connection error occurs.
|
||||
:rtype: :class:`Future <asyncio.Future>`
|
||||
"""
|
||||
if not msg.serial:
|
||||
msg.serial = self.next_serial()
|
||||
|
||||
future = self._loop.create_future()
|
||||
self._writer.schedule_write(msg, future)
|
||||
return future
|
||||
|
||||
def get_proxy_object(
|
||||
self, bus_name: str, path: str, introspection: intr.Node
|
||||
) -> ProxyObject:
|
||||
return super().get_proxy_object(bus_name, path, introspection)
|
||||
|
||||
async def wait_for_disconnect(self):
|
||||
"""Wait for the message bus to disconnect.
|
||||
|
||||
:returns: :class:`None` when the message bus has disconnected.
|
||||
:rtype: :class:`None`
|
||||
|
||||
:raises:
|
||||
- :class:`Exception` - If connection was terminated unexpectedly or \
|
||||
an internal error occurred in the library.
|
||||
"""
|
||||
return await self._disconnect_future
|
||||
|
||||
def _make_method_handler(self, interface, method):
|
||||
if not asyncio.iscoroutinefunction(method.fn):
|
||||
return super()._make_method_handler(interface, method)
|
||||
|
||||
negotiate_unix_fd = self._negotiate_unix_fd
|
||||
msg_body_to_args = ServiceInterface._msg_body_to_args
|
||||
fn_result_to_body = ServiceInterface._fn_result_to_body
|
||||
|
||||
def _coroutine_method_handler(
|
||||
msg: Message, send_reply: Callable[[Message], None]
|
||||
) -> None:
|
||||
"""A coroutine method handler."""
|
||||
args = msg_body_to_args(msg) if msg.unix_fds else msg.body
|
||||
fut = asyncio.ensure_future(method.fn(interface, *args))
|
||||
# Hold a strong reference to the future to ensure
|
||||
# it is not garbage collected before it is done.
|
||||
self._pending_futures.add(fut)
|
||||
if (
|
||||
send_reply is _block_unexpected_reply
|
||||
or msg.flags.value & NO_REPLY_EXPECTED_VALUE
|
||||
):
|
||||
fut.add_done_callback(self._pending_futures.discard)
|
||||
return
|
||||
|
||||
# We only create the closure function if we are actually going to reply
|
||||
def _done(fut: asyncio.Future) -> None:
|
||||
"""The callback for when the method is done."""
|
||||
with send_reply:
|
||||
result = fut.result()
|
||||
body, unix_fds = fn_result_to_body(
|
||||
result, method.out_signature_tree, replace_fds=negotiate_unix_fd
|
||||
)
|
||||
send_reply(
|
||||
Message.new_method_return(
|
||||
msg, method.out_signature, body, unix_fds
|
||||
)
|
||||
)
|
||||
|
||||
fut.add_done_callback(_done)
|
||||
# Discard the future only after running the done callback
|
||||
fut.add_done_callback(self._pending_futures.discard)
|
||||
|
||||
return _coroutine_method_handler
|
||||
|
||||
async def _auth_readline(self) -> str:
|
||||
buf = b""
|
||||
while buf[-2:] != b"\r\n":
|
||||
# The auth protocol is line based, so we can read until we get a
|
||||
# newline.
|
||||
buf += await self._loop.sock_recv(self._sock, 1024)
|
||||
return buf[:-2].decode()
|
||||
|
||||
async def _authenticate(self) -> None:
|
||||
await self._loop.sock_sendall(self._sock, b"\0")
|
||||
|
||||
first_line = self._auth._authentication_start(
|
||||
negotiate_unix_fd=self._negotiate_unix_fd
|
||||
)
|
||||
|
||||
if first_line is not None:
|
||||
if type(first_line) is not str:
|
||||
raise AuthError("authenticator gave response not type str")
|
||||
await self._loop.sock_sendall(
|
||||
self._sock, Authenticator._format_line(first_line)
|
||||
)
|
||||
|
||||
while True:
|
||||
response = self._auth._receive_line(await self._auth_readline())
|
||||
if response is not None:
|
||||
await self._loop.sock_sendall(
|
||||
self._sock, Authenticator._format_line(response)
|
||||
)
|
||||
self._stream.flush()
|
||||
if response == "BEGIN":
|
||||
# The first octet received by the server after the \r\n of the BEGIN command
|
||||
# from the client must be the first octet of the authenticated/encrypted stream
|
||||
# of D-Bus messages.
|
||||
break
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect the message bus by closing the underlying connection asynchronously.
|
||||
|
||||
All pending and future calls will error with a connection error.
|
||||
"""
|
||||
super().disconnect()
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
logging.warning("could not close socket", exc_info=True)
|
||||
|
||||
def _finalize(self, err: Optional[Exception] = None) -> None:
|
||||
try:
|
||||
self._loop.remove_reader(self._fd)
|
||||
except Exception:
|
||||
logging.warning("could not remove message reader", exc_info=True)
|
||||
try:
|
||||
self._loop.remove_writer(self._fd)
|
||||
except Exception:
|
||||
logging.warning("could not remove message writer", exc_info=True)
|
||||
|
||||
had_handlers = bool(self._method_return_handlers or self._user_message_handlers)
|
||||
|
||||
super()._finalize(err)
|
||||
|
||||
if self._disconnect_future.done():
|
||||
return
|
||||
|
||||
if err and not self._user_disconnect:
|
||||
_future_set_exception(self._disconnect_future, err)
|
||||
# If this happens during a reply, the message handlers
|
||||
# will have the exception set and wait_for_disconnect will
|
||||
# never be called so we need to manually set the exception
|
||||
# as retrieved to avoid asyncio warnings when the future
|
||||
# is garbage collected.
|
||||
if had_handlers:
|
||||
with contextlib.suppress(Exception):
|
||||
self._disconnect_future.exception()
|
||||
else:
|
||||
_future_set_result(self._disconnect_future, None)
|
||||
|
||||
def _reply_handler(
|
||||
self, future: asyncio.Future, reply: Optional[Any], err: Optional[Exception]
|
||||
) -> None:
|
||||
"""The reply handler for method calls."""
|
||||
if err:
|
||||
_future_set_exception(future, err)
|
||||
else:
|
||||
_future_set_result(future, reply)
|
13
dbus_fast/aio/message_reader.pxd
Normal file
13
dbus_fast/aio/message_reader.pxd
Normal file
|
@ -0,0 +1,13 @@
|
|||
"""cdefs for message_reader.py"""
|
||||
|
||||
import cython
|
||||
|
||||
from .._private.unmarshaller cimport Unmarshaller
|
||||
|
||||
|
||||
cpdef _message_reader(
|
||||
Unmarshaller unmarshaller,
|
||||
object process,
|
||||
object finalize,
|
||||
bint negotiate_unix_fd
|
||||
)
|
45
dbus_fast/aio/message_reader.py
Normal file
45
dbus_fast/aio/message_reader.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
import logging
|
||||
import socket
|
||||
from functools import partial
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .._private.unmarshaller import Unmarshaller
|
||||
from ..message import Message
|
||||
|
||||
|
||||
def _message_reader(
|
||||
unmarshaller: Unmarshaller,
|
||||
process: Callable[[Message], None],
|
||||
finalize: Callable[[Optional[Exception]], None],
|
||||
negotiate_unix_fd: bool,
|
||||
) -> None:
|
||||
"""Reads messages from the unmarshaller and passes them to the process function."""
|
||||
try:
|
||||
while True:
|
||||
message = unmarshaller._unmarshall()
|
||||
if message is None:
|
||||
return
|
||||
try:
|
||||
process(message)
|
||||
except Exception:
|
||||
logging.error("Unexpected error processing message: %s", exc_info=True)
|
||||
# If we are not negotiating unix fds, we can stop reading as soon as we have
|
||||
# the buffer is empty as asyncio will call us again when there is more data.
|
||||
if (
|
||||
not negotiate_unix_fd
|
||||
and not unmarshaller._has_another_message_in_buffer()
|
||||
):
|
||||
return
|
||||
except Exception as e:
|
||||
finalize(e)
|
||||
|
||||
|
||||
def build_message_reader(
|
||||
sock: Optional[socket.socket],
|
||||
process: Callable[[Message], None],
|
||||
finalize: Callable[[Optional[Exception]], None],
|
||||
negotiate_unix_fd: bool,
|
||||
) -> Callable[[], None]:
|
||||
"""Build a callable that reads messages from the unmarshaller and passes them to the process function."""
|
||||
unmarshaller = Unmarshaller(None, sock, negotiate_unix_fd)
|
||||
return partial(_message_reader, unmarshaller, process, finalize, negotiate_unix_fd)
|
205
dbus_fast/aio/proxy_object.py
Normal file
205
dbus_fast/aio/proxy_object.py
Normal file
|
@ -0,0 +1,205 @@
|
|||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING, Any, List, Union
|
||||
|
||||
from .. import introspection as intr
|
||||
from .._private.util import replace_fds_with_idx, replace_idx_with_fds
|
||||
from ..constants import ErrorType, MessageFlag
|
||||
from ..errors import DBusError
|
||||
from ..message import Message
|
||||
from ..message_bus import BaseMessageBus
|
||||
from ..proxy_object import BaseProxyInterface, BaseProxyObject
|
||||
from ..signature import Variant
|
||||
from ..unpack import unpack_variants as unpack
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message_bus import MessageBus as AioMessageBus
|
||||
|
||||
NO_REPLY_EXPECTED_VALUE = MessageFlag.NO_REPLY_EXPECTED.value
|
||||
|
||||
|
||||
class ProxyInterface(BaseProxyInterface):
|
||||
"""A class representing a proxy to an interface exported on the bus by
|
||||
another client for the asyncio :class:`MessageBus
|
||||
<dbus_fast.aio.MessageBus>` implementation.
|
||||
|
||||
This class is not meant to be constructed directly by the user. Use
|
||||
:func:`ProxyObject.get_interface()
|
||||
<dbus_fast.aio.ProxyObject.get_interface>` on a asyncio proxy object to get
|
||||
a proxy interface.
|
||||
|
||||
This class exposes methods to call DBus methods, listen to signals, and get
|
||||
and set properties on the interface that are created dynamically based on
|
||||
the introspection data passed to the proxy object that made this proxy
|
||||
interface.
|
||||
|
||||
A *method call* takes this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
result = await interface.call_[METHOD](*args)
|
||||
|
||||
Where ``METHOD`` is the name of the method converted to snake case.
|
||||
|
||||
DBus methods are exposed as coroutines that take arguments that correpond
|
||||
to the *in args* of the interface method definition and return a ``result``
|
||||
that corresponds to the *out arg*. If the method has more than one out arg,
|
||||
they are returned within a :class:`list`.
|
||||
|
||||
To *listen to a signal* use this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
interface.on_[SIGNAL](callback)
|
||||
|
||||
To *stop listening to a signal* use this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
interface.off_[SIGNAL](callback)
|
||||
|
||||
Where ``SIGNAL`` is the name of the signal converted to snake case.
|
||||
|
||||
DBus signals are exposed with an event-callback interface. The provided
|
||||
``callback`` will be called when the signal is emitted with arguments that
|
||||
correspond to the *out args* of the interface signal definition.
|
||||
|
||||
To *get or set a property* use this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
value = await interface.get_[PROPERTY]()
|
||||
await interface.set_[PROPERTY](value)
|
||||
|
||||
Where ``PROPERTY`` is the name of the property converted to snake case.
|
||||
|
||||
DBus property getters and setters are exposed as coroutines. The ``value``
|
||||
must correspond to the type of the property in the interface definition.
|
||||
|
||||
If the service returns an error for a DBus call, a :class:`DBusError
|
||||
<dbus_fast.DBusError>` will be raised with information about the error.
|
||||
"""
|
||||
|
||||
bus: "AioMessageBus"
|
||||
|
||||
def _add_method(self, intr_method: intr.Method) -> None:
|
||||
async def method_fn(
|
||||
*args, flags=MessageFlag.NONE, unpack_variants: bool = False
|
||||
):
|
||||
input_body, unix_fds = replace_fds_with_idx(
|
||||
intr_method.in_signature, list(args)
|
||||
)
|
||||
|
||||
msg = await self.bus.call(
|
||||
Message(
|
||||
destination=self.bus_name,
|
||||
path=self.path,
|
||||
interface=self.introspection.name,
|
||||
member=intr_method.name,
|
||||
signature=intr_method.in_signature,
|
||||
body=input_body,
|
||||
flags=flags,
|
||||
unix_fds=unix_fds,
|
||||
)
|
||||
)
|
||||
|
||||
if flags is not None and flags.value & NO_REPLY_EXPECTED_VALUE:
|
||||
return None
|
||||
|
||||
BaseProxyInterface._check_method_return(msg, intr_method.out_signature)
|
||||
|
||||
out_len = len(intr_method.out_args)
|
||||
|
||||
body = replace_idx_with_fds(msg.signature_tree, msg.body, msg.unix_fds)
|
||||
|
||||
if not out_len:
|
||||
return None
|
||||
|
||||
if unpack_variants:
|
||||
body = unpack(body)
|
||||
|
||||
if out_len == 1:
|
||||
return body[0]
|
||||
return body
|
||||
|
||||
method_name = f"call_{BaseProxyInterface._to_snake_case(intr_method.name)}"
|
||||
setattr(self, method_name, method_fn)
|
||||
|
||||
def _add_property(
|
||||
self,
|
||||
intr_property: intr.Property,
|
||||
) -> None:
|
||||
async def property_getter(
|
||||
*, flags=MessageFlag.NONE, unpack_variants: bool = False
|
||||
):
|
||||
msg = await self.bus.call(
|
||||
Message(
|
||||
destination=self.bus_name,
|
||||
path=self.path,
|
||||
interface="org.freedesktop.DBus.Properties",
|
||||
member="Get",
|
||||
signature="ss",
|
||||
body=[self.introspection.name, intr_property.name],
|
||||
)
|
||||
)
|
||||
|
||||
BaseProxyInterface._check_method_return(msg, "v")
|
||||
variant = msg.body[0]
|
||||
if variant.signature != intr_property.signature:
|
||||
raise DBusError(
|
||||
ErrorType.CLIENT_ERROR,
|
||||
f'property returned unexpected signature "{variant.signature}"',
|
||||
msg,
|
||||
)
|
||||
|
||||
body = replace_idx_with_fds("v", msg.body, msg.unix_fds)[0].value
|
||||
|
||||
if unpack_variants:
|
||||
return unpack(body)
|
||||
return body
|
||||
|
||||
async def property_setter(val: Any) -> None:
|
||||
variant = Variant(intr_property.signature, val)
|
||||
|
||||
body, unix_fds = replace_fds_with_idx(
|
||||
"ssv", [self.introspection.name, intr_property.name, variant]
|
||||
)
|
||||
|
||||
msg = await self.bus.call(
|
||||
Message(
|
||||
destination=self.bus_name,
|
||||
path=self.path,
|
||||
interface="org.freedesktop.DBus.Properties",
|
||||
member="Set",
|
||||
signature="ssv",
|
||||
body=body,
|
||||
unix_fds=unix_fds,
|
||||
)
|
||||
)
|
||||
|
||||
BaseProxyInterface._check_method_return(msg)
|
||||
|
||||
snake_case = BaseProxyInterface._to_snake_case(intr_property.name)
|
||||
setattr(self, f"get_{snake_case}", property_getter)
|
||||
setattr(self, f"set_{snake_case}", property_setter)
|
||||
|
||||
|
||||
class ProxyObject(BaseProxyObject):
|
||||
"""The proxy object implementation for the GLib :class:`MessageBus <dbus_fast.glib.MessageBus>`.
|
||||
|
||||
For more information, see the :class:`BaseProxyObject <dbus_fast.proxy_object.BaseProxyObject>`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_name: str,
|
||||
path: str,
|
||||
introspection: Union[intr.Node, str, ET.Element],
|
||||
bus: BaseMessageBus,
|
||||
) -> None:
|
||||
super().__init__(bus_name, path, introspection, bus, ProxyInterface)
|
||||
|
||||
def get_interface(self, name: str) -> ProxyInterface:
|
||||
return super().get_interface(name)
|
||||
|
||||
def get_children(self) -> List["ProxyObject"]:
|
||||
return super().get_children()
|
127
dbus_fast/auth.py
Normal file
127
dbus_fast/auth.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
import enum
|
||||
import os
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .errors import AuthError
|
||||
|
||||
UID_NOT_SPECIFIED = -1
|
||||
|
||||
# The auth interface here is unstable. I would like to eventually open this up
|
||||
# for people to define their own custom authentication protocols, but I'm not
|
||||
# familiar with what's needed for that exactly. To work with any message bus
|
||||
# implementation would require abstracting out all the IO. Async operations
|
||||
# might be challenging because different IO backends have different ways of
|
||||
# doing that. I might just end up giving the raw socket and leaving it all up
|
||||
# to the user, but it would be nice to have a little guidance in the interface
|
||||
# since a lot of it is strongly specified. If you have a need for this, contact
|
||||
# the project maintainer to help stabilize this interface.
|
||||
|
||||
|
||||
class _AuthResponse(enum.Enum):
|
||||
OK = "OK"
|
||||
REJECTED = "REJECTED"
|
||||
DATA = "DATA"
|
||||
ERROR = "ERROR"
|
||||
AGREE_UNIX_FD = "AGREE_UNIX_FD"
|
||||
|
||||
@classmethod
|
||||
def parse(klass, line: str) -> Tuple["_AuthResponse", List[str]]:
|
||||
args = line.split(" ")
|
||||
response = klass(args[0])
|
||||
return response, args[1:]
|
||||
|
||||
|
||||
# UNSTABLE
|
||||
class Authenticator:
|
||||
"""The base class for authenticators for :class:`MessageBus <dbus_fast.message_bus.BaseMessageBus>` authentication.
|
||||
|
||||
In the future, the library may allow extending this class for custom authentication protocols.
|
||||
|
||||
:seealso: https://dbus.freedesktop.org/doc/dbus-specification.html#auth-protocol
|
||||
"""
|
||||
|
||||
def _authentication_start(self, negotiate_unix_fd: bool = False) -> str:
|
||||
raise NotImplementedError(
|
||||
"authentication_start() must be implemented in the inheriting class"
|
||||
)
|
||||
|
||||
def _receive_line(self, line: str) -> str:
|
||||
raise NotImplementedError(
|
||||
"receive_line() must be implemented in the inheriting class"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_line(line: str) -> bytes:
|
||||
return f"{line}\r\n".encode()
|
||||
|
||||
|
||||
class AuthExternal(Authenticator):
|
||||
"""An authenticator class for the external auth protocol for use with the
|
||||
:class:`MessageBus <dbus_fast.message_bus.BaseMessageBus>`.
|
||||
|
||||
:param uid: The uid to use when connecting to the message bus. Use UID_NOT_SPECIFIED to use the uid known to the kernel.
|
||||
:vartype uid: int
|
||||
|
||||
:sealso: https://dbus.freedesktop.org/doc/dbus-specification.html#auth-protocol
|
||||
"""
|
||||
|
||||
def __init__(self, uid: Optional[int] = None) -> None:
|
||||
self.negotiate_unix_fd: bool = False
|
||||
self.negotiating_fds: bool = False
|
||||
self.uid: Optional[int] = uid
|
||||
|
||||
def _authentication_start(self, negotiate_unix_fd: bool = False) -> str:
|
||||
self.negotiate_unix_fd = negotiate_unix_fd
|
||||
uid = self.uid
|
||||
if uid == UID_NOT_SPECIFIED:
|
||||
return "AUTH EXTERNAL"
|
||||
if uid is None:
|
||||
uid = os.getuid()
|
||||
hex_uid = str(uid).encode().hex()
|
||||
return f"AUTH EXTERNAL {hex_uid}"
|
||||
|
||||
def _receive_line(self, line: str) -> str:
|
||||
response, args = _AuthResponse.parse(line)
|
||||
|
||||
if response is _AuthResponse.OK:
|
||||
if self.negotiate_unix_fd:
|
||||
self.negotiating_fds = True
|
||||
return "NEGOTIATE_UNIX_FD"
|
||||
else:
|
||||
return "BEGIN"
|
||||
|
||||
if response is _AuthResponse.AGREE_UNIX_FD:
|
||||
return "BEGIN"
|
||||
|
||||
if response is _AuthResponse.DATA and self.uid == UID_NOT_SPECIFIED:
|
||||
return "DATA"
|
||||
|
||||
raise AuthError(f"authentication failed: {response.value}: {args}")
|
||||
|
||||
|
||||
class AuthAnonymous(Authenticator):
|
||||
"""An authenticator class for the anonymous auth protocol for use with the
|
||||
:class:`MessageBus <dbus_fast.message_bus.BaseMessageBus>`.
|
||||
|
||||
:sealso: https://dbus.freedesktop.org/doc/dbus-specification.html#auth-protocol
|
||||
"""
|
||||
|
||||
def _authentication_start(self, negotiate_unix_fd: bool = False) -> str:
|
||||
if negotiate_unix_fd:
|
||||
raise AuthError(
|
||||
"anonymous authentication does not support negotiating unix fds right now"
|
||||
)
|
||||
|
||||
return "AUTH ANONYMOUS"
|
||||
|
||||
def _receive_line(self, line: str) -> str:
|
||||
response, args = _AuthResponse.parse(line)
|
||||
|
||||
if response != _AuthResponse.OK:
|
||||
raise AuthError(f"authentication failed: {response.value}: {args}")
|
||||
|
||||
return "BEGIN"
|
||||
|
||||
|
||||
# The following line provides backwards compatibility, remove at some point? --jrd
|
||||
AuthAnnonymous = AuthAnonymous
|
135
dbus_fast/constants.py
Normal file
135
dbus_fast/constants.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
from enum import Enum, IntFlag
|
||||
|
||||
|
||||
class BusType(Enum):
|
||||
"""An enum that indicates a type of bus. On most systems, there are
|
||||
normally two different kinds of buses running.
|
||||
"""
|
||||
|
||||
SESSION = 1 #: A bus for the current graphical user session.
|
||||
SYSTEM = 2 #: A persistent bus for the whole machine.
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""An enum that indicates a type of message."""
|
||||
|
||||
METHOD_CALL = 1 #: An outgoing method call.
|
||||
METHOD_RETURN = 2 #: A return to a previously sent method call
|
||||
ERROR = 3 #: A return to a method call that has failed
|
||||
SIGNAL = 4 #: A broadcast signal to subscribed connections
|
||||
|
||||
|
||||
MESSAGE_TYPE_MAP = {field.value: field for field in MessageType}
|
||||
|
||||
|
||||
class MessageFlag(IntFlag):
|
||||
"""Flags that affect the behavior of sent and received messages"""
|
||||
|
||||
NONE = 0
|
||||
NO_REPLY_EXPECTED = 1 #: The method call does not expect a method return.
|
||||
NO_AUTOSTART = 2
|
||||
ALLOW_INTERACTIVE_AUTHORIZATION = 4
|
||||
|
||||
|
||||
# This is written out because of https://github.com/python/cpython/issues/98976
|
||||
MESSAGE_FLAG_MAP = {
|
||||
0: MessageFlag.NONE,
|
||||
1: MessageFlag.NO_REPLY_EXPECTED,
|
||||
2: MessageFlag.NO_AUTOSTART,
|
||||
4: MessageFlag.ALLOW_INTERACTIVE_AUTHORIZATION,
|
||||
}
|
||||
|
||||
|
||||
class NameFlag(IntFlag):
|
||||
"""A flag that affects the behavior of a name request."""
|
||||
|
||||
NONE = 0
|
||||
ALLOW_REPLACEMENT = 1 #: If another client requests this name, let them have it.
|
||||
REPLACE_EXISTING = 2 #: If another client owns this name, try to take it.
|
||||
DO_NOT_QUEUE = 4 #: Name requests normally queue and wait for the owner to release the name. Do not enter this queue.
|
||||
|
||||
|
||||
class RequestNameReply(Enum):
|
||||
"""An enum that describes the result of a name request."""
|
||||
|
||||
PRIMARY_OWNER = 1 #: The bus owns the name.
|
||||
IN_QUEUE = 2 #: The bus is in a queue and may receive the name after it is relased by the primary owner.
|
||||
EXISTS = 3 #: The name has an owner and NameFlag.DO_NOT_QUEUE was given.
|
||||
ALREADY_OWNER = 4 #: The bus already owns the name.
|
||||
|
||||
|
||||
class ReleaseNameReply(Enum):
|
||||
"""An enum that describes the result of a name release request"""
|
||||
|
||||
RELEASED = 1
|
||||
NON_EXISTENT = 2
|
||||
NOT_OWNER = 3
|
||||
|
||||
|
||||
class PropertyAccess(Enum):
|
||||
"""An enum that describes whether a DBus property can be gotten or set with
|
||||
the ``org.freedesktop.DBus.Properties`` interface.
|
||||
"""
|
||||
|
||||
READ = "read" #: The property is readonly.
|
||||
WRITE = "write" #: The property is writeonly.
|
||||
READWRITE = "readwrite" #: The property can be read or written to.
|
||||
|
||||
def readable(self) -> bool:
|
||||
"""Get whether the property can be read."""
|
||||
return self == PropertyAccess.READ or self == PropertyAccess.READWRITE
|
||||
|
||||
def writable(self) -> bool:
|
||||
"""Get whether the property can be written to."""
|
||||
return self == PropertyAccess.WRITE or self == PropertyAccess.READWRITE
|
||||
|
||||
|
||||
class ArgDirection(Enum):
|
||||
"""For an introspected argument, indicates whether it is an input parameter or a return value."""
|
||||
|
||||
IN = "in"
|
||||
OUT = "out"
|
||||
|
||||
|
||||
class ErrorType(str, Enum):
|
||||
"""An enum for the type of an error for a message reply.
|
||||
|
||||
:seealso: http://man7.org/linux/man-pages/man3/sd-bus-errors.3.html
|
||||
"""
|
||||
|
||||
SERVICE_ERROR = "com.dubstepdish.dbus.next.ServiceError" #: A custom error to indicate an exported service threw an exception.
|
||||
INTERNAL_ERROR = "com.dubstepdish.dbus.next.InternalError" #: A custom error to indicate something went wrong with the library.
|
||||
CLIENT_ERROR = "com.dubstepdish.dbus.next.ClientError" #: A custom error to indicate something went wrong with the client.
|
||||
|
||||
FAILED = "org.freedesktop.DBus.Error.Failed"
|
||||
NO_MEMORY = "org.freedesktop.DBus.Error.NoMemory"
|
||||
SERVICE_UNKNOWN = "org.freedesktop.DBus.Error.ServiceUnknown"
|
||||
NAME_HAS_NO_OWNER = "org.freedesktop.DBus.Error.NameHasNoOwner"
|
||||
NO_REPLY = "org.freedesktop.DBus.Error.NoReply"
|
||||
IO_ERROR = "org.freedesktop.DBus.Error.IOError"
|
||||
BAD_ADDRESS = "org.freedesktop.DBus.Error.BadAddress"
|
||||
NOT_SUPPORTED = "org.freedesktop.DBus.Error.NotSupported"
|
||||
LIMITS_EXCEEDED = "org.freedesktop.DBus.Error.LimitsExceeded"
|
||||
ACCESS_DENIED = "org.freedesktop.DBus.Error.AccessDenied"
|
||||
AUTH_FAILED = "org.freedesktop.DBus.Error.AuthFailed"
|
||||
NO_SERVER = "org.freedesktop.DBus.Error.NoServer"
|
||||
TIMEOUT = "org.freedesktop.DBus.Error.Timeout"
|
||||
NO_NETWORK = "org.freedesktop.DBus.Error.NoNetwork"
|
||||
ADDRESS_IN_USE = "org.freedesktop.DBus.Error.AddressInUse"
|
||||
DISCONNECTED = "org.freedesktop.DBus.Error.Disconnected"
|
||||
INVALID_ARGS = "org.freedesktop.DBus.Error.InvalidArgs"
|
||||
FILE_NOT_FOUND = "org.freedesktop.DBus.Error.FileNotFound"
|
||||
FILE_EXISTS = "org.freedesktop.DBus.Error.FileExists"
|
||||
UNKNOWN_METHOD = "org.freedesktop.DBus.Error.UnknownMethod"
|
||||
UNKNOWN_OBJECT = "org.freedesktop.DBus.Error.UnknownObject"
|
||||
UNKNOWN_INTERFACE = "org.freedesktop.DBus.Error.UnknownInterface"
|
||||
UNKNOWN_PROPERTY = "org.freedesktop.DBus.Error.UnknownProperty"
|
||||
PROPERTY_READ_ONLY = "org.freedesktop.DBus.Error.PropertyReadOnly"
|
||||
UNIX_PROCESS_ID_UNKNOWN = "org.freedesktop.DBus.Error.UnixProcessIdUnknown"
|
||||
INVALID_SIGNATURE = "org.freedesktop.DBus.Error.InvalidSignature"
|
||||
INCONSISTENT_MESSAGE = "org.freedesktop.DBus.Error.InconsistentMessage"
|
||||
MATCH_RULE_NOT_FOUND = "org.freedesktop.DBus.Error.MatchRuleNotFound"
|
||||
MATCH_RULE_INVALID = "org.freedesktop.DBus.Error.MatchRuleInvalid"
|
||||
INTERACTIVE_AUTHORIZATION_REQUIRED = (
|
||||
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
|
||||
)
|
84
dbus_fast/errors.py
Normal file
84
dbus_fast/errors.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
from typing import Optional, Union
|
||||
|
||||
|
||||
class SignatureBodyMismatchError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSignatureError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAddressError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMessageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidIntrospectionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InterfaceNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SignalDisabledError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidBusNameError(TypeError):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(f"invalid bus name: {name}")
|
||||
|
||||
|
||||
class InvalidObjectPathError(TypeError):
|
||||
def __init__(self, path: str) -> None:
|
||||
super().__init__(f"invalid object path: {path}")
|
||||
|
||||
|
||||
class InvalidInterfaceNameError(TypeError):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(f"invalid interface name: {name}")
|
||||
|
||||
|
||||
class InvalidMemberNameError(TypeError):
|
||||
def __init__(self, member: str) -> None:
|
||||
super().__init__(f"invalid member name: {member}")
|
||||
|
||||
|
||||
from .constants import ErrorType, MessageType
|
||||
from .message import Message
|
||||
from .validators import assert_interface_name_valid
|
||||
|
||||
|
||||
class DBusError(Exception):
|
||||
def __init__(
|
||||
self, type_: Union[ErrorType, str], text: str, reply: Optional[Message] = None
|
||||
) -> None:
|
||||
super().__init__(text)
|
||||
|
||||
if type(type_) is ErrorType:
|
||||
type_ = type_.value
|
||||
|
||||
assert_interface_name_valid(type_) # type: ignore[arg-type]
|
||||
if reply is not None and type(reply) is not Message:
|
||||
raise TypeError("reply must be of type Message")
|
||||
|
||||
self.type = type_
|
||||
self.text = text
|
||||
self.reply = reply
|
||||
|
||||
@staticmethod
|
||||
def _from_message(msg: Message) -> "DBusError":
|
||||
assert msg.message_type == MessageType.ERROR
|
||||
return DBusError(msg.error_name or "unknown", msg.body[0], reply=msg)
|
||||
|
||||
def _as_message(self, msg: Message) -> Message:
|
||||
return Message.new_error(msg, self.type, self.text)
|
2
dbus_fast/glib/__init__.py
Normal file
2
dbus_fast/glib/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .message_bus import MessageBus
|
||||
from .proxy_object import ProxyInterface, ProxyObject
|
513
dbus_fast/glib/message_bus.py
Normal file
513
dbus_fast/glib/message_bus.py
Normal file
|
@ -0,0 +1,513 @@
|
|||
import io
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .. import introspection as intr
|
||||
from .._private.unmarshaller import Unmarshaller
|
||||
from ..auth import Authenticator, AuthExternal
|
||||
from ..constants import (
|
||||
BusType,
|
||||
MessageFlag,
|
||||
MessageType,
|
||||
NameFlag,
|
||||
ReleaseNameReply,
|
||||
RequestNameReply,
|
||||
)
|
||||
from ..errors import AuthError
|
||||
from ..message import Message
|
||||
from ..message_bus import BaseMessageBus
|
||||
from .proxy_object import ProxyObject
|
||||
|
||||
# glib is optional
|
||||
_import_error = None
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
|
||||
_GLibSource = GLib.Source
|
||||
except ImportError as e:
|
||||
_import_error = e
|
||||
|
||||
class _GLibSource:
|
||||
pass
|
||||
|
||||
|
||||
class _MessageSource(_GLibSource):
|
||||
def __init__(self, bus):
|
||||
self.unmarshaller = None
|
||||
self.bus = bus
|
||||
|
||||
def prepare(self):
|
||||
return (False, -1)
|
||||
|
||||
def check(self):
|
||||
return False
|
||||
|
||||
def dispatch(self, callback, user_data):
|
||||
try:
|
||||
while self.bus._stream.readable():
|
||||
if not self.unmarshaller:
|
||||
self.unmarshaller = Unmarshaller(self.bus._stream)
|
||||
|
||||
message = self.unmarshaller.unmarshall()
|
||||
if message:
|
||||
callback(message)
|
||||
self.unmarshaller = None
|
||||
else:
|
||||
break
|
||||
except Exception as e:
|
||||
self.bus.disconnect()
|
||||
self.bus._finalize(e)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
|
||||
class _MessageWritableSource(_GLibSource):
|
||||
def __init__(self, bus):
|
||||
self.bus = bus
|
||||
self.buf = b""
|
||||
self.message_stream = None
|
||||
self.chunk_size = 128
|
||||
|
||||
def prepare(self):
|
||||
return (False, -1)
|
||||
|
||||
def check(self):
|
||||
return False
|
||||
|
||||
def dispatch(self, callback, user_data):
|
||||
try:
|
||||
if self.buf:
|
||||
self.bus._stream.write(self.buf)
|
||||
self.buf = b""
|
||||
|
||||
if self.message_stream:
|
||||
while True:
|
||||
self.buf = self.message_stream.read(self.chunk_size)
|
||||
if self.buf == b"":
|
||||
break
|
||||
self.bus._stream.write(self.buf)
|
||||
if len(self.buf) < self.chunk_size:
|
||||
self.buf = b""
|
||||
break
|
||||
self.buf = b""
|
||||
|
||||
self.bus._stream.flush()
|
||||
|
||||
if not self.bus._buffered_messages:
|
||||
return GLib.SOURCE_REMOVE
|
||||
else:
|
||||
message = self.bus._buffered_messages.pop(0)
|
||||
self.message_stream = io.BytesIO(message._marshall(False))
|
||||
return GLib.SOURCE_CONTINUE
|
||||
except BlockingIOError:
|
||||
return GLib.SOURCE_CONTINUE
|
||||
except Exception as e:
|
||||
self.bus._finalize(e)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
|
||||
class _AuthLineSource(_GLibSource):
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
self.buf = b""
|
||||
|
||||
def prepare(self):
|
||||
return (False, -1)
|
||||
|
||||
def check(self):
|
||||
return False
|
||||
|
||||
def dispatch(self, callback, user_data):
|
||||
self.buf += self.stream.read()
|
||||
if self.buf[-2:] == b"\r\n":
|
||||
resp = callback(self.buf.decode()[:-2])
|
||||
if resp:
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
|
||||
class MessageBus(BaseMessageBus):
|
||||
"""The message bus implementation for use with the GLib main loop.
|
||||
|
||||
The message bus class is the entry point into all the features of the
|
||||
library. It sets up a connection to the DBus daemon and exposes an
|
||||
interface to send and receive messages and expose services.
|
||||
|
||||
You must call :func:`connect() <dbus_fast.glib.MessageBus.connect>` or
|
||||
:func:`connect_sync() <dbus_fast.glib.MessageBus.connect_sync>` before
|
||||
using this message bus.
|
||||
|
||||
:param bus_type: The type of bus to connect to. Affects the search path for
|
||||
the bus address.
|
||||
:type bus_type: :class:`BusType <dbus_fast.BusType>`
|
||||
:param bus_address: A specific bus address to connect to. Should not be
|
||||
used under normal circumstances.
|
||||
:param auth: The authenticator to use, defaults to an instance of
|
||||
:class:`AuthExternal <dbus_fast.auth.AuthExternal>`.
|
||||
:type auth: :class:`Authenticator <dbus_fast.auth.Authenticator>`
|
||||
|
||||
:ivar connected: True if this message bus is expected to be able to send
|
||||
and receive messages.
|
||||
:vartype connected: bool
|
||||
:ivar unique_name: The unique name of the message bus connection. It will
|
||||
be :class:`None` until the message bus connects.
|
||||
:vartype unique_name: str
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_address: Optional[str] = None,
|
||||
bus_type: BusType = BusType.SESSION,
|
||||
auth: Optional[Authenticator] = None,
|
||||
):
|
||||
if _import_error:
|
||||
raise _import_error
|
||||
|
||||
super().__init__(bus_address, bus_type, ProxyObject)
|
||||
self._main_context = GLib.main_context_default()
|
||||
# buffer messages until connect
|
||||
self._buffered_messages = []
|
||||
|
||||
if auth is None:
|
||||
self._auth = AuthExternal()
|
||||
else:
|
||||
self._auth = auth
|
||||
|
||||
def _on_message(self, msg: Message) -> None:
|
||||
try:
|
||||
self._process_message(msg)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"got unexpected error processing a message: {e}.\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def connect(
|
||||
self,
|
||||
connect_notify: Optional[
|
||||
Callable[["MessageBus", Optional[Exception]], None]
|
||||
] = None,
|
||||
):
|
||||
"""Connect this message bus to the DBus daemon.
|
||||
|
||||
This method or the synchronous version must be called before the
|
||||
message bus can be used.
|
||||
|
||||
:param connect_notify: A callback that will be called with this message
|
||||
bus. May return an :class:`Exception` on connection errors or
|
||||
:class:`AuthError <dbus_fast.AuthError>` on authorization errors.
|
||||
:type callback: :class:`Callable`
|
||||
"""
|
||||
|
||||
def authenticate_notify(exc):
|
||||
if exc is not None:
|
||||
if connect_notify is not None:
|
||||
connect_notify(None, exc)
|
||||
return
|
||||
self.message_source = _MessageSource(self)
|
||||
self.message_source.set_callback(self._on_message)
|
||||
self.message_source.attach(self._main_context)
|
||||
|
||||
self.writable_source = None
|
||||
|
||||
self.message_source.add_unix_fd(self._fd, GLib.IO_IN)
|
||||
|
||||
def on_hello(reply, err):
|
||||
if err:
|
||||
if connect_notify:
|
||||
connect_notify(reply, err)
|
||||
return
|
||||
|
||||
self.unique_name = reply.body[0]
|
||||
|
||||
for m in self._buffered_messages:
|
||||
self.send(m)
|
||||
|
||||
if connect_notify:
|
||||
connect_notify(self, err)
|
||||
|
||||
hello_msg = Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
member="Hello",
|
||||
serial=self.next_serial(),
|
||||
)
|
||||
|
||||
self._method_return_handlers[hello_msg.serial] = on_hello
|
||||
self._stream.write(hello_msg._marshall(False))
|
||||
self._stream.flush()
|
||||
|
||||
self._authenticate(authenticate_notify)
|
||||
|
||||
def connect_sync(self) -> "MessageBus":
|
||||
"""Connect this message bus to the DBus daemon.
|
||||
|
||||
This method or the asynchronous version must be called before the
|
||||
message bus can be used.
|
||||
|
||||
:returns: This message bus for convenience.
|
||||
:rtype: :class:`MessageBus <dbus_fast.glib.MessageBus>`
|
||||
|
||||
:raises:
|
||||
- :class:`AuthError <dbus_fast.AuthError>` - If authorization to \
|
||||
the DBus daemon failed.
|
||||
- :class:`Exception` - If there was a connection error.
|
||||
"""
|
||||
main = GLib.MainLoop()
|
||||
connection_error = None
|
||||
|
||||
def connect_notify(bus, err):
|
||||
nonlocal connection_error
|
||||
connection_error = err
|
||||
main.quit()
|
||||
|
||||
self.connect(connect_notify)
|
||||
main.run()
|
||||
|
||||
if connection_error:
|
||||
raise connection_error
|
||||
|
||||
return self
|
||||
|
||||
def call(
|
||||
self,
|
||||
msg: Message,
|
||||
reply_notify: Optional[
|
||||
Callable[[Optional[Message], Optional[Exception]], None]
|
||||
] = None,
|
||||
):
|
||||
"""Send a method call and asynchronously wait for a reply from the DBus
|
||||
daemon.
|
||||
|
||||
:param msg: The method call message to send.
|
||||
:type msg: :class:`Message <dbus_fast.Message>`
|
||||
:param reply_notify: A callback that will be called with the reply to
|
||||
this message. May return an :class:`Exception` on connection errors.
|
||||
:type reply_notify: Callable
|
||||
"""
|
||||
BaseMessageBus._check_callback_type(reply_notify)
|
||||
self._call(msg, reply_notify)
|
||||
|
||||
def call_sync(self, msg: Message) -> Optional[Message]:
|
||||
"""Send a method call and synchronously wait for a reply from the DBus
|
||||
daemon.
|
||||
|
||||
:param msg: The method call message to send.
|
||||
:type msg: :class:`Message <dbus_fast.Message>`
|
||||
|
||||
:returns: A message in reply to the message sent. If the message does
|
||||
not expect a reply based on the message flags or type, returns
|
||||
``None`` immediately.
|
||||
:rtype: :class:`Message <dbus_fast.Message>`
|
||||
|
||||
:raises:
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
if (
|
||||
msg.flags & MessageFlag.NO_REPLY_EXPECTED
|
||||
or msg.message_type is not MessageType.METHOD_CALL
|
||||
):
|
||||
self.send(msg)
|
||||
return None
|
||||
|
||||
if not msg.serial:
|
||||
msg.serial = self.next_serial()
|
||||
|
||||
main = GLib.MainLoop()
|
||||
handler_reply = None
|
||||
connection_error = None
|
||||
|
||||
def reply_handler(reply, err):
|
||||
nonlocal handler_reply
|
||||
nonlocal connection_error
|
||||
|
||||
handler_reply = reply
|
||||
connection_error = err
|
||||
|
||||
main.quit()
|
||||
|
||||
self._method_return_handlers[msg.serial] = reply_handler
|
||||
self.send(msg)
|
||||
main.run()
|
||||
|
||||
if connection_error:
|
||||
raise connection_error
|
||||
|
||||
return handler_reply
|
||||
|
||||
def introspect_sync(self, bus_name: str, path: str) -> intr.Node:
|
||||
"""Get introspection data for the node at the given path from the given
|
||||
bus name.
|
||||
|
||||
Calls the standard ``org.freedesktop.DBus.Introspectable.Introspect``
|
||||
on the bus for the path.
|
||||
|
||||
:param bus_name: The name to introspect.
|
||||
:type bus_name: str
|
||||
:param path: The path to introspect.
|
||||
:type path: str
|
||||
|
||||
:returns: The introspection data for the name at the path.
|
||||
:rtype: :class:`Node <dbus_fast.introspection.Node>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidObjectPathError <dbus_fast.InvalidObjectPathError>` \
|
||||
- If the given object path is not valid.
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If \
|
||||
the given bus name is not valid.
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
main = GLib.MainLoop()
|
||||
request_result = None
|
||||
request_error = None
|
||||
|
||||
def reply_notify(result, err):
|
||||
nonlocal request_result
|
||||
nonlocal request_error
|
||||
|
||||
request_result = result
|
||||
request_error = err
|
||||
|
||||
main.quit()
|
||||
|
||||
super().introspect(bus_name, path, reply_notify)
|
||||
main.run()
|
||||
|
||||
if request_error:
|
||||
raise request_error
|
||||
|
||||
return request_result
|
||||
|
||||
def request_name_sync(
|
||||
self, name: str, flags: NameFlag = NameFlag.NONE
|
||||
) -> RequestNameReply:
|
||||
"""Request that this message bus owns the given name.
|
||||
|
||||
:param name: The name to request.
|
||||
:type name: str
|
||||
:param flags: Name flags that affect the behavior of the name request.
|
||||
:type flags: :class:`NameFlag <dbus_fast.NameFlag>`
|
||||
|
||||
:returns: The reply to the name request.
|
||||
:rtype: :class:`RequestNameReply <dbus_fast.RequestNameReply>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If \
|
||||
the given bus name is not valid.
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
main = GLib.MainLoop()
|
||||
request_result = None
|
||||
request_error = None
|
||||
|
||||
def reply_notify(result, err):
|
||||
nonlocal request_result
|
||||
nonlocal request_error
|
||||
|
||||
request_result = result
|
||||
request_error = err
|
||||
|
||||
main.quit()
|
||||
|
||||
super().request_name(name, flags, reply_notify)
|
||||
main.run()
|
||||
|
||||
if request_error:
|
||||
raise request_error
|
||||
|
||||
return request_result
|
||||
|
||||
def release_name_sync(self, name: str) -> ReleaseNameReply:
|
||||
"""Request that this message bus release the given name.
|
||||
|
||||
:param name: The name to release.
|
||||
:type name: str
|
||||
|
||||
:returns: The reply to the release request.
|
||||
:rtype: :class:`ReleaseNameReply <dbus_fast.ReleaseNameReply>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If \
|
||||
the given bus name is not valid.
|
||||
- :class:`DBusError <dbus_fast.DBusError>` - If the service threw \
|
||||
an error for the method call or returned an invalid result.
|
||||
- :class:`Exception` - If a connection error occurred.
|
||||
"""
|
||||
main = GLib.MainLoop()
|
||||
release_result = None
|
||||
release_error = None
|
||||
|
||||
def reply_notify(result, err):
|
||||
nonlocal release_result
|
||||
nonlocal release_error
|
||||
|
||||
release_result = result
|
||||
release_error = err
|
||||
|
||||
main.quit()
|
||||
|
||||
super().release_name(name, reply_notify)
|
||||
main.run()
|
||||
|
||||
if release_error:
|
||||
raise release_error
|
||||
|
||||
return release_result
|
||||
|
||||
def send(self, msg: Message):
|
||||
if not msg.serial:
|
||||
msg.serial = self.next_serial()
|
||||
|
||||
self._buffered_messages.append(msg)
|
||||
|
||||
if self.unique_name:
|
||||
self._schedule_write()
|
||||
|
||||
def get_proxy_object(
|
||||
self, bus_name: str, path: str, introspection: intr.Node
|
||||
) -> ProxyObject:
|
||||
return super().get_proxy_object(bus_name, path, introspection)
|
||||
|
||||
def _schedule_write(self):
|
||||
if self.writable_source is None or self.writable_source.is_destroyed():
|
||||
self.writable_source = _MessageWritableSource(self)
|
||||
self.writable_source.attach(self._main_context)
|
||||
self.writable_source.add_unix_fd(self._fd, GLib.IO_OUT)
|
||||
|
||||
def _authenticate(self, authenticate_notify):
|
||||
self._stream.write(b"\0")
|
||||
first_line = self._auth._authentication_start()
|
||||
if first_line is not None:
|
||||
if type(first_line) is not str:
|
||||
raise AuthError("authenticator gave response not type str")
|
||||
self._stream.write(f"{first_line}\r\n".encode())
|
||||
self._stream.flush()
|
||||
|
||||
def line_notify(line):
|
||||
try:
|
||||
resp = self._auth._receive_line(line)
|
||||
self._stream.write(Authenticator._format_line(resp))
|
||||
self._stream.flush()
|
||||
if resp == "BEGIN":
|
||||
self._readline_source = None
|
||||
authenticate_notify(None)
|
||||
return True
|
||||
except Exception as e:
|
||||
authenticate_notify(e)
|
||||
return True
|
||||
|
||||
readline_source = _AuthLineSource(self._stream)
|
||||
readline_source.set_callback(line_notify)
|
||||
readline_source.add_unix_fd(self._fd, GLib.IO_IN)
|
||||
readline_source.attach(self._main_context)
|
||||
# make sure it doesnt get cleaned up
|
||||
self._readline_source = readline_source
|
320
dbus_fast/glib/proxy_object.py
Normal file
320
dbus_fast/glib/proxy_object.py
Normal file
|
@ -0,0 +1,320 @@
|
|||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Union
|
||||
|
||||
from .. import introspection as intr
|
||||
from ..constants import ErrorType
|
||||
from ..errors import DBusError
|
||||
from ..message import Message
|
||||
from ..message_bus import BaseMessageBus
|
||||
from ..proxy_object import BaseProxyInterface, BaseProxyObject
|
||||
from ..signature import Variant
|
||||
from ..unpack import unpack_variants as unpack
|
||||
|
||||
# glib is optional
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class ProxyInterface(BaseProxyInterface):
|
||||
"""A class representing a proxy to an interface exported on the bus by
|
||||
another client for the GLib :class:`MessageBus <dbus_fast.glib.MessageBus>`
|
||||
implementation.
|
||||
|
||||
This class is not meant to be constructed directly by the user. Use
|
||||
:func:`ProxyObject.get_interface()
|
||||
<dbus_fast.glib.ProxyObject.get_interface>` on a GLib proxy
|
||||
object to get a proxy interface.
|
||||
|
||||
This class exposes methods to call DBus methods, listen to signals, and get
|
||||
and set properties on the interface that are created dynamically based on
|
||||
the introspection data passed to the proxy object that made this proxy
|
||||
interface.
|
||||
|
||||
A *method call* takes this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
def callback(error: Exception, result: list(Any)):
|
||||
pass
|
||||
|
||||
interface.call_[METHOD](*args, callback)
|
||||
result = interface.call_[METHOD]_sync(*args)
|
||||
|
||||
Where ``METHOD`` is the name of the method converted to snake case.
|
||||
|
||||
To call a method, provide ``*args`` that correspond to the *in args* of the
|
||||
introspection method definition.
|
||||
|
||||
To *asynchronously* call a method, provide a callback that takes an error
|
||||
as the first argument and a list as the second argument. If the call
|
||||
completed successfully, ``error`` will be :class:`None`. If the service
|
||||
returns an error, it will be a :class:`DBusError <dbus_fast.DBusError>`
|
||||
with information about the error returned from the bus. The result will be
|
||||
a list of values that correspond to the *out args* of the introspection
|
||||
method definition.
|
||||
|
||||
To *synchronously* call a method, use the ``call_[METHOD]_sync()`` form.
|
||||
The ``result`` corresponds to the *out arg* of the introspection method
|
||||
definition. If the method has more than one otu arg, they are returned
|
||||
within a :class:`list`.
|
||||
|
||||
To *listen to a signal* use this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
interface.on_[SIGNAL](callback)
|
||||
|
||||
To *stop listening to a signal* use this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
interface.off_[SIGNAL](callback)
|
||||
|
||||
Where ``SIGNAL`` is the name of the signal converted to snake case.
|
||||
|
||||
DBus signals are exposed with an event-callback interface. The provided
|
||||
``callback`` will be called when the signal is emitted with arguments that
|
||||
correspond to the *out args* of the interface signal definition.
|
||||
|
||||
To *get or set a property* use this form:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
def get_callback(error: Exception, value: Any):
|
||||
pass
|
||||
|
||||
def set_callback(error: Exception)
|
||||
pass
|
||||
|
||||
interface.get_[PROPERTY](get_callback)
|
||||
value: Any = interface.get_[PROPERTY]_sync()
|
||||
|
||||
interface.set_[PROPERTY](set_callback)
|
||||
interface.set_[PROPERTY]_sync(value)
|
||||
|
||||
Where ``PROPERTY`` is the name of the property converted to snake case.
|
||||
|
||||
The ``value`` must correspond to the type of the property in the interface
|
||||
definition.
|
||||
|
||||
To asynchronously get or set a property, provide a callback that takes an
|
||||
:class:`Exception` as the first argument. If the call completed
|
||||
successfully, ``error`` will be :class:`None`. If the service returns an
|
||||
error, it will be a :class:`DBusError <dbus_fast.DBusError>` with
|
||||
information about the error returned from the bus.
|
||||
|
||||
If the service returns an error for a synchronous DBus call, a
|
||||
:class:`DBusError <dbus_fast.DBusError>` will be raised with information
|
||||
about the error.
|
||||
"""
|
||||
|
||||
def _add_method(self, intr_method):
|
||||
in_len = len(intr_method.in_args)
|
||||
out_len = len(intr_method.out_args)
|
||||
|
||||
def method_fn(*args, unpack_variants: bool = False):
|
||||
if len(args) != in_len + 1:
|
||||
raise TypeError(
|
||||
f"method {intr_method.name} expects {in_len} arguments and a callback (got {len(args)} args)"
|
||||
)
|
||||
|
||||
args = list(args)
|
||||
# TODO type check: this callback takes two parameters
|
||||
# (MessageBus.check_callback(cb))
|
||||
callback = args.pop()
|
||||
|
||||
def call_notify(msg, err):
|
||||
if err:
|
||||
callback([], err)
|
||||
return
|
||||
|
||||
try:
|
||||
BaseProxyInterface._check_method_return(
|
||||
msg, intr_method.out_signature
|
||||
)
|
||||
except DBusError as e:
|
||||
err = e
|
||||
|
||||
if unpack_variants:
|
||||
callback(unpack(msg.body), err)
|
||||
else:
|
||||
callback(msg.body, err)
|
||||
|
||||
self.bus.call(
|
||||
Message(
|
||||
destination=self.bus_name,
|
||||
path=self.path,
|
||||
interface=self.introspection.name,
|
||||
member=intr_method.name,
|
||||
signature=intr_method.in_signature,
|
||||
body=list(args),
|
||||
),
|
||||
call_notify,
|
||||
)
|
||||
|
||||
def method_fn_sync(*args, unpack_variants: bool = False):
|
||||
main = GLib.MainLoop()
|
||||
call_error = None
|
||||
call_body = None
|
||||
|
||||
def callback(body, err):
|
||||
nonlocal call_error
|
||||
nonlocal call_body
|
||||
call_error = err
|
||||
call_body = body
|
||||
main.quit()
|
||||
|
||||
method_fn(*args, callback)
|
||||
|
||||
main.run()
|
||||
|
||||
if call_error:
|
||||
raise call_error
|
||||
|
||||
if not out_len:
|
||||
return None
|
||||
|
||||
if unpack_variants:
|
||||
call_body = unpack(call_body)
|
||||
|
||||
if out_len == 1:
|
||||
return call_body[0]
|
||||
return call_body
|
||||
|
||||
method_name = f"call_{BaseProxyInterface._to_snake_case(intr_method.name)}"
|
||||
method_name_sync = f"{method_name}_sync"
|
||||
|
||||
setattr(self, method_name, method_fn)
|
||||
setattr(self, method_name_sync, method_fn_sync)
|
||||
|
||||
def _add_property(self, intr_property):
|
||||
def property_getter(callback, *, unpack_variants: bool = False):
|
||||
def call_notify(msg, err):
|
||||
if err:
|
||||
callback(None, err)
|
||||
return
|
||||
|
||||
try:
|
||||
BaseProxyInterface._check_method_return(msg)
|
||||
except Exception as e:
|
||||
callback(None, e)
|
||||
return
|
||||
|
||||
variant = msg.body[0]
|
||||
if variant.signature != intr_property.signature:
|
||||
err = DBusError(
|
||||
ErrorType.CLIENT_ERROR,
|
||||
'property returned unexpected signature "{variant.signature}"',
|
||||
msg,
|
||||
)
|
||||
callback(None, err)
|
||||
return
|
||||
if unpack_variants:
|
||||
callback(unpack(variant.value), None)
|
||||
else:
|
||||
callback(variant.value, None)
|
||||
|
||||
self.bus.call(
|
||||
Message(
|
||||
destination=self.bus_name,
|
||||
path=self.path,
|
||||
interface="org.freedesktop.DBus.Properties",
|
||||
member="Get",
|
||||
signature="ss",
|
||||
body=[self.introspection.name, intr_property.name],
|
||||
),
|
||||
call_notify,
|
||||
)
|
||||
|
||||
def property_getter_sync(*, unpack_variants: bool = False):
|
||||
property_value = None
|
||||
reply_error = None
|
||||
|
||||
main = GLib.MainLoop()
|
||||
|
||||
def callback(value, err):
|
||||
nonlocal property_value
|
||||
nonlocal reply_error
|
||||
property_value = value
|
||||
reply_error = err
|
||||
main.quit()
|
||||
|
||||
property_getter(callback)
|
||||
main.run()
|
||||
if reply_error:
|
||||
raise reply_error
|
||||
if unpack_variants:
|
||||
return unpack(property_value)
|
||||
return property_value
|
||||
|
||||
def property_setter(value, callback):
|
||||
def call_notify(msg, err):
|
||||
if err:
|
||||
callback(None, err)
|
||||
return
|
||||
try:
|
||||
BaseProxyInterface._check_method_return(msg)
|
||||
except Exception as e:
|
||||
callback(None, e)
|
||||
return
|
||||
|
||||
return callback(None, None)
|
||||
|
||||
variant = Variant(intr_property.signature, value)
|
||||
self.bus.call(
|
||||
Message(
|
||||
destination=self.bus_name,
|
||||
path=self.path,
|
||||
interface="org.freedesktop.DBus.Properties",
|
||||
member="Set",
|
||||
signature="ssv",
|
||||
body=[self.introspection.name, intr_property.name, variant],
|
||||
),
|
||||
call_notify,
|
||||
)
|
||||
|
||||
def property_setter_sync(val):
|
||||
reply_error = None
|
||||
|
||||
main = GLib.MainLoop()
|
||||
|
||||
def callback(value, err):
|
||||
nonlocal reply_error
|
||||
reply_error = err
|
||||
main.quit()
|
||||
|
||||
property_setter(val, callback)
|
||||
main.run()
|
||||
if reply_error:
|
||||
raise reply_error
|
||||
return None
|
||||
|
||||
snake_case = super()._to_snake_case(intr_property.name)
|
||||
setattr(self, f"get_{snake_case}", property_getter)
|
||||
setattr(self, f"get_{snake_case}_sync", property_getter_sync)
|
||||
setattr(self, f"set_{snake_case}", property_setter)
|
||||
setattr(self, f"set_{snake_case}_sync", property_setter_sync)
|
||||
|
||||
|
||||
class ProxyObject(BaseProxyObject):
|
||||
"""The proxy object implementation for the asyncio :class:`MessageBus <dbus_fast.aio.MessageBus>`.
|
||||
|
||||
For more information, see the :class:`BaseProxyObject <dbus_fast.proxy_object.BaseProxyObject>`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_name: str,
|
||||
path: str,
|
||||
introspection: Union[intr.Node, str, ET.Element],
|
||||
bus: BaseMessageBus,
|
||||
):
|
||||
super().__init__(bus_name, path, introspection, bus, ProxyInterface)
|
||||
|
||||
def get_interface(self, name: str) -> ProxyInterface:
|
||||
return super().get_interface(name)
|
||||
|
||||
def get_children(self) -> List["ProxyObject"]:
|
||||
return super().get_children()
|
597
dbus_fast/introspection.py
Normal file
597
dbus_fast/introspection.py
Normal file
|
@ -0,0 +1,597 @@
|
|||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from .constants import ArgDirection, PropertyAccess
|
||||
from .errors import InvalidIntrospectionError
|
||||
from .signature import SignatureType, get_signature_tree
|
||||
from .validators import assert_interface_name_valid, assert_member_name_valid
|
||||
|
||||
# https://dbus.freedesktop.org/doc/dbus-specification.html#introspection-format
|
||||
# TODO annotations
|
||||
|
||||
|
||||
class Arg:
|
||||
"""A class that represents an input or output argument to a signal or a method.
|
||||
|
||||
:ivar name: The name of this arg.
|
||||
:vartype name: str
|
||||
:ivar direction: Whether this is an input or an output argument.
|
||||
:vartype direction: :class:`ArgDirection <dbus_fast.ArgDirection>`
|
||||
:ivar type: The parsed signature type of this argument.
|
||||
:vartype type: :class:`SignatureType <dbus_fast.SignatureType>`
|
||||
:ivar signature: The signature string of this argument.
|
||||
:vartype signature: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidMemberNameError <dbus_fast.InvalidMemberNameError>` - If the name of the arg is not valid.
|
||||
- :class:`InvalidSignatureError <dbus_fast.InvalidSignatureError>` - If the signature is not valid.
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the signature is not a single complete type.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signature: Union[SignatureType, str],
|
||||
direction: Optional[List[ArgDirection]] = None,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
if name is not None:
|
||||
assert_member_name_valid(name)
|
||||
|
||||
type_ = None
|
||||
if type(signature) is SignatureType:
|
||||
type_ = signature
|
||||
signature = signature.signature
|
||||
else:
|
||||
tree = get_signature_tree(signature)
|
||||
if len(tree.types) != 1:
|
||||
raise InvalidIntrospectionError(
|
||||
f"an argument must have a single complete type. (has {len(tree.types)} types)"
|
||||
)
|
||||
type_ = tree.types[0]
|
||||
|
||||
self.type = type_
|
||||
self.signature = signature
|
||||
self.name = name
|
||||
self.direction = direction
|
||||
|
||||
def from_xml(element: ET.Element, direction: ArgDirection) -> "Arg":
|
||||
"""Convert a :class:`xml.etree.ElementTree.Element` into a
|
||||
:class:`Arg`.
|
||||
|
||||
The element must be valid DBus introspection XML for an ``arg``.
|
||||
|
||||
:param element: The parsed XML element.
|
||||
:type element: :class:`xml.etree.ElementTree.Element`
|
||||
:param direction: The direction of this arg. Must be specified because it can default to different values depending on if it's in a method or signal.
|
||||
:type direction: :class:`ArgDirection <dbus_fast.ArgDirection>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
|
||||
"""
|
||||
name = element.attrib.get("name")
|
||||
signature = element.attrib.get("type")
|
||||
|
||||
if not signature:
|
||||
raise InvalidIntrospectionError(
|
||||
'a method argument must have a "type" attribute'
|
||||
)
|
||||
|
||||
return Arg(signature, direction, name)
|
||||
|
||||
def to_xml(self) -> ET.Element:
|
||||
"""Convert this :class:`Arg` into an :class:`xml.etree.ElementTree.Element`."""
|
||||
element = ET.Element("arg")
|
||||
if self.name:
|
||||
element.set("name", self.name)
|
||||
|
||||
if self.direction:
|
||||
element.set("direction", self.direction.value)
|
||||
element.set("type", self.signature)
|
||||
|
||||
return element
|
||||
|
||||
|
||||
class Signal:
|
||||
"""A class that represents a signal exposed on an interface.
|
||||
|
||||
:ivar name: The name of this signal
|
||||
:vartype name: str
|
||||
:ivar args: A list of output arguments for this signal.
|
||||
:vartype args: list(Arg)
|
||||
:ivar signature: The collected signature of the output arguments.
|
||||
:vartype signature: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidMemberNameError <dbus_fast.InvalidMemberNameError>` - If the name of the signal is not a valid member name.
|
||||
"""
|
||||
|
||||
def __init__(self, name: Optional[str], args: Optional[List[Arg]] = None):
|
||||
if name is not None:
|
||||
assert_member_name_valid(name)
|
||||
|
||||
self.name = name
|
||||
self.args = args or []
|
||||
self.signature = "".join(arg.signature for arg in self.args)
|
||||
|
||||
def from_xml(element):
|
||||
"""Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Signal`.
|
||||
|
||||
The element must be valid DBus introspection XML for a ``signal``.
|
||||
|
||||
:param element: The parsed XML element.
|
||||
:type element: :class:`xml.etree.ElementTree.Element`
|
||||
:param is_root: Whether this is the root node
|
||||
:type is_root: bool
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
|
||||
"""
|
||||
name = element.attrib.get("name")
|
||||
if not name:
|
||||
raise InvalidIntrospectionError('signals must have a "name" attribute')
|
||||
|
||||
args = []
|
||||
for child in element:
|
||||
if child.tag == "arg":
|
||||
args.append(Arg.from_xml(child, ArgDirection.OUT))
|
||||
|
||||
signal = Signal(name, args)
|
||||
|
||||
return signal
|
||||
|
||||
def to_xml(self) -> ET.Element:
|
||||
"""Convert this :class:`Signal` into an :class:`xml.etree.ElementTree.Element`."""
|
||||
element = ET.Element("signal")
|
||||
element.set("name", self.name)
|
||||
|
||||
for arg in self.args:
|
||||
element.append(arg.to_xml())
|
||||
|
||||
return element
|
||||
|
||||
|
||||
class Method:
|
||||
"""A class that represents a method exposed on an :class:`Interface`.
|
||||
|
||||
:ivar name: The name of this method.
|
||||
:vartype name: str
|
||||
:ivar in_args: A list of input arguments to this method.
|
||||
:vartype in_args: list(Arg)
|
||||
:ivar out_args: A list of output arguments to this method.
|
||||
:vartype out_args: list(Arg)
|
||||
:ivar in_signature: The collected signature string of the input arguments.
|
||||
:vartype in_signature: str
|
||||
:ivar out_signature: The collected signature string of the output arguments.
|
||||
:vartype out_signature: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidMemberNameError <dbus_fast.InvalidMemberNameError>` - If the name of this method is not valid.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, in_args: List[Arg] = [], out_args: List[Arg] = []):
|
||||
assert_member_name_valid(name)
|
||||
|
||||
self.name = name
|
||||
self.in_args = in_args
|
||||
self.out_args = out_args
|
||||
self.in_signature = "".join(arg.signature for arg in in_args)
|
||||
self.out_signature = "".join(arg.signature for arg in out_args)
|
||||
|
||||
def from_xml(element: ET.Element) -> "Method":
|
||||
"""Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Method`.
|
||||
|
||||
The element must be valid DBus introspection XML for a ``method``.
|
||||
|
||||
:param element: The parsed XML element.
|
||||
:type element: :class:`xml.etree.ElementTree.Element`
|
||||
:param is_root: Whether this is the root node
|
||||
:type is_root: bool
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
|
||||
"""
|
||||
name = element.attrib.get("name")
|
||||
if not name:
|
||||
raise InvalidIntrospectionError('interfaces must have a "name" attribute')
|
||||
|
||||
in_args = []
|
||||
out_args = []
|
||||
|
||||
for child in element:
|
||||
if child.tag == "arg":
|
||||
direction = ArgDirection(child.attrib.get("direction", "in"))
|
||||
arg = Arg.from_xml(child, direction)
|
||||
if direction == ArgDirection.IN:
|
||||
in_args.append(arg)
|
||||
elif direction == ArgDirection.OUT:
|
||||
out_args.append(arg)
|
||||
|
||||
return Method(name, in_args, out_args)
|
||||
|
||||
def to_xml(self) -> ET.Element:
|
||||
"""Convert this :class:`Method` into an :class:`xml.etree.ElementTree.Element`."""
|
||||
element = ET.Element("method")
|
||||
element.set("name", self.name)
|
||||
|
||||
for arg in self.in_args:
|
||||
element.append(arg.to_xml())
|
||||
for arg in self.out_args:
|
||||
element.append(arg.to_xml())
|
||||
|
||||
return element
|
||||
|
||||
|
||||
class Property:
|
||||
"""A class that represents a DBus property exposed on an
|
||||
:class:`Interface`.
|
||||
|
||||
:ivar name: The name of this property.
|
||||
:vartype name: str
|
||||
:ivar signature: The signature string for this property. Must be a single complete type.
|
||||
:vartype signature: str
|
||||
:ivar access: Whether this property is readable and writable.
|
||||
:vartype access: :class:`PropertyAccess <dbus_fast.PropertyAccess>`
|
||||
:ivar type: The parsed type of this property.
|
||||
:vartype type: :class:`SignatureType <dbus_fast.SignatureType>`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the property is not a single complete type.
|
||||
- :class `InvalidSignatureError <dbus_fast.InvalidSignatureError>` - If the given signature is not valid.
|
||||
- :class: `InvalidMemberNameError <dbus_fast.InvalidMemberNameError>` - If the member name is not valid.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
signature: str,
|
||||
access: PropertyAccess = PropertyAccess.READWRITE,
|
||||
):
|
||||
assert_member_name_valid(name)
|
||||
|
||||
tree = get_signature_tree(signature)
|
||||
if len(tree.types) != 1:
|
||||
raise InvalidIntrospectionError(
|
||||
f"properties must have a single complete type. (has {len(tree.types)} types)"
|
||||
)
|
||||
|
||||
self.name = name
|
||||
self.signature = signature
|
||||
self.access = access
|
||||
self.type = tree.types[0]
|
||||
|
||||
def from_xml(element):
|
||||
"""Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Property`.
|
||||
|
||||
The element must be valid DBus introspection XML for a ``property``.
|
||||
|
||||
:param element: The parsed XML element.
|
||||
:type element: :class:`xml.etree.ElementTree.Element`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
|
||||
"""
|
||||
name = element.attrib.get("name")
|
||||
signature = element.attrib.get("type")
|
||||
access = PropertyAccess(element.attrib.get("access", "readwrite"))
|
||||
|
||||
if not name:
|
||||
raise InvalidIntrospectionError('properties must have a "name" attribute')
|
||||
if not signature:
|
||||
raise InvalidIntrospectionError('properties must have a "type" attribute')
|
||||
|
||||
return Property(name, signature, access)
|
||||
|
||||
def to_xml(self) -> ET.Element:
|
||||
"""Convert this :class:`Property` into an :class:`xml.etree.ElementTree.Element`."""
|
||||
element = ET.Element("property")
|
||||
element.set("name", self.name)
|
||||
element.set("type", self.signature)
|
||||
element.set("access", self.access.value)
|
||||
return element
|
||||
|
||||
|
||||
class Interface:
|
||||
"""A class that represents a DBus interface exported on on object path.
|
||||
|
||||
Contains information about the methods, signals, and properties exposed on
|
||||
this interface.
|
||||
|
||||
:ivar name: The name of this interface.
|
||||
:vartype name: str
|
||||
:ivar methods: A list of methods exposed on this interface.
|
||||
:vartype methods: list(:class:`Method`)
|
||||
:ivar signals: A list of signals exposed on this interface.
|
||||
:vartype signals: list(:class:`Signal`)
|
||||
:ivar properties: A list of properties exposed on this interface.
|
||||
:vartype properties: list(:class:`Property`)
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidInterfaceNameError <dbus_fast.InvalidInterfaceNameError>` - If the name is not a valid interface name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
methods: Optional[List[Method]] = None,
|
||||
signals: Optional[List[Signal]] = None,
|
||||
properties: Optional[List[Property]] = None,
|
||||
):
|
||||
assert_interface_name_valid(name)
|
||||
|
||||
self.name = name
|
||||
self.methods = methods if methods is not None else []
|
||||
self.signals = signals if signals is not None else []
|
||||
self.properties = properties if properties is not None else []
|
||||
|
||||
@staticmethod
|
||||
def from_xml(element: ET.Element) -> "Interface":
|
||||
"""Convert a :class:`xml.etree.ElementTree.Element` into a
|
||||
:class:`Interface`.
|
||||
|
||||
The element must be valid DBus introspection XML for an ``interface``.
|
||||
|
||||
:param element: The parsed XML element.
|
||||
:type element: :class:`xml.etree.ElementTree.Element`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
|
||||
"""
|
||||
name = element.attrib.get("name")
|
||||
if not name:
|
||||
raise InvalidIntrospectionError('interfaces must have a "name" attribute')
|
||||
|
||||
interface = Interface(name)
|
||||
|
||||
for child in element:
|
||||
if child.tag == "method":
|
||||
interface.methods.append(Method.from_xml(child))
|
||||
elif child.tag == "signal":
|
||||
interface.signals.append(Signal.from_xml(child))
|
||||
elif child.tag == "property":
|
||||
interface.properties.append(Property.from_xml(child))
|
||||
|
||||
return interface
|
||||
|
||||
def to_xml(self) -> ET.Element:
|
||||
"""Convert this :class:`Interface` into an :class:`xml.etree.ElementTree.Element`."""
|
||||
element = ET.Element("interface")
|
||||
element.set("name", self.name)
|
||||
|
||||
for method in self.methods:
|
||||
element.append(method.to_xml())
|
||||
for signal in self.signals:
|
||||
element.append(signal.to_xml())
|
||||
for prop in self.properties:
|
||||
element.append(prop.to_xml())
|
||||
|
||||
return element
|
||||
|
||||
|
||||
class Node:
|
||||
"""A class that represents a node in an object path in introspection data.
|
||||
|
||||
A node contains information about interfaces exported on this path and
|
||||
child nodes. A node can be converted to and from introspection XML exposed
|
||||
through the ``org.freedesktop.DBus.Introspectable`` standard DBus
|
||||
interface.
|
||||
|
||||
This class is an essential building block for a high-level DBus interface.
|
||||
This is the underlying data structure for the :class:`ProxyObject
|
||||
<dbus_fast.proxy_object.BaseProxyInterface>`. A :class:`ServiceInterface
|
||||
<dbus_fast.service.ServiceInterface>` definition is converted to this class
|
||||
to expose XML on the introspectable interface.
|
||||
|
||||
:ivar interfaces: A list of interfaces exposed on this node.
|
||||
:vartype interfaces: list(:class:`Interface <dbus_fast.introspection.Interface>`)
|
||||
:ivar nodes: A list of child nodes.
|
||||
:vartype nodes: list(:class:`Node`)
|
||||
:ivar name: The object path of this node.
|
||||
:vartype name: str
|
||||
:ivar is_root: Whether this is the root node. False if it is a child node.
|
||||
:vartype is_root: bool
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the name is not a valid node name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
interfaces: Optional[List[Interface]] = None,
|
||||
is_root: bool = True,
|
||||
):
|
||||
if not is_root and not name:
|
||||
raise InvalidIntrospectionError('child nodes must have a "name" attribute')
|
||||
|
||||
self.interfaces = interfaces if interfaces is not None else []
|
||||
self.nodes = []
|
||||
self.name = name
|
||||
self.is_root = is_root
|
||||
|
||||
@staticmethod
|
||||
def from_xml(element: ET.Element, is_root: bool = False):
|
||||
"""Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Node`.
|
||||
|
||||
The element must be valid DBus introspection XML for a ``node``.
|
||||
|
||||
:param element: The parsed XML element.
|
||||
:type element: :class:`xml.etree.ElementTree.Element`
|
||||
:param is_root: Whether this is the root node
|
||||
:type is_root: bool
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the XML tree is not valid introspection data.
|
||||
"""
|
||||
node = Node(element.attrib.get("name"), is_root=is_root)
|
||||
|
||||
for child in element:
|
||||
if child.tag == "interface":
|
||||
node.interfaces.append(Interface.from_xml(child))
|
||||
elif child.tag == "node":
|
||||
node.nodes.append(Node.from_xml(child))
|
||||
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def parse(data: str) -> "Node":
|
||||
"""Parse XML data as a string into a :class:`Node`.
|
||||
|
||||
The string must be valid DBus introspection XML.
|
||||
|
||||
:param data: The XMl string.
|
||||
:type data: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the string is not valid introspection data.
|
||||
"""
|
||||
element = ET.fromstring(data)
|
||||
if element.tag != "node":
|
||||
raise InvalidIntrospectionError(
|
||||
'introspection data must have a "node" for the root element'
|
||||
)
|
||||
|
||||
return Node.from_xml(element, is_root=True)
|
||||
|
||||
def to_xml(self) -> ET.Element:
|
||||
"""Convert this :class:`Node` into an :class:`xml.etree.ElementTree.Element`."""
|
||||
element = ET.Element("node")
|
||||
|
||||
if self.name:
|
||||
element.set("name", self.name)
|
||||
|
||||
for interface in self.interfaces:
|
||||
element.append(interface.to_xml())
|
||||
for node in self.nodes:
|
||||
element.append(node.to_xml())
|
||||
|
||||
return element
|
||||
|
||||
def tostring(self) -> str:
|
||||
"""Convert this :class:`Node` into a DBus introspection XML string."""
|
||||
header = '<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"\n"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">\n'
|
||||
|
||||
def indent(elem, level=0):
|
||||
i = "\n" + level * " "
|
||||
if len(elem):
|
||||
if not elem.text or not elem.text.strip():
|
||||
elem.text = i + " "
|
||||
if not elem.tail or not elem.tail.strip():
|
||||
elem.tail = i
|
||||
for elem in elem:
|
||||
indent(elem, level + 1)
|
||||
if not elem.tail or not elem.tail.strip():
|
||||
elem.tail = i
|
||||
else:
|
||||
if level and (not elem.tail or not elem.tail.strip()):
|
||||
elem.tail = i
|
||||
|
||||
xml = self.to_xml()
|
||||
indent(xml)
|
||||
return header + ET.tostring(xml, encoding="unicode").rstrip()
|
||||
|
||||
@staticmethod
|
||||
def default(name: Optional[str] = None) -> "Node":
|
||||
"""Create a :class:`Node` with the default interfaces supported by this library.
|
||||
|
||||
The default interfaces include:
|
||||
|
||||
* ``org.freedesktop.DBus.Introspectable``
|
||||
* ``org.freedesktop.DBus.Peer``
|
||||
* ``org.freedesktop.DBus.Properties``
|
||||
* ``org.freedesktop.DBus.ObjectManager``
|
||||
"""
|
||||
return Node(
|
||||
name,
|
||||
is_root=True,
|
||||
interfaces=[
|
||||
Interface(
|
||||
"org.freedesktop.DBus.Introspectable",
|
||||
methods=[
|
||||
Method(
|
||||
"Introspect", out_args=[Arg("s", ArgDirection.OUT, "data")]
|
||||
)
|
||||
],
|
||||
),
|
||||
Interface(
|
||||
"org.freedesktop.DBus.Peer",
|
||||
methods=[
|
||||
Method(
|
||||
"GetMachineId",
|
||||
out_args=[Arg("s", ArgDirection.OUT, "machine_uuid")],
|
||||
),
|
||||
Method("Ping"),
|
||||
],
|
||||
),
|
||||
Interface(
|
||||
"org.freedesktop.DBus.Properties",
|
||||
methods=[
|
||||
Method(
|
||||
"Get",
|
||||
in_args=[
|
||||
Arg("s", ArgDirection.IN, "interface_name"),
|
||||
Arg("s", ArgDirection.IN, "property_name"),
|
||||
],
|
||||
out_args=[Arg("v", ArgDirection.OUT, "value")],
|
||||
),
|
||||
Method(
|
||||
"Set",
|
||||
in_args=[
|
||||
Arg("s", ArgDirection.IN, "interface_name"),
|
||||
Arg("s", ArgDirection.IN, "property_name"),
|
||||
Arg("v", ArgDirection.IN, "value"),
|
||||
],
|
||||
),
|
||||
Method(
|
||||
"GetAll",
|
||||
in_args=[Arg("s", ArgDirection.IN, "interface_name")],
|
||||
out_args=[Arg("a{sv}", ArgDirection.OUT, "props")],
|
||||
),
|
||||
],
|
||||
signals=[
|
||||
Signal(
|
||||
"PropertiesChanged",
|
||||
args=[
|
||||
Arg("s", ArgDirection.OUT, "interface_name"),
|
||||
Arg("a{sv}", ArgDirection.OUT, "changed_properties"),
|
||||
Arg("as", ArgDirection.OUT, "invalidated_properties"),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
Interface(
|
||||
"org.freedesktop.DBus.ObjectManager",
|
||||
methods=[
|
||||
Method(
|
||||
"GetManagedObjects",
|
||||
out_args=[
|
||||
Arg(
|
||||
"a{oa{sa{sv}}}",
|
||||
ArgDirection.OUT,
|
||||
"objpath_interfaces_and_properties",
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
signals=[
|
||||
Signal(
|
||||
"InterfacesAdded",
|
||||
args=[
|
||||
Arg("o", ArgDirection.OUT, "object_path"),
|
||||
Arg(
|
||||
"a{sa{sv}}",
|
||||
ArgDirection.OUT,
|
||||
"interfaces_and_properties",
|
||||
),
|
||||
],
|
||||
),
|
||||
Signal(
|
||||
"InterfacesRemoved",
|
||||
args=[
|
||||
Arg("o", ArgDirection.OUT, "object_path"),
|
||||
Arg("as", ArgDirection.OUT, "interfaces"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
2
dbus_fast/main.py
Normal file
2
dbus_fast/main.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
def add(n1: int, n2: int) -> int:
|
||||
return n1 + n2
|
56
dbus_fast/message.pxd
Normal file
56
dbus_fast/message.pxd
Normal file
|
@ -0,0 +1,56 @@
|
|||
"""cdefs for message.py"""
|
||||
|
||||
import cython
|
||||
|
||||
from ._private.marshaller cimport Marshaller
|
||||
from .signature cimport Variant
|
||||
|
||||
|
||||
cdef object ErrorType
|
||||
cdef object SignatureTree
|
||||
cdef object SignatureType
|
||||
cdef object MessageType
|
||||
|
||||
|
||||
cdef object HEADER_PATH
|
||||
cdef object HEADER_INTERFACE
|
||||
cdef object HEADER_MEMBER
|
||||
cdef object HEADER_ERROR_NAME
|
||||
cdef object HEADER_REPLY_SERIAL
|
||||
cdef object HEADER_DESTINATION
|
||||
cdef object HEADER_SENDER
|
||||
cdef object HEADER_SIGNATURE
|
||||
cdef object HEADER_UNIX_FDS
|
||||
|
||||
|
||||
cdef object LITTLE_ENDIAN
|
||||
cdef object PROTOCOL_VERSION
|
||||
|
||||
cdef object MESSAGE_FLAG
|
||||
cdef object MESSAGE_FLAG_NONE
|
||||
cdef object MESSAGE_TYPE_METHOD_CALL
|
||||
|
||||
cdef get_signature_tree
|
||||
|
||||
cdef class Message:
|
||||
|
||||
cdef public object destination
|
||||
cdef public object path
|
||||
cdef public object interface
|
||||
cdef public object member
|
||||
cdef public object message_type
|
||||
cdef public object flags
|
||||
cdef public object error_name
|
||||
cdef public object reply_serial
|
||||
cdef public object sender
|
||||
cdef public cython.list unix_fds
|
||||
cdef public object signature
|
||||
cdef public object signature_tree
|
||||
cdef public object body
|
||||
cdef public object serial
|
||||
|
||||
@cython.locals(
|
||||
body_buffer=cython.bytearray,
|
||||
header_buffer=cython.bytearray
|
||||
)
|
||||
cpdef _marshall(self, object negotiate_unix_fd)
|
319
dbus_fast/message.py
Normal file
319
dbus_fast/message.py
Normal file
|
@ -0,0 +1,319 @@
|
|||
from typing import Any, List, Optional, Union
|
||||
|
||||
from ._private.constants import LITTLE_ENDIAN, PROTOCOL_VERSION, HeaderField
|
||||
from ._private.marshaller import Marshaller
|
||||
from .constants import ErrorType, MessageFlag, MessageType
|
||||
from .errors import InvalidMessageError
|
||||
from .signature import SignatureTree, Variant, get_signature_tree
|
||||
from .validators import (
|
||||
assert_bus_name_valid,
|
||||
assert_interface_name_valid,
|
||||
assert_member_name_valid,
|
||||
assert_object_path_valid,
|
||||
)
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
MessageType.METHOD_CALL.value: ("path", "member"),
|
||||
MessageType.SIGNAL.value: ("path", "member", "interface"),
|
||||
MessageType.ERROR.value: ("error_name", "reply_serial"),
|
||||
MessageType.METHOD_RETURN.value: ("reply_serial",),
|
||||
}
|
||||
|
||||
HEADER_PATH = HeaderField.PATH.value
|
||||
HEADER_INTERFACE = HeaderField.INTERFACE.value
|
||||
HEADER_MEMBER = HeaderField.MEMBER.value
|
||||
HEADER_ERROR_NAME = HeaderField.ERROR_NAME.value
|
||||
HEADER_REPLY_SERIAL = HeaderField.REPLY_SERIAL.value
|
||||
HEADER_DESTINATION = HeaderField.DESTINATION.value
|
||||
HEADER_SIGNATURE = HeaderField.SIGNATURE.value
|
||||
HEADER_UNIX_FDS = HeaderField.UNIX_FDS.value
|
||||
|
||||
MESSAGE_FLAG = MessageFlag
|
||||
|
||||
MESSAGE_FLAG_NONE = MessageFlag.NONE
|
||||
MESSAGE_TYPE_METHOD_CALL = MessageType.METHOD_CALL
|
||||
|
||||
|
||||
class Message:
|
||||
"""A class for sending and receiving messages through the
|
||||
:class:`MessageBus <dbus_fast.message_bus.BaseMessageBus>` with the
|
||||
low-level api.
|
||||
|
||||
A ``Message`` can be constructed by the user to send over the message bus.
|
||||
When messages are received, such as from method calls or signal emissions,
|
||||
they will use this class as well.
|
||||
|
||||
:ivar destination: The address of the client for which this message is intended.
|
||||
:vartype destination: str
|
||||
:ivar path: The intended object path exported on the destination bus.
|
||||
:vartype path: str
|
||||
:ivar interface: The intended interface on the object path.
|
||||
:vartype interface: str
|
||||
:ivar member: The intended member on the interface.
|
||||
:vartype member: str
|
||||
:ivar message_type: The type of this message. A method call, signal, method return, or error.
|
||||
:vartype message_type: :class:`MessageType`
|
||||
:ivar flags: Flags that affect the behavior of this message.
|
||||
:vartype flags: :class:`MessageFlag`
|
||||
:ivar error_name: If this message is an error, the name of this error. Must be a valid interface name.
|
||||
:vartype error_name: str
|
||||
:ivar reply_serial: If this is a return type, the serial this message is in reply to.
|
||||
:vartype reply_serial: int
|
||||
:ivar sender: The address of the sender of this message. Will be a unique name.
|
||||
:vartype sender: str
|
||||
:ivar unix_fds: A list of unix fds that were sent in the header of this message.
|
||||
:vartype unix_fds: list(int)
|
||||
:ivar signature: The signature of the body of this message.
|
||||
:vartype signature: str
|
||||
:ivar signature_tree: The signature parsed as a signature tree.
|
||||
:vartype signature_tree: :class:`SignatureTree`
|
||||
:ivar body: The body of this message. Must match the signature.
|
||||
:vartype body: list(Any)
|
||||
:ivar serial: The serial of the message. Will be automatically set during message sending if not present. Use the ``new_serial()`` method of the bus to generate a serial.
|
||||
:vartype serial: int
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidMessageError` - If the message is malformed or missing fields for the message type.
|
||||
- :class:`InvalidSignatureError` - If the given signature is not valid.
|
||||
- :class:`InvalidObjectPathError` - If ``path`` is not a valid object path.
|
||||
- :class:`InvalidBusNameError` - If ``destination`` is not a valid bus name.
|
||||
- :class:`InvalidMemberNameError` - If ``member`` is not a valid member name.
|
||||
- :class:`InvalidInterfaceNameError` - If ``error_name`` or ``interface`` is not a valid interface name.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"destination",
|
||||
"path",
|
||||
"interface",
|
||||
"member",
|
||||
"message_type",
|
||||
"flags",
|
||||
"error_name",
|
||||
"reply_serial",
|
||||
"sender",
|
||||
"unix_fds",
|
||||
"signature",
|
||||
"signature_tree",
|
||||
"body",
|
||||
"serial",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
destination: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
interface: Optional[str] = None,
|
||||
member: Optional[str] = None,
|
||||
message_type: MessageType = MESSAGE_TYPE_METHOD_CALL,
|
||||
flags: Union[MessageFlag, int] = MESSAGE_FLAG_NONE,
|
||||
error_name: Optional[Union[str, ErrorType]] = None,
|
||||
reply_serial: int = 0,
|
||||
sender: Optional[str] = None,
|
||||
unix_fds: List[int] = [],
|
||||
signature: Optional[Union[SignatureTree, str]] = None,
|
||||
body: List[Any] = [],
|
||||
serial: int = 0,
|
||||
validate: bool = True,
|
||||
) -> None:
|
||||
self.destination = destination
|
||||
self.path = path
|
||||
self.interface = interface
|
||||
self.member = member
|
||||
self.message_type = message_type
|
||||
self.flags = flags if type(flags) is MESSAGE_FLAG else MESSAGE_FLAG(flags)
|
||||
self.error_name = (
|
||||
str(error_name.value) if type(error_name) is ErrorType else error_name
|
||||
)
|
||||
self.reply_serial = reply_serial or 0
|
||||
self.sender = sender
|
||||
self.unix_fds = unix_fds
|
||||
if type(signature) is SignatureTree:
|
||||
self.signature = signature.signature
|
||||
self.signature_tree = signature
|
||||
else:
|
||||
self.signature = signature or "" # type: ignore[assignment]
|
||||
self.signature_tree = get_signature_tree(signature or "")
|
||||
self.body = body
|
||||
self.serial = serial or 0
|
||||
|
||||
if not validate:
|
||||
return
|
||||
if self.destination is not None:
|
||||
assert_bus_name_valid(self.destination)
|
||||
if self.interface is not None:
|
||||
assert_interface_name_valid(self.interface)
|
||||
if self.path is not None:
|
||||
assert_object_path_valid(self.path)
|
||||
if self.member is not None:
|
||||
assert_member_name_valid(self.member)
|
||||
if self.error_name is not None:
|
||||
assert_interface_name_valid(self.error_name) # type: ignore[arg-type]
|
||||
|
||||
required_fields = REQUIRED_FIELDS.get(self.message_type.value)
|
||||
if not required_fields:
|
||||
raise InvalidMessageError(f"got unknown message type: {self.message_type}")
|
||||
for field in required_fields:
|
||||
if not getattr(self, field):
|
||||
raise InvalidMessageError(f"missing required field: {field}")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of this message."""
|
||||
return (
|
||||
f"<Message {self.message_type.name} "
|
||||
f"serial={self.serial} "
|
||||
f"reply_serial={self.reply_serial} "
|
||||
f"sender={self.sender} "
|
||||
f"destination={self.destination} "
|
||||
f"path={self.path} "
|
||||
f"interface={self.interface} "
|
||||
f"member={self.member} "
|
||||
f"error_name={self.error_name} "
|
||||
f"signature={self.signature} "
|
||||
f"body={self.body}>"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def new_error(
|
||||
msg: "Message", error_name: Union[str, ErrorType], error_text: str
|
||||
) -> "Message":
|
||||
"""A convenience constructor to create an error message in reply to the given message.
|
||||
|
||||
:param msg: The message this error is in reply to.
|
||||
:type msg: :class:`Message`
|
||||
:param error_name: The name of this error. Must be a valid interface name.
|
||||
:type error_name: str
|
||||
:param error_text: Human-readable text for the error.
|
||||
|
||||
:returns: The error message.
|
||||
:rtype: :class:`Message`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidInterfaceNameError` - If the error_name is not a valid interface name.
|
||||
"""
|
||||
return Message(
|
||||
message_type=MessageType.ERROR,
|
||||
reply_serial=msg.serial,
|
||||
destination=msg.sender,
|
||||
error_name=error_name,
|
||||
signature="s",
|
||||
body=[error_text],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def new_method_return(
|
||||
msg: "Message",
|
||||
signature: str = "",
|
||||
body: List[Any] = [],
|
||||
unix_fds: List[int] = [],
|
||||
) -> "Message":
|
||||
"""A convenience constructor to create a method return to the given method call message.
|
||||
|
||||
:param msg: The method call message this is a reply to.
|
||||
:type msg: :class:`Message`
|
||||
:param signature: The signature for the message body.
|
||||
:type signature: str
|
||||
:param body: The body of this message. Must match the signature.
|
||||
:type body: list(Any)
|
||||
:param unix_fds: List integer file descriptors to send with this message.
|
||||
:type body: list(int)
|
||||
|
||||
:returns: The method return message
|
||||
:rtype: :class:`Message`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidSignatureError` - If the signature is not a valid signature.
|
||||
"""
|
||||
return Message(
|
||||
message_type=MessageType.METHOD_RETURN,
|
||||
reply_serial=msg.serial,
|
||||
destination=msg.sender,
|
||||
signature=signature,
|
||||
body=body,
|
||||
unix_fds=unix_fds,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def new_signal(
|
||||
path: str,
|
||||
interface: str,
|
||||
member: str,
|
||||
signature: str = "",
|
||||
body: Optional[List[Any]] = None,
|
||||
unix_fds: Optional[List[int]] = None,
|
||||
) -> "Message":
|
||||
"""A convenience constructor to create a new signal message.
|
||||
|
||||
:param path: The path of this signal.
|
||||
:type path: str
|
||||
:param interface: The interface of this signal.
|
||||
:type interface: str
|
||||
:param member: The member name of this signal.
|
||||
:type member: str
|
||||
:param signature: The signature of the signal body.
|
||||
:type signature: str
|
||||
:param body: The body of this signal message.
|
||||
:type body: list(Any)
|
||||
:param unix_fds: List integer file descriptors to send with this message.
|
||||
:type body: list(int)
|
||||
|
||||
:returns: The signal message.
|
||||
:rtype: :class:`Message`
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidSignatureError` - If the signature is not a valid signature.
|
||||
- :class:`InvalidObjectPathError` - If ``path`` is not a valid object path.
|
||||
- :class:`InvalidInterfaceNameError` - If ``interface`` is not a valid interface name.
|
||||
- :class:`InvalidMemberNameError` - If ``member`` is not a valid member name.
|
||||
"""
|
||||
return Message(
|
||||
message_type=MessageType.SIGNAL,
|
||||
interface=interface,
|
||||
path=path,
|
||||
member=member,
|
||||
signature=signature,
|
||||
body=body or [],
|
||||
unix_fds=unix_fds or [],
|
||||
)
|
||||
|
||||
def _marshall(self, negotiate_unix_fd: bool) -> bytearray:
|
||||
"""Marshall this message into a byte array."""
|
||||
# TODO maximum message size is 134217728 (128 MiB)
|
||||
body_block = Marshaller(self.signature, self.body)
|
||||
body_buffer = body_block._marshall()
|
||||
|
||||
fields = []
|
||||
|
||||
# No verify here since the marshaller will raise an exception if the
|
||||
# Variant is invalid.
|
||||
|
||||
if self.path:
|
||||
fields.append([HEADER_PATH, Variant("o", self.path, False)])
|
||||
if self.interface:
|
||||
fields.append([HEADER_INTERFACE, Variant("s", self.interface, False)])
|
||||
if self.member:
|
||||
fields.append([HEADER_MEMBER, Variant("s", self.member, False)])
|
||||
if self.error_name:
|
||||
fields.append([HEADER_ERROR_NAME, Variant("s", self.error_name, False)])
|
||||
if self.reply_serial:
|
||||
fields.append([HEADER_REPLY_SERIAL, Variant("u", self.reply_serial, False)])
|
||||
if self.destination:
|
||||
fields.append([HEADER_DESTINATION, Variant("s", self.destination, False)])
|
||||
if self.signature:
|
||||
fields.append([HEADER_SIGNATURE, Variant("g", self.signature, False)])
|
||||
if self.unix_fds and negotiate_unix_fd:
|
||||
fields.append([HEADER_UNIX_FDS, Variant("u", len(self.unix_fds), False)])
|
||||
|
||||
header_body = [
|
||||
LITTLE_ENDIAN,
|
||||
self.message_type.value,
|
||||
self.flags.value,
|
||||
PROTOCOL_VERSION,
|
||||
len(body_buffer),
|
||||
self.serial,
|
||||
fields,
|
||||
]
|
||||
header_block = Marshaller("yyyyuua(yv)", header_body)
|
||||
header_block._marshall()
|
||||
header_block._align(8)
|
||||
header_buffer = header_block._buffer()
|
||||
return header_buffer + body_buffer
|
70
dbus_fast/message_bus.pxd
Normal file
70
dbus_fast/message_bus.pxd
Normal file
|
@ -0,0 +1,70 @@
|
|||
import cython
|
||||
|
||||
from ._private.address cimport get_bus_address, parse_address
|
||||
from .message cimport Message
|
||||
from .service cimport ServiceInterface, _Method
|
||||
|
||||
|
||||
cdef object MessageType
|
||||
cdef object DBusError
|
||||
cdef object MessageFlag
|
||||
|
||||
cdef object MESSAGE_TYPE_CALL
|
||||
cdef object MESSAGE_TYPE_SIGNAL
|
||||
cdef cython.uint NO_REPLY_EXPECTED_VALUE
|
||||
cdef object NONE
|
||||
cdef object NO_REPLY_EXPECTED
|
||||
|
||||
cdef object BLOCK_UNEXPECTED_REPLY
|
||||
cdef object assert_object_path_valid
|
||||
cdef object assert_bus_name_valid
|
||||
|
||||
@cython.locals(flag_value=cython.uint)
|
||||
cdef bint _expects_reply(Message msg)
|
||||
|
||||
|
||||
cdef class BaseMessageBus:
|
||||
|
||||
cdef public object unique_name
|
||||
cdef public bint _disconnected
|
||||
cdef public object _user_disconnect
|
||||
cdef public cython.dict _method_return_handlers
|
||||
cdef public object _serial
|
||||
cdef public cython.dict _path_exports
|
||||
cdef public cython.list _user_message_handlers
|
||||
cdef public cython.dict _name_owners
|
||||
cdef public object _bus_address
|
||||
cdef public object _name_owner_match_rule
|
||||
cdef public cython.dict _match_rules
|
||||
cdef public object _high_level_client_initialized
|
||||
cdef public object _ProxyObject
|
||||
cdef public object _machine_id
|
||||
cdef public object _negotiate_unix_fd
|
||||
cdef public object _sock
|
||||
cdef public object _stream
|
||||
cdef public object _fd
|
||||
|
||||
cpdef _process_message(self, Message msg)
|
||||
|
||||
@cython.locals(
|
||||
methods=cython.list,
|
||||
method=_Method,
|
||||
interface=ServiceInterface,
|
||||
interfaces=cython.list,
|
||||
)
|
||||
cdef _find_message_handler(self, Message msg)
|
||||
|
||||
cdef _setup_socket(self)
|
||||
|
||||
@cython.locals(no_reply_expected=bint)
|
||||
cpdef _call(self, Message msg, object callback)
|
||||
|
||||
cpdef next_serial(self)
|
||||
|
||||
cpdef void _callback_method_handler(
|
||||
self,
|
||||
ServiceInterface interface,
|
||||
_Method method,
|
||||
Message msg,
|
||||
object send_reply
|
||||
)
|
1299
dbus_fast/message_bus.py
Normal file
1299
dbus_fast/message_bus.py
Normal file
File diff suppressed because it is too large
Load diff
341
dbus_fast/proxy_object.py
Normal file
341
dbus_fast/proxy_object.py
Normal file
|
@ -0,0 +1,341 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Callable, Coroutine, Dict, List, Optional, Type, Union
|
||||
|
||||
from . import introspection as intr
|
||||
from . import message_bus
|
||||
from ._private.util import replace_idx_with_fds
|
||||
from .constants import ErrorType, MessageType
|
||||
from .errors import DBusError, InterfaceNotFoundError
|
||||
from .message import Message
|
||||
from .unpack import unpack_variants as unpack
|
||||
from .validators import assert_bus_name_valid, assert_object_path_valid
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalHandler:
|
||||
"""Signal handler."""
|
||||
|
||||
fn: Callable
|
||||
unpack_variants: bool
|
||||
|
||||
|
||||
class BaseProxyInterface:
|
||||
"""An abstract class representing a proxy to an interface exported on the bus by another client.
|
||||
|
||||
Implementations of this class are not meant to be constructed directly by
|
||||
users. Use :func:`BaseProxyObject.get_interface` to get a proxy interface.
|
||||
Each message bus implementation provides its own proxy interface
|
||||
implementation that will be returned by that method.
|
||||
|
||||
Proxy interfaces can be used to call methods, get properties, and listen to
|
||||
signals on the interface. Proxy interfaces are created dynamically with a
|
||||
family of methods for each of these operations based on what members the
|
||||
interface exposes. Each proxy interface implementation exposes these
|
||||
members in a different way depending on the features of the backend. See
|
||||
the documentation of the proxy interface implementation you use for more
|
||||
details.
|
||||
|
||||
:ivar bus_name: The name of the bus this interface is exported on.
|
||||
:vartype bus_name: str
|
||||
:ivar path: The object path exported on the client that owns the bus name.
|
||||
:vartype path: str
|
||||
:ivar introspection: Parsed introspection data for the proxy interface.
|
||||
:vartype introspection: :class:`Node <dbus_fast.introspection.Interface>`
|
||||
:ivar bus: The message bus this proxy interface is connected to.
|
||||
:vartype bus: :class:`BaseMessageBus <dbus_fast.message_bus.BaseMessageBus>`
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_name: str,
|
||||
path: str,
|
||||
introspection: intr.Interface,
|
||||
bus: "message_bus.BaseMessageBus",
|
||||
) -> None:
|
||||
self.bus_name = bus_name
|
||||
self.path = path
|
||||
self.introspection = introspection
|
||||
self.bus = bus
|
||||
self._signal_handlers: Dict[str, List[SignalHandler]] = {}
|
||||
self._signal_match_rule = f"type='signal',sender={bus_name},interface={introspection.name},path={path}"
|
||||
|
||||
_underscorer1 = re.compile(r"(.)([A-Z][a-z]+)")
|
||||
_underscorer2 = re.compile(r"([a-z0-9])([A-Z])")
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=128)
|
||||
def _to_snake_case(member: str) -> str:
|
||||
subbed = BaseProxyInterface._underscorer1.sub(r"\1_\2", member)
|
||||
return BaseProxyInterface._underscorer2.sub(r"\1_\2", subbed).lower()
|
||||
|
||||
@staticmethod
|
||||
def _check_method_return(msg: Message, signature: Optional[str] = None):
|
||||
if msg.message_type == MessageType.ERROR:
|
||||
raise DBusError._from_message(msg)
|
||||
elif msg.message_type != MessageType.METHOD_RETURN:
|
||||
raise DBusError(
|
||||
ErrorType.CLIENT_ERROR, "method call didnt return a method return", msg
|
||||
)
|
||||
elif signature is not None and msg.signature != signature:
|
||||
raise DBusError(
|
||||
ErrorType.CLIENT_ERROR,
|
||||
f'method call returned unexpected signature: "{msg.signature}"',
|
||||
msg,
|
||||
)
|
||||
|
||||
def _add_method(self, intr_method: intr.Method) -> None:
|
||||
raise NotImplementedError("this must be implemented in the inheriting class")
|
||||
|
||||
def _add_property(self, intr_property: intr.Property) -> None:
|
||||
raise NotImplementedError("this must be implemented in the inheriting class")
|
||||
|
||||
def _message_handler(self, msg: Message) -> None:
|
||||
if (
|
||||
msg.message_type != MessageType.SIGNAL
|
||||
or msg.interface != self.introspection.name
|
||||
or msg.path != self.path
|
||||
or msg.member not in self._signal_handlers
|
||||
):
|
||||
return
|
||||
|
||||
if (
|
||||
msg.sender != self.bus_name
|
||||
and self.bus._name_owners.get(self.bus_name, "") != msg.sender
|
||||
):
|
||||
# The sender is always a unique name, but the bus name given might
|
||||
# be a well known name. If the sender isn't an exact match, check
|
||||
# to see if it owns the bus_name we were given from the cache kept
|
||||
# on the bus for this purpose.
|
||||
return
|
||||
|
||||
match = [s for s in self.introspection.signals if s.name == msg.member]
|
||||
if not len(match):
|
||||
return
|
||||
intr_signal = match[0]
|
||||
if intr_signal.signature != msg.signature:
|
||||
logging.warning(
|
||||
f'got signal "{self.introspection.name}.{msg.member}" with unexpected signature "{msg.signature}"'
|
||||
)
|
||||
return
|
||||
|
||||
body = replace_idx_with_fds(msg.signature, msg.body, msg.unix_fds)
|
||||
no_sig = None
|
||||
for handler in self._signal_handlers[msg.member]:
|
||||
if handler.unpack_variants:
|
||||
if not no_sig:
|
||||
no_sig = unpack(body)
|
||||
data = no_sig
|
||||
else:
|
||||
data = body
|
||||
|
||||
cb_result = handler.fn(*data)
|
||||
if isinstance(cb_result, Coroutine):
|
||||
asyncio.create_task(cb_result)
|
||||
|
||||
def _add_signal(self, intr_signal: intr.Signal, interface: intr.Interface) -> None:
|
||||
def on_signal_fn(fn: Callable, *, unpack_variants: bool = False):
|
||||
fn_signature = inspect.signature(fn)
|
||||
if 0 < len(
|
||||
[
|
||||
par
|
||||
for par in fn_signature.parameters.values()
|
||||
if par.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
and par.default == inspect.Parameter.empty
|
||||
]
|
||||
):
|
||||
raise TypeError(
|
||||
"reply_notify cannot have required keyword only parameters"
|
||||
)
|
||||
|
||||
positional_params = [
|
||||
par.kind
|
||||
for par in fn_signature.parameters.values()
|
||||
if par.kind
|
||||
not in [inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.VAR_KEYWORD]
|
||||
]
|
||||
if len(positional_params) != len(intr_signal.args) and (
|
||||
inspect.Parameter.VAR_POSITIONAL not in positional_params
|
||||
or len(positional_params) - 1 > len(intr_signal.args)
|
||||
):
|
||||
raise TypeError(
|
||||
f"reply_notify must be a function with {len(intr_signal.args)} positional parameters"
|
||||
)
|
||||
|
||||
if not self._signal_handlers:
|
||||
self.bus._add_match_rule(self._signal_match_rule)
|
||||
self.bus.add_message_handler(self._message_handler)
|
||||
|
||||
if intr_signal.name not in self._signal_handlers:
|
||||
self._signal_handlers[intr_signal.name] = []
|
||||
|
||||
self._signal_handlers[intr_signal.name].append(
|
||||
SignalHandler(fn, unpack_variants)
|
||||
)
|
||||
|
||||
def off_signal_fn(fn: Callable, *, unpack_variants: bool = False) -> None:
|
||||
try:
|
||||
i = self._signal_handlers[intr_signal.name].index(
|
||||
SignalHandler(fn, unpack_variants)
|
||||
)
|
||||
del self._signal_handlers[intr_signal.name][i]
|
||||
if not self._signal_handlers[intr_signal.name]:
|
||||
del self._signal_handlers[intr_signal.name]
|
||||
except (KeyError, ValueError):
|
||||
return
|
||||
|
||||
if not self._signal_handlers:
|
||||
self.bus._remove_match_rule(self._signal_match_rule)
|
||||
self.bus.remove_message_handler(self._message_handler)
|
||||
|
||||
snake_case = BaseProxyInterface._to_snake_case(intr_signal.name)
|
||||
setattr(interface, f"on_{snake_case}", on_signal_fn)
|
||||
setattr(interface, f"off_{snake_case}", off_signal_fn)
|
||||
|
||||
|
||||
class BaseProxyObject:
|
||||
"""An abstract class representing a proxy to an object exported on the bus by another client.
|
||||
|
||||
Implementations of this class are not meant to be constructed directly. Use
|
||||
:func:`BaseMessageBus.get_proxy_object()
|
||||
<dbus_fast.message_bus.BaseMessageBus.get_proxy_object>` to get a proxy
|
||||
object. Each message bus implementation provides its own proxy object
|
||||
implementation that will be returned by that method.
|
||||
|
||||
The primary use of the proxy object is to select a proxy interface to act
|
||||
on. Information on what interfaces are available is provided by
|
||||
introspection data provided to this class. This introspection data can
|
||||
either be included in your project as an XML file (recommended) or
|
||||
retrieved from the ``org.freedesktop.DBus.Introspectable`` interface at
|
||||
runtime.
|
||||
|
||||
:ivar bus_name: The name of the bus this object is exported on.
|
||||
:vartype bus_name: str
|
||||
:ivar path: The object path exported on the client that owns the bus name.
|
||||
:vartype path: str
|
||||
:ivar introspection: Parsed introspection data for the proxy object.
|
||||
:vartype introspection: :class:`Node <dbus_fast.introspection.Node>`
|
||||
:ivar bus: The message bus this proxy object is connected to.
|
||||
:vartype bus: :class:`BaseMessageBus <dbus_fast.message_bus.BaseMessageBus>`
|
||||
:ivar ~.ProxyInterface: The proxy interface class this proxy object uses.
|
||||
:vartype ~.ProxyInterface: Type[:class:`BaseProxyInterface <dbus_fast.proxy_object.BaseProxyObject>`]
|
||||
:ivar child_paths: A list of absolute object paths of the children of this object.
|
||||
:vartype child_paths: list(str)
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidBusNameError <dbus_fast.InvalidBusNameError>` - If the given bus name is not valid.
|
||||
- :class:`InvalidObjectPathError <dbus_fast.InvalidObjectPathError>` - If the given object path is not valid.
|
||||
- :class:`InvalidIntrospectionError <dbus_fast.InvalidIntrospectionError>` - If the introspection data for the node is not valid.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_name: str,
|
||||
path: str,
|
||||
introspection: Union[intr.Node, str, ET.Element],
|
||||
bus: "message_bus.BaseMessageBus",
|
||||
ProxyInterface: Type[BaseProxyInterface],
|
||||
) -> None:
|
||||
assert_object_path_valid(path)
|
||||
assert_bus_name_valid(bus_name)
|
||||
|
||||
if not isinstance(bus, message_bus.BaseMessageBus):
|
||||
raise TypeError("bus must be an instance of BaseMessageBus")
|
||||
if not issubclass(ProxyInterface, BaseProxyInterface):
|
||||
raise TypeError("ProxyInterface must be an instance of BaseProxyInterface")
|
||||
|
||||
if type(introspection) is intr.Node:
|
||||
self.introspection = introspection
|
||||
elif type(introspection) is str:
|
||||
self.introspection = intr.Node.parse(introspection)
|
||||
elif type(introspection) is ET.Element:
|
||||
self.introspection = intr.Node.from_xml(introspection)
|
||||
else:
|
||||
raise TypeError(
|
||||
"introspection must be xml node introspection or introspection.Node class"
|
||||
)
|
||||
|
||||
self.bus_name = bus_name
|
||||
self.path = path
|
||||
self.bus = bus
|
||||
self.ProxyInterface = ProxyInterface
|
||||
self.child_paths = [f"{path}/{n.name}" for n in self.introspection.nodes]
|
||||
|
||||
self._interfaces = {}
|
||||
|
||||
# lazy loaded by get_children()
|
||||
self._children = None
|
||||
|
||||
def get_interface(self, name: str) -> BaseProxyInterface:
|
||||
"""Get an interface exported on this proxy object and connect it to the bus.
|
||||
|
||||
:param name: The name of the interface to retrieve.
|
||||
:type name: str
|
||||
|
||||
:raises:
|
||||
- :class:`InterfaceNotFoundError <dbus_fast.InterfaceNotFoundError>` - If there is no interface by this name exported on the bus.
|
||||
"""
|
||||
if name in self._interfaces:
|
||||
return self._interfaces[name]
|
||||
|
||||
try:
|
||||
intr_interface = next(
|
||||
i for i in self.introspection.interfaces if i.name == name
|
||||
)
|
||||
except StopIteration:
|
||||
raise InterfaceNotFoundError(f"interface not found on this object: {name}")
|
||||
|
||||
interface = self.ProxyInterface(
|
||||
self.bus_name, self.path, intr_interface, self.bus
|
||||
)
|
||||
|
||||
for intr_method in intr_interface.methods:
|
||||
interface._add_method(intr_method)
|
||||
for intr_property in intr_interface.properties:
|
||||
interface._add_property(intr_property)
|
||||
for intr_signal in intr_interface.signals:
|
||||
interface._add_signal(intr_signal, interface)
|
||||
|
||||
def get_owner_notify(msg: Message, err: Optional[Exception]) -> None:
|
||||
if err:
|
||||
logging.error(f'getting name owner for "{name}" failed, {err}')
|
||||
return
|
||||
if msg.message_type == MessageType.ERROR:
|
||||
if msg.error_name != ErrorType.NAME_HAS_NO_OWNER.value:
|
||||
logging.error(
|
||||
f'getting name owner for "{name}" failed, {msg.body[0]}'
|
||||
)
|
||||
return
|
||||
|
||||
self.bus._name_owners[self.bus_name] = msg.body[0]
|
||||
|
||||
if self.bus_name[0] != ":" and not self.bus._name_owners.get(self.bus_name, ""):
|
||||
self.bus._call(
|
||||
Message(
|
||||
destination="org.freedesktop.DBus",
|
||||
interface="org.freedesktop.DBus",
|
||||
path="/org/freedesktop/DBus",
|
||||
member="GetNameOwner",
|
||||
signature="s",
|
||||
body=[self.bus_name],
|
||||
),
|
||||
get_owner_notify,
|
||||
)
|
||||
|
||||
self._interfaces[name] = interface
|
||||
return interface
|
||||
|
||||
def get_children(self) -> List["BaseProxyObject"]:
|
||||
"""Get the child nodes of this proxy object according to the introspection data."""
|
||||
if self._children is None:
|
||||
self._children = [
|
||||
self.__class__(self.bus_name, self.path, child, self.bus)
|
||||
for child in self.introspection.nodes
|
||||
]
|
||||
|
||||
return self._children
|
0
dbus_fast/py.typed
Normal file
0
dbus_fast/py.typed
Normal file
59
dbus_fast/send_reply.py
Normal file
59
dbus_fast/send_reply.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
import traceback
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Optional, Type
|
||||
|
||||
from .constants import ErrorType
|
||||
from .errors import DBusError
|
||||
from .message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message_bus import BaseMessageBus
|
||||
|
||||
|
||||
class SendReply:
|
||||
"""A context manager to send a reply to a message."""
|
||||
|
||||
__slots__ = ("_bus", "_msg")
|
||||
|
||||
def __init__(self, bus: "BaseMessageBus", msg: Message) -> None:
|
||||
"""Create a new reply context manager."""
|
||||
self._bus = bus
|
||||
self._msg = msg
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __call__(self, reply: Message) -> None:
|
||||
self._bus.send(reply)
|
||||
|
||||
def _exit(
|
||||
self,
|
||||
exc_type: Optional[Type[Exception]],
|
||||
exc_value: Optional[Exception],
|
||||
tb: Optional[TracebackType],
|
||||
) -> bool:
|
||||
if exc_value:
|
||||
if isinstance(exc_value, DBusError):
|
||||
self(exc_value._as_message(self._msg))
|
||||
else:
|
||||
self(
|
||||
Message.new_error(
|
||||
self._msg,
|
||||
ErrorType.SERVICE_ERROR,
|
||||
f"The service interface raised an error: {exc_value}.\n{traceback.format_tb(tb)}",
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[Exception]],
|
||||
exc_value: Optional[Exception],
|
||||
tb: Optional[TracebackType],
|
||||
) -> bool:
|
||||
return self._exit(exc_type, exc_value, tb)
|
||||
|
||||
def send_error(self, exc: Exception) -> None:
|
||||
self._exit(exc.__class__, exc, exc.__traceback__)
|
51
dbus_fast/service.pxd
Normal file
51
dbus_fast/service.pxd
Normal file
|
@ -0,0 +1,51 @@
|
|||
"""cdefs for service.py"""
|
||||
|
||||
import cython
|
||||
|
||||
from .message cimport Message
|
||||
from .signature cimport SignatureTree
|
||||
|
||||
|
||||
cdef class _Method:
|
||||
|
||||
cdef public str name
|
||||
cdef public object fn
|
||||
cdef public bint disabled
|
||||
cdef public object introspection
|
||||
cdef public str in_signature
|
||||
cdef public str out_signature
|
||||
cdef public SignatureTree in_signature_tree
|
||||
cdef public SignatureTree out_signature_tree
|
||||
|
||||
|
||||
|
||||
cdef tuple _real_fn_result_to_body(
|
||||
object result,
|
||||
SignatureTree signature_tree,
|
||||
bint replace_fds
|
||||
)
|
||||
|
||||
cdef class ServiceInterface:
|
||||
|
||||
cdef public str name
|
||||
cdef list __methods
|
||||
cdef list __properties
|
||||
cdef list __signals
|
||||
cdef set __buses
|
||||
cdef dict __handlers
|
||||
|
||||
@staticmethod
|
||||
cdef list _c_get_methods(ServiceInterface interface)
|
||||
|
||||
@staticmethod
|
||||
cdef object _c_get_handler(ServiceInterface interface, _Method method, object bus)
|
||||
|
||||
@staticmethod
|
||||
cdef list _c_msg_body_to_args(Message msg)
|
||||
|
||||
@staticmethod
|
||||
cdef tuple _c_fn_result_to_body(
|
||||
object result,
|
||||
SignatureTree signature_tree,
|
||||
bint replace_fds,
|
||||
)
|
659
dbus_fast/service.py
Normal file
659
dbus_fast/service.py
Normal file
|
@ -0,0 +1,659 @@
|
|||
import asyncio
|
||||
import copy
|
||||
import inspect
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from . import introspection as intr
|
||||
from ._private.util import (
|
||||
parse_annotation,
|
||||
replace_fds_with_idx,
|
||||
replace_idx_with_fds,
|
||||
signature_contains_type,
|
||||
)
|
||||
from .constants import PropertyAccess
|
||||
from .errors import SignalDisabledError
|
||||
from .message import Message
|
||||
from .signature import (
|
||||
SignatureBodyMismatchError,
|
||||
SignatureTree,
|
||||
Variant,
|
||||
get_signature_tree,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message_bus import BaseMessageBus
|
||||
|
||||
|
||||
class _Method:
|
||||
def __init__(self, fn, name: str, disabled=False):
|
||||
in_signature = ""
|
||||
out_signature = ""
|
||||
|
||||
inspection = inspect.signature(fn)
|
||||
|
||||
in_args = []
|
||||
for i, param in enumerate(inspection.parameters.values()):
|
||||
if i == 0:
|
||||
# first is self
|
||||
continue
|
||||
annotation = parse_annotation(param.annotation)
|
||||
if not annotation:
|
||||
raise ValueError(
|
||||
"method parameters must specify the dbus type string as an annotation"
|
||||
)
|
||||
in_args.append(intr.Arg(annotation, intr.ArgDirection.IN, param.name))
|
||||
in_signature += annotation
|
||||
|
||||
out_args = []
|
||||
out_signature = parse_annotation(inspection.return_annotation)
|
||||
if out_signature:
|
||||
for type_ in get_signature_tree(out_signature).types:
|
||||
out_args.append(intr.Arg(type_, intr.ArgDirection.OUT))
|
||||
|
||||
self.name = name
|
||||
self.fn = fn
|
||||
self.disabled = disabled
|
||||
self.introspection = intr.Method(name, in_args, out_args)
|
||||
self.in_signature = in_signature
|
||||
self.out_signature = out_signature
|
||||
self.in_signature_tree = get_signature_tree(in_signature)
|
||||
self.out_signature_tree = get_signature_tree(out_signature)
|
||||
|
||||
|
||||
def method(name: Optional[str] = None, disabled: bool = False):
|
||||
"""A decorator to mark a class method of a :class:`ServiceInterface` to be a DBus service method.
|
||||
|
||||
The parameters and return value must each be annotated with a signature
|
||||
string of a single complete DBus type.
|
||||
|
||||
This class method will be called when a client calls the method on the DBus
|
||||
interface. The parameters given to the function come from the calling
|
||||
client and will conform to the dbus-fast type system. The parameters
|
||||
returned will be returned to the calling client and must conform to the
|
||||
dbus-fast type system. If multiple parameters are returned, they must be
|
||||
contained within a :class:`list`.
|
||||
|
||||
The decorated method may raise a :class:`DBusError <dbus_fast.DBusError>`
|
||||
to return an error to the client.
|
||||
|
||||
:param name: The member name that DBus clients will use to call this method. Defaults to the name of the class method.
|
||||
:type name: str
|
||||
:param disabled: If set to true, the method will not be visible to clients.
|
||||
:type disabled: bool
|
||||
|
||||
:example:
|
||||
|
||||
::
|
||||
|
||||
@method()
|
||||
def echo(self, val: 's') -> 's':
|
||||
return val
|
||||
|
||||
@method()
|
||||
def echo_two(self, val1: 's', val2: 'u') -> 'su':
|
||||
return [val1, val2]
|
||||
"""
|
||||
if name is not None and type(name) is not str:
|
||||
raise TypeError("name must be a string")
|
||||
if type(disabled) is not bool:
|
||||
raise TypeError("disabled must be a bool")
|
||||
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapped(*args, **kwargs):
|
||||
fn(*args, **kwargs)
|
||||
|
||||
fn_name = name if name else fn.__name__
|
||||
wrapped.__dict__["__DBUS_METHOD"] = _Method(fn, fn_name, disabled=disabled)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class _Signal:
|
||||
def __init__(self, fn, name, disabled=False):
|
||||
inspection = inspect.signature(fn)
|
||||
|
||||
args = []
|
||||
signature = ""
|
||||
signature_tree = None
|
||||
|
||||
return_annotation = parse_annotation(inspection.return_annotation)
|
||||
|
||||
if return_annotation:
|
||||
signature = return_annotation
|
||||
signature_tree = get_signature_tree(signature)
|
||||
for type_ in signature_tree.types:
|
||||
args.append(intr.Arg(type_, intr.ArgDirection.OUT))
|
||||
else:
|
||||
signature = ""
|
||||
signature_tree = get_signature_tree("")
|
||||
|
||||
self.signature = signature
|
||||
self.signature_tree = signature_tree
|
||||
self.name = name
|
||||
self.disabled = disabled
|
||||
self.introspection = intr.Signal(self.name, args)
|
||||
|
||||
|
||||
def signal(name: Optional[str] = None, disabled: bool = False):
|
||||
"""A decorator to mark a class method of a :class:`ServiceInterface` to be a DBus signal.
|
||||
|
||||
The signal is broadcast on the bus when the decorated class method is
|
||||
called by the user.
|
||||
|
||||
If the signal has an out argument, the class method must have a return type
|
||||
annotation with a signature string of a single complete DBus type and the
|
||||
return value of the class method must conform to the dbus-fast type system.
|
||||
If the signal has multiple out arguments, they must be returned within a
|
||||
``list``.
|
||||
|
||||
:param name: The member name that will be used for this signal. Defaults to
|
||||
the name of the class method.
|
||||
:type name: str
|
||||
:param disabled: If set to true, the signal will not be visible to clients.
|
||||
:type disabled: bool
|
||||
|
||||
:example:
|
||||
|
||||
::
|
||||
|
||||
@signal()
|
||||
def string_signal(self, val) -> 's':
|
||||
return val
|
||||
|
||||
@signal()
|
||||
def two_strings_signal(self, val1, val2) -> 'ss':
|
||||
return [val1, val2]
|
||||
"""
|
||||
if name is not None and type(name) is not str:
|
||||
raise TypeError("name must be a string")
|
||||
if type(disabled) is not bool:
|
||||
raise TypeError("disabled must be a bool")
|
||||
|
||||
def decorator(fn):
|
||||
fn_name = name if name else fn.__name__
|
||||
signal = _Signal(fn, fn_name, disabled)
|
||||
|
||||
@wraps(fn)
|
||||
def wrapped(self, *args, **kwargs):
|
||||
if signal.disabled:
|
||||
raise SignalDisabledError("Tried to call a disabled signal")
|
||||
result = fn(self, *args, **kwargs)
|
||||
ServiceInterface._handle_signal(self, signal, result)
|
||||
return result
|
||||
|
||||
wrapped.__dict__["__DBUS_SIGNAL"] = signal
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class _Property(property):
|
||||
def set_options(self, options):
|
||||
self.options = getattr(self, "options", {})
|
||||
for k, v in options.items():
|
||||
self.options[k] = v
|
||||
|
||||
if "name" in options and options["name"] is not None:
|
||||
self.name = options["name"]
|
||||
else:
|
||||
self.name = self.prop_getter.__name__
|
||||
|
||||
if "access" in options:
|
||||
self.access = PropertyAccess(options["access"])
|
||||
else:
|
||||
self.access = PropertyAccess.READWRITE
|
||||
|
||||
if "disabled" in options:
|
||||
self.disabled = options["disabled"]
|
||||
else:
|
||||
self.disabled = False
|
||||
|
||||
self.introspection = intr.Property(self.name, self.signature, self.access)
|
||||
|
||||
self.__dict__["__DBUS_PROPERTY"] = True
|
||||
|
||||
def __init__(self, fn, *args, **kwargs):
|
||||
self.prop_getter = fn
|
||||
self.prop_setter = None
|
||||
|
||||
inspection = inspect.signature(fn)
|
||||
if len(inspection.parameters) != 1:
|
||||
raise ValueError('the property must only have the "self" input parameter')
|
||||
|
||||
return_annotation = parse_annotation(inspection.return_annotation)
|
||||
|
||||
if not return_annotation:
|
||||
raise ValueError(
|
||||
"the property must specify the dbus type string as a return annotation string"
|
||||
)
|
||||
|
||||
self.signature = return_annotation
|
||||
tree = get_signature_tree(return_annotation)
|
||||
|
||||
if len(tree.types) != 1:
|
||||
raise ValueError("the property signature must be a single complete type")
|
||||
|
||||
self.type = tree.types[0]
|
||||
|
||||
if "options" in kwargs:
|
||||
options = kwargs["options"]
|
||||
self.set_options(options)
|
||||
del kwargs["options"]
|
||||
|
||||
super().__init__(fn, *args, **kwargs)
|
||||
|
||||
def setter(self, fn, **kwargs):
|
||||
# XXX The setter decorator seems to be recreating the class in the list
|
||||
# of class members and clobbering the options so we need to reset them.
|
||||
# Why does it do that?
|
||||
result = super().setter(fn, **kwargs)
|
||||
result.prop_setter = fn
|
||||
result.set_options(self.options)
|
||||
return result
|
||||
|
||||
|
||||
def dbus_property(
|
||||
access: PropertyAccess = PropertyAccess.READWRITE,
|
||||
name: Optional[str] = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
"""A decorator to mark a class method of a :class:`ServiceInterface` to be a DBus property.
|
||||
|
||||
The class method must be a Python getter method with a return annotation
|
||||
that is a signature string of a single complete DBus type. When a client
|
||||
gets the property through the ``org.freedesktop.DBus.Properties``
|
||||
interface, the getter will be called and the resulting value will be
|
||||
returned to the client.
|
||||
|
||||
If the property is writable, it must have a setter method that takes a
|
||||
single parameter that is annotated with the same signature. When a client
|
||||
sets the property through the ``org.freedesktop.DBus.Properties``
|
||||
interface, the setter will be called with the value from the calling
|
||||
client.
|
||||
|
||||
The parameters of the getter and the setter must conform to the dbus-fast
|
||||
type system. The getter or the setter may raise a :class:`DBusError
|
||||
<dbus_fast.DBusError>` to return an error to the client.
|
||||
|
||||
:param name: The name that DBus clients will use to interact with this
|
||||
property on the bus.
|
||||
:type name: str
|
||||
:param disabled: If set to true, the property will not be visible to
|
||||
clients.
|
||||
:type disabled: bool
|
||||
|
||||
:example:
|
||||
|
||||
::
|
||||
|
||||
@dbus_property()
|
||||
def string_prop(self) -> 's':
|
||||
return self._string_prop
|
||||
|
||||
@string_prop.setter
|
||||
def string_prop(self, val: 's'):
|
||||
self._string_prop = val
|
||||
"""
|
||||
if type(access) is not PropertyAccess:
|
||||
raise TypeError("access must be a PropertyAccess class")
|
||||
if name is not None and type(name) is not str:
|
||||
raise TypeError("name must be a string")
|
||||
if type(disabled) is not bool:
|
||||
raise TypeError("disabled must be a bool")
|
||||
|
||||
def decorator(fn):
|
||||
options = {"name": name, "access": access, "disabled": disabled}
|
||||
return _Property(fn, options=options)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _real_fn_result_to_body(
|
||||
result: Optional[Any],
|
||||
signature_tree: SignatureTree,
|
||||
replace_fds: bool,
|
||||
) -> Tuple[List[Any], List[int]]:
|
||||
out_len = len(signature_tree.types)
|
||||
if result is None:
|
||||
final_result = []
|
||||
else:
|
||||
if out_len == 1:
|
||||
final_result = [result]
|
||||
else:
|
||||
result_type = type(result)
|
||||
if result_type is not list and result_type is not tuple:
|
||||
raise SignatureBodyMismatchError(
|
||||
"Expected signal to return a list or tuple of arguments"
|
||||
)
|
||||
final_result = result
|
||||
|
||||
if out_len != len(final_result):
|
||||
raise SignatureBodyMismatchError(
|
||||
f"Signature and function return mismatch, expected {len(signature_tree.types)} arguments but got {len(result)}"
|
||||
)
|
||||
|
||||
if not replace_fds:
|
||||
return final_result, []
|
||||
return replace_fds_with_idx(signature_tree, final_result)
|
||||
|
||||
|
||||
class ServiceInterface:
|
||||
"""An abstract class that can be extended by the user to define DBus services.
|
||||
|
||||
Instances of :class:`ServiceInterface` can be exported on a path of the bus
|
||||
with the :class:`export <dbus_fast.message_bus.BaseMessageBus.export>`
|
||||
method of a :class:`MessageBus <dbus_fast.message_bus.BaseMessageBus>`.
|
||||
|
||||
Use the :func:`@method <dbus_fast.service.method>`, :func:`@dbus_property
|
||||
<dbus_fast.service.dbus_property>`, and :func:`@signal
|
||||
<dbus_fast.service.signal>` decorators to mark class methods as DBus
|
||||
methods, properties, and signals respectively.
|
||||
|
||||
:ivar name: The name of this interface as it appears to clients. Must be a
|
||||
valid interface name.
|
||||
:vartype name: str
|
||||
"""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
# TODO cannot be overridden by a dbus member
|
||||
self.name = name
|
||||
self.__methods: List[_Method] = []
|
||||
self.__properties: List[_Property] = []
|
||||
self.__signals: List[_Signal] = []
|
||||
self.__buses = set()
|
||||
self.__handlers: Dict[
|
||||
BaseMessageBus,
|
||||
Dict[_Method, Callable[[Message, Callable[[Message], None]], None]],
|
||||
] = {}
|
||||
|
||||
for name, member in inspect.getmembers(type(self)):
|
||||
member_dict = getattr(member, "__dict__", {})
|
||||
if type(member) is _Property:
|
||||
# XXX The getter and the setter may show up as different
|
||||
# members if they have different names. But if they have the
|
||||
# same name, they will be the same member. So we try to merge
|
||||
# them together here. I wish we could make this cleaner.
|
||||
found = False
|
||||
for prop in self.__properties:
|
||||
if prop.prop_getter is member.prop_getter:
|
||||
found = True
|
||||
if member.prop_setter is not None:
|
||||
prop.prop_setter = member.prop_setter
|
||||
|
||||
if not found:
|
||||
self.__properties.append(member)
|
||||
elif "__DBUS_METHOD" in member_dict:
|
||||
method = member_dict["__DBUS_METHOD"]
|
||||
assert type(method) is _Method
|
||||
self.__methods.append(method)
|
||||
elif "__DBUS_SIGNAL" in member_dict:
|
||||
signal = member_dict["__DBUS_SIGNAL"]
|
||||
assert type(signal) is _Signal
|
||||
self.__signals.append(signal)
|
||||
|
||||
# validate that writable properties have a setter
|
||||
for prop in self.__properties:
|
||||
if prop.access.writable() and prop.prop_setter is None:
|
||||
raise ValueError(
|
||||
f'property "{prop.name}" is writable but does not have a setter'
|
||||
)
|
||||
|
||||
def emit_properties_changed(
|
||||
self, changed_properties: Dict[str, Any], invalidated_properties: List[str] = []
|
||||
):
|
||||
"""Emit the ``org.freedesktop.DBus.Properties.PropertiesChanged`` signal.
|
||||
|
||||
This signal is intended to be used to alert clients when a property of
|
||||
the interface has changed.
|
||||
|
||||
:param changed_properties: The keys must be the names of properties exposed by this bus. The values must be valid for the signature of those properties.
|
||||
:type changed_properties: dict(str, Any)
|
||||
:param invalidated_properties: A list of names of properties that are now invalid (presumably for clients who cache the value).
|
||||
:type invalidated_properties: list(str)
|
||||
"""
|
||||
# TODO cannot be overridden by a dbus member
|
||||
variant_dict = {}
|
||||
|
||||
for prop in ServiceInterface._get_properties(self):
|
||||
if prop.name in changed_properties:
|
||||
variant_dict[prop.name] = Variant(
|
||||
prop.signature, changed_properties[prop.name]
|
||||
)
|
||||
|
||||
body = [self.name, variant_dict, invalidated_properties]
|
||||
for bus in ServiceInterface._get_buses(self):
|
||||
bus._interface_signal_notify(
|
||||
self,
|
||||
"org.freedesktop.DBus.Properties",
|
||||
"PropertiesChanged",
|
||||
"sa{sv}as",
|
||||
body,
|
||||
)
|
||||
|
||||
def introspect(self) -> intr.Interface:
|
||||
"""Get introspection information for this interface.
|
||||
|
||||
This might be useful for creating clients for the interface or examining the introspection output of an interface.
|
||||
|
||||
:returns: The introspection data for the interface.
|
||||
:rtype: :class:`dbus_fast.introspection.Interface`
|
||||
"""
|
||||
# TODO cannot be overridden by a dbus member
|
||||
return intr.Interface(
|
||||
self.name,
|
||||
methods=[
|
||||
method.introspection
|
||||
for method in ServiceInterface._get_methods(self)
|
||||
if not method.disabled
|
||||
],
|
||||
signals=[
|
||||
signal.introspection
|
||||
for signal in ServiceInterface._get_signals(self)
|
||||
if not signal.disabled
|
||||
],
|
||||
properties=[
|
||||
prop.introspection
|
||||
for prop in ServiceInterface._get_properties(self)
|
||||
if not prop.disabled
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_properties(interface: "ServiceInterface") -> List[_Property]:
|
||||
return interface.__properties
|
||||
|
||||
@staticmethod
|
||||
def _get_methods(interface: "ServiceInterface") -> List[_Method]:
|
||||
return interface.__methods
|
||||
|
||||
@staticmethod
|
||||
def _c_get_methods(interface: "ServiceInterface") -> List[_Method]:
|
||||
# _c_get_methods is used by the C code to get the methods for an
|
||||
# interface
|
||||
# https://github.com/cython/cython/issues/3327
|
||||
return interface.__methods
|
||||
|
||||
@staticmethod
|
||||
def _get_signals(interface: "ServiceInterface") -> List[_Signal]:
|
||||
return interface.__signals
|
||||
|
||||
@staticmethod
|
||||
def _get_buses(interface: "ServiceInterface") -> Set["BaseMessageBus"]:
|
||||
return interface.__buses
|
||||
|
||||
@staticmethod
|
||||
def _get_handler(
|
||||
interface: "ServiceInterface", method: _Method, bus: "BaseMessageBus"
|
||||
) -> Callable[[Message, Callable[[Message], None]], None]:
|
||||
return interface.__handlers[bus][method]
|
||||
|
||||
@staticmethod
|
||||
def _c_get_handler(
|
||||
interface: "ServiceInterface", method: _Method, bus: "BaseMessageBus"
|
||||
) -> Callable[[Message, Callable[[Message], None]], None]:
|
||||
# _c_get_handler is used by the C code to get the handler for a method
|
||||
# https://github.com/cython/cython/issues/3327
|
||||
return interface.__handlers[bus][method]
|
||||
|
||||
@staticmethod
|
||||
def _add_bus(
|
||||
interface: "ServiceInterface",
|
||||
bus: "BaseMessageBus",
|
||||
maker: Callable[
|
||||
["ServiceInterface", _Method],
|
||||
Callable[[Message, Callable[[Message], None]], None],
|
||||
],
|
||||
) -> None:
|
||||
interface.__buses.add(bus)
|
||||
interface.__handlers[bus] = {
|
||||
method: maker(interface, method) for method in interface.__methods
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _remove_bus(interface: "ServiceInterface", bus: "BaseMessageBus") -> None:
|
||||
interface.__buses.remove(bus)
|
||||
del interface.__handlers[bus]
|
||||
|
||||
@staticmethod
|
||||
def _msg_body_to_args(msg: Message) -> List[Any]:
|
||||
return ServiceInterface._c_msg_body_to_args(msg)
|
||||
|
||||
@staticmethod
|
||||
def _c_msg_body_to_args(msg: Message) -> List[Any]:
|
||||
# https://github.com/cython/cython/issues/3327
|
||||
if not signature_contains_type(msg.signature_tree, msg.body, "h"):
|
||||
return msg.body
|
||||
|
||||
# XXX: This deep copy could be expensive if messages are very
|
||||
# large. We could optimize this by only copying what we change
|
||||
# here.
|
||||
return replace_idx_with_fds(
|
||||
msg.signature_tree, copy.deepcopy(msg.body), msg.unix_fds
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fn_result_to_body(
|
||||
result: Optional[Any],
|
||||
signature_tree: SignatureTree,
|
||||
replace_fds: bool = True,
|
||||
) -> Tuple[List[Any], List[int]]:
|
||||
return _real_fn_result_to_body(result, signature_tree, replace_fds)
|
||||
|
||||
@staticmethod
|
||||
def _c_fn_result_to_body(
|
||||
result: Optional[Any],
|
||||
signature_tree: SignatureTree,
|
||||
replace_fds: bool,
|
||||
) -> Tuple[List[Any], List[int]]:
|
||||
"""The high level interfaces may return single values which may be
|
||||
wrapped in a list to be a message body. Also they may return fds
|
||||
directly for type 'h' which need to be put into an external list."""
|
||||
# https://github.com/cython/cython/issues/3327
|
||||
return _real_fn_result_to_body(result, signature_tree, replace_fds)
|
||||
|
||||
@staticmethod
|
||||
def _handle_signal(
|
||||
interface: "ServiceInterface", signal: _Signal, result: Optional[Any]
|
||||
) -> None:
|
||||
body, fds = ServiceInterface._fn_result_to_body(result, signal.signature_tree)
|
||||
for bus in ServiceInterface._get_buses(interface):
|
||||
bus._interface_signal_notify(
|
||||
interface, interface.name, signal.name, signal.signature, body, fds
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_property_value(interface: "ServiceInterface", prop: _Property, callback):
|
||||
# XXX MUST CHECK TYPE RETURNED BY GETTER
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(prop.prop_getter):
|
||||
task = asyncio.ensure_future(prop.prop_getter(interface))
|
||||
|
||||
def get_property_callback(task):
|
||||
try:
|
||||
result = task.result()
|
||||
except Exception as e:
|
||||
callback(interface, prop, None, e)
|
||||
return
|
||||
|
||||
callback(interface, prop, result, None)
|
||||
|
||||
task.add_done_callback(get_property_callback)
|
||||
return
|
||||
|
||||
callback(
|
||||
interface, prop, getattr(interface, prop.prop_getter.__name__), None
|
||||
)
|
||||
except Exception as e:
|
||||
callback(interface, prop, None, e)
|
||||
|
||||
@staticmethod
|
||||
def _set_property_value(interface: "ServiceInterface", prop, value, callback):
|
||||
# XXX MUST CHECK TYPE TO SET
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(prop.prop_setter):
|
||||
task = asyncio.ensure_future(prop.prop_setter(interface, value))
|
||||
|
||||
def set_property_callback(task):
|
||||
try:
|
||||
task.result()
|
||||
except Exception as e:
|
||||
callback(interface, prop, e)
|
||||
return
|
||||
|
||||
callback(interface, prop, None)
|
||||
|
||||
task.add_done_callback(set_property_callback)
|
||||
return
|
||||
|
||||
setattr(interface, prop.prop_setter.__name__, value)
|
||||
callback(interface, prop, None)
|
||||
except Exception as e:
|
||||
callback(interface, prop, e)
|
||||
|
||||
@staticmethod
|
||||
def _get_all_property_values(
|
||||
interface: "ServiceInterface", callback, user_data=None
|
||||
):
|
||||
result = {}
|
||||
result_error = None
|
||||
|
||||
for prop in ServiceInterface._get_properties(interface):
|
||||
if prop.disabled or not prop.access.readable():
|
||||
continue
|
||||
result[prop.name] = None
|
||||
|
||||
if not result:
|
||||
callback(interface, result, user_data, None)
|
||||
return
|
||||
|
||||
def get_property_callback(
|
||||
interface: "ServiceInterface",
|
||||
prop: _Property,
|
||||
value: Any,
|
||||
e: Optional[Exception],
|
||||
) -> None:
|
||||
nonlocal result_error
|
||||
if e is not None:
|
||||
result_error = e
|
||||
del result[prop.name]
|
||||
else:
|
||||
try:
|
||||
result[prop.name] = Variant(prop.signature, value)
|
||||
except SignatureBodyMismatchError as e:
|
||||
result_error = e
|
||||
del result[prop.name]
|
||||
|
||||
if any(v is None for v in result.values()):
|
||||
return
|
||||
|
||||
callback(interface, result, user_data, result_error)
|
||||
|
||||
for prop in ServiceInterface._get_properties(interface):
|
||||
if prop.disabled or not prop.access.readable():
|
||||
continue
|
||||
ServiceInterface._get_property_value(interface, prop, get_property_callback)
|
26
dbus_fast/signature.pxd
Normal file
26
dbus_fast/signature.pxd
Normal file
|
@ -0,0 +1,26 @@
|
|||
"""cdefs for signature.py"""
|
||||
|
||||
import cython
|
||||
|
||||
|
||||
cdef class SignatureType:
|
||||
|
||||
cdef public str token
|
||||
cdef public list children
|
||||
cdef str _signature
|
||||
|
||||
|
||||
cdef class SignatureTree:
|
||||
|
||||
cdef public str signature
|
||||
cdef public list types
|
||||
|
||||
|
||||
cdef class Variant:
|
||||
|
||||
cdef public SignatureType type
|
||||
cdef public str signature
|
||||
cdef public object value
|
||||
|
||||
@cython.locals(signature_tree=SignatureTree)
|
||||
cdef _init_variant(self, object signature, object value, bint verify)
|
456
dbus_fast/signature.py
Normal file
456
dbus_fast/signature.py
Normal file
|
@ -0,0 +1,456 @@
|
|||
from functools import lru_cache
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from .errors import InvalidSignatureError, SignatureBodyMismatchError
|
||||
from .validators import is_object_path_valid
|
||||
|
||||
|
||||
class SignatureType:
|
||||
"""A class that represents a single complete type within a signature.
|
||||
|
||||
This class is not meant to be constructed directly. Use the :class:`SignatureTree`
|
||||
class to parse signatures.
|
||||
|
||||
:ivar ~.signature: The signature of this complete type.
|
||||
:vartype ~.signature: str
|
||||
|
||||
:ivar children: A list of child types if this is a container type. Arrays \
|
||||
have one child type, dict entries have two child types (key and value), and \
|
||||
structs have child types equal to the number of struct members.
|
||||
:vartype children: list(:class:`SignatureType`)
|
||||
"""
|
||||
|
||||
_tokens = "ybnqiuxtdsogavh({"
|
||||
__slots__ = ("token", "children", "_signature")
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
"""Init a new SignatureType."""
|
||||
self.token: str = token
|
||||
self.children: List[SignatureType] = []
|
||||
self._signature: Optional[str] = None
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Compare this type to another type or signature string."""
|
||||
if type(other) is SignatureType:
|
||||
return self.signature == other.signature
|
||||
return super().__eq__(other)
|
||||
|
||||
def _collapse(self) -> str:
|
||||
"""Collapse this type into a signature string."""
|
||||
if self.token not in "a({":
|
||||
return self.token
|
||||
|
||||
signature = [self.token]
|
||||
|
||||
for child in self.children:
|
||||
signature.append(child._collapse())
|
||||
|
||||
if self.token == "(":
|
||||
signature.append(")")
|
||||
elif self.token == "{":
|
||||
signature.append("}")
|
||||
|
||||
return "".join(signature)
|
||||
|
||||
@property
|
||||
def signature(self) -> str:
|
||||
if self._signature is not None:
|
||||
return self._signature
|
||||
self._signature = self._collapse()
|
||||
return self._signature
|
||||
|
||||
@staticmethod
|
||||
def _parse_next(signature: str) -> Tuple["SignatureType", str]:
|
||||
if not signature:
|
||||
raise InvalidSignatureError("Cannot parse an empty signature")
|
||||
|
||||
token = signature[0]
|
||||
|
||||
if token not in SignatureType._tokens:
|
||||
raise InvalidSignatureError(f'got unexpected token: "{token}"')
|
||||
|
||||
# container types
|
||||
if token == "a":
|
||||
self = SignatureType("a")
|
||||
(child, signature) = SignatureType._parse_next(signature[1:])
|
||||
if not child:
|
||||
raise InvalidSignatureError("missing type for array")
|
||||
self.children.append(child)
|
||||
return (self, signature)
|
||||
elif token == "(":
|
||||
self = SignatureType("(")
|
||||
signature = signature[1:]
|
||||
while True:
|
||||
(child, signature) = SignatureType._parse_next(signature)
|
||||
if not signature:
|
||||
raise InvalidSignatureError('missing closing ")" for struct')
|
||||
self.children.append(child)
|
||||
if signature[0] == ")":
|
||||
return (self, signature[1:])
|
||||
elif token == "{":
|
||||
self = SignatureType("{")
|
||||
signature = signature[1:]
|
||||
(key_child, signature) = SignatureType._parse_next(signature)
|
||||
if not key_child or len(key_child.children):
|
||||
raise InvalidSignatureError("expected a simple type for dict entry key")
|
||||
self.children.append(key_child)
|
||||
(value_child, signature) = SignatureType._parse_next(signature)
|
||||
if not value_child:
|
||||
raise InvalidSignatureError("expected a value for dict entry")
|
||||
if not signature or signature[0] != "}":
|
||||
raise InvalidSignatureError('missing closing "}" for dict entry')
|
||||
self.children.append(value_child)
|
||||
return (self, signature[1:])
|
||||
|
||||
# basic type
|
||||
return (SignatureType(token), signature[1:])
|
||||
|
||||
def _verify_byte(self, body: Any) -> None:
|
||||
BYTE_MIN = 0x00
|
||||
BYTE_MAX = 0xFF
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus BYTE type "y" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
if body < BYTE_MIN or body > BYTE_MAX:
|
||||
raise SignatureBodyMismatchError(
|
||||
f"DBus BYTE type must be between {BYTE_MIN} and {BYTE_MAX}"
|
||||
)
|
||||
|
||||
def _verify_boolean(self, body: Any) -> None:
|
||||
if not isinstance(body, bool):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus BOOLEAN type "b" must be Python type "bool", got {type(body)}'
|
||||
)
|
||||
|
||||
def _verify_int16(self, body: Any) -> None:
|
||||
INT16_MIN = -0x7FFF - 1
|
||||
INT16_MAX = 0x7FFF
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus INT16 type "n" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
elif body > INT16_MAX or body < INT16_MIN:
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus INT16 type "n" must be between {INT16_MIN} and {INT16_MAX}'
|
||||
)
|
||||
|
||||
def _verify_uint16(self, body: Any) -> None:
|
||||
UINT16_MIN = 0
|
||||
UINT16_MAX = 0xFFFF
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus UINT16 type "q" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
elif body > UINT16_MAX or body < UINT16_MIN:
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus UINT16 type "q" must be between {UINT16_MIN} and {UINT16_MAX}'
|
||||
)
|
||||
|
||||
def _verify_int32(self, body: int) -> None:
|
||||
INT32_MIN = -0x7FFFFFFF - 1
|
||||
INT32_MAX = 0x7FFFFFFF
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus INT32 type "i" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
elif body > INT32_MAX or body < INT32_MIN:
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus INT32 type "i" must be between {INT32_MIN} and {INT32_MAX}'
|
||||
)
|
||||
|
||||
def _verify_uint32(self, body: Any) -> None:
|
||||
UINT32_MIN = 0
|
||||
UINT32_MAX = 0xFFFFFFFF
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus UINT32 type "u" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
elif body > UINT32_MAX or body < UINT32_MIN:
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus UINT32 type "u" must be between {UINT32_MIN} and {UINT32_MAX}'
|
||||
)
|
||||
|
||||
def _verify_int64(self, body: Any) -> None:
|
||||
INT64_MAX = 9223372036854775807
|
||||
INT64_MIN = -INT64_MAX - 1
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus INT64 type "x" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
elif body > INT64_MAX or body < INT64_MIN:
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus INT64 type "x" must be between {INT64_MIN} and {INT64_MAX}'
|
||||
)
|
||||
|
||||
def _verify_uint64(self, body: Any) -> None:
|
||||
UINT64_MIN = 0
|
||||
UINT64_MAX = 18446744073709551615
|
||||
if not isinstance(body, int):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus UINT64 type "t" must be Python type "int", got {type(body)}'
|
||||
)
|
||||
elif body > UINT64_MAX or body < UINT64_MIN:
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus UINT64 type "t" must be between {UINT64_MIN} and {UINT64_MAX}'
|
||||
)
|
||||
|
||||
def _verify_double(self, body: Any) -> None:
|
||||
if not isinstance(body, (float, int)):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus DOUBLE type "d" must be Python type "float" or "int", got {type(body)}'
|
||||
)
|
||||
|
||||
def _verify_unix_fd(self, body: Any) -> None:
|
||||
try:
|
||||
self._verify_uint32(body)
|
||||
except SignatureBodyMismatchError:
|
||||
raise SignatureBodyMismatchError(
|
||||
'DBus UNIX_FD type "h" must be a valid UINT32'
|
||||
)
|
||||
|
||||
def _verify_object_path(self, body: Any) -> None:
|
||||
if not is_object_path_valid(body):
|
||||
raise SignatureBodyMismatchError(
|
||||
'DBus OBJECT_PATH type "o" must be a valid object path'
|
||||
)
|
||||
|
||||
def _verify_string(self, body: Any) -> None:
|
||||
if not isinstance(body, str):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus STRING type "s" must be Python type "str", got {type(body)}'
|
||||
)
|
||||
|
||||
def _verify_signature(self, body: Any) -> None:
|
||||
# I guess we could run it through the SignatureTree parser instead
|
||||
if not isinstance(body, str):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus SIGNATURE type "g" must be Python type "str", got {type(body)}'
|
||||
)
|
||||
if len(body.encode()) > 0xFF:
|
||||
raise SignatureBodyMismatchError(
|
||||
'DBus SIGNATURE type "g" must be less than 256 bytes'
|
||||
)
|
||||
|
||||
def _verify_array(self, body: Any) -> None:
|
||||
child_type = self.children[0]
|
||||
|
||||
if child_type.token == "{":
|
||||
if not isinstance(body, dict):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus ARRAY type "a" with DICT_ENTRY child must be Python type "dict", got {type(body)}'
|
||||
)
|
||||
for key, value in body.items():
|
||||
child_type.children[0].verify(key)
|
||||
child_type.children[1].verify(value)
|
||||
elif child_type.token == "y":
|
||||
if not isinstance(body, (bytearray, bytes)):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus ARRAY type "a" with BYTE child must be Python type "bytes", got {type(body)}'
|
||||
)
|
||||
# no need to verify children
|
||||
else:
|
||||
if not isinstance(body, list):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus ARRAY type "a" must be Python type "list", got {type(body)}'
|
||||
)
|
||||
for member in body:
|
||||
child_type.verify(member)
|
||||
|
||||
def _verify_struct(self, body: Any) -> None:
|
||||
if not isinstance(body, (list, tuple)):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus STRUCT type "(" must be Python type "list" or "tuple", got {type(body)}'
|
||||
)
|
||||
|
||||
if len(body) != len(self.children):
|
||||
raise SignatureBodyMismatchError(
|
||||
'DBus STRUCT type "(" must have Python list members equal to the number of struct type members'
|
||||
)
|
||||
|
||||
for i, member in enumerate(body):
|
||||
self.children[i].verify(member)
|
||||
|
||||
def _verify_variant(self, body: Any) -> None:
|
||||
# a variant signature and value is valid by construction
|
||||
if not isinstance(body, Variant):
|
||||
raise SignatureBodyMismatchError(
|
||||
f'DBus VARIANT type "v" must be Python type "Variant", got {type(body)}'
|
||||
)
|
||||
|
||||
def verify(self, body: Any) -> bool:
|
||||
"""Verify that the body matches this type.
|
||||
|
||||
:returns: True if the body matches this type.
|
||||
:raises:
|
||||
:class:`SignatureBodyMismatchError` if the body does not match this type.
|
||||
"""
|
||||
if body is None:
|
||||
raise SignatureBodyMismatchError('Cannot serialize Python type "None"')
|
||||
validator = self.validators.get(self.token)
|
||||
if validator:
|
||||
validator(self, body)
|
||||
else:
|
||||
raise Exception(f"cannot verify type with token {self.token}")
|
||||
|
||||
return True
|
||||
|
||||
validators: Dict[str, Callable[["SignatureType", Any], None]] = {
|
||||
"y": _verify_byte,
|
||||
"b": _verify_boolean,
|
||||
"n": _verify_int16,
|
||||
"q": _verify_uint16,
|
||||
"i": _verify_int32,
|
||||
"u": _verify_uint32,
|
||||
"x": _verify_int64,
|
||||
"t": _verify_uint64,
|
||||
"d": _verify_double,
|
||||
"h": _verify_uint32,
|
||||
"o": _verify_string,
|
||||
"s": _verify_string,
|
||||
"g": _verify_signature,
|
||||
"a": _verify_array,
|
||||
"(": _verify_struct,
|
||||
"v": _verify_variant,
|
||||
}
|
||||
|
||||
|
||||
class SignatureTree:
|
||||
"""A class that represents a signature as a tree structure for conveniently
|
||||
working with DBus signatures.
|
||||
|
||||
This class will not normally be used directly by the user.
|
||||
|
||||
:ivar types: A list of parsed complete types.
|
||||
:vartype types: list(:class:`SignatureType`)
|
||||
|
||||
:ivar ~.signature: The signature of this signature tree.
|
||||
:vartype ~.signature: str
|
||||
|
||||
:raises:
|
||||
:class:`InvalidSignatureError` if the given signature is not valid.
|
||||
"""
|
||||
|
||||
__slots__ = ("signature", "types")
|
||||
|
||||
def __init__(self, signature: str = "") -> None:
|
||||
self.signature = signature
|
||||
|
||||
self.types: List[SignatureType] = []
|
||||
|
||||
if len(signature) > 0xFF:
|
||||
raise InvalidSignatureError("A signature must be less than 256 characters")
|
||||
|
||||
while signature:
|
||||
(type_, signature) = SignatureType._parse_next(signature)
|
||||
self.types.append(type_)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(other) is SignatureTree:
|
||||
return self.signature == other.signature
|
||||
return super().__eq__(other)
|
||||
|
||||
def verify(self, body: List[Any]) -> bool:
|
||||
"""Verifies that the give body matches this signature tree
|
||||
|
||||
:param body: the body to verify for this tree
|
||||
:type body: list(Any)
|
||||
|
||||
:returns: True if the signature matches the body or an exception if not.
|
||||
|
||||
:raises:
|
||||
:class:`SignatureBodyMismatchError` if the signature does not match the body.
|
||||
"""
|
||||
if not isinstance(body, list):
|
||||
raise SignatureBodyMismatchError(
|
||||
f"The body must be a list (got {type(body)})"
|
||||
)
|
||||
if len(body) != len(self.types):
|
||||
raise SignatureBodyMismatchError(
|
||||
f"The body has the wrong number of types (got {len(body)}, expected {len(self.types)})"
|
||||
)
|
||||
for i, type_ in enumerate(self.types):
|
||||
type_.verify(body[i])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Variant:
|
||||
"""A class to represent a DBus variant (type "v").
|
||||
|
||||
This class is used in message bodies to represent variants. The user can
|
||||
expect a value in the body with type "v" to use this class and can
|
||||
construct this class directly for use in message bodies sent over the bus.
|
||||
|
||||
:ivar signature: The signature for this variant. Must be a single complete type.
|
||||
:vartype signature: str or SignatureTree or SignatureType
|
||||
|
||||
:ivar value: The value of this variant. Must correspond to the signature.
|
||||
:vartype value: Any
|
||||
|
||||
:raises:
|
||||
:class:`InvalidSignatureError` if the signature is not valid.
|
||||
:class:`SignatureBodyMismatchError` if the signature does not match the body.
|
||||
"""
|
||||
|
||||
__slots__ = ("type", "signature", "value")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signature: Union[str, SignatureTree, SignatureType],
|
||||
value: Any,
|
||||
verify: bool = True,
|
||||
) -> None:
|
||||
"""Init a new Variant."""
|
||||
self._init_variant(signature, value, verify)
|
||||
|
||||
def _init_variant(
|
||||
self,
|
||||
signature: Union[str, SignatureTree, SignatureType],
|
||||
value: Any,
|
||||
verify: bool,
|
||||
) -> None:
|
||||
if type(signature) is SignatureTree:
|
||||
signature_tree = signature
|
||||
self.signature = signature_tree.signature
|
||||
self.type = signature_tree.types[0]
|
||||
elif type(signature) is SignatureType:
|
||||
signature_tree = None
|
||||
self.signature = signature.signature
|
||||
self.type = signature
|
||||
elif type(signature) is str:
|
||||
signature_tree = get_signature_tree(signature)
|
||||
self.signature = signature
|
||||
self.type = signature_tree.types[0]
|
||||
else:
|
||||
raise TypeError(
|
||||
"signature must be a SignatureTree, SignatureType, or a string"
|
||||
)
|
||||
self.value = value
|
||||
if verify:
|
||||
if signature_tree and len(signature_tree.types) != 1:
|
||||
raise ValueError(
|
||||
"variants must have a signature for a single complete type"
|
||||
)
|
||||
self.type.verify(value)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(other) is Variant:
|
||||
return self.signature == other.signature and self.value == other.value
|
||||
return super().__eq__(other)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<dbus_fast.signature.Variant ('{}', {})>".format(
|
||||
self.type.signature, self.value
|
||||
)
|
||||
|
||||
|
||||
get_signature_tree = lru_cache(maxsize=None)(SignatureTree)
|
||||
"""Get a signature tree for the given signature.
|
||||
|
||||
:param signature: The signature to get a tree for.
|
||||
:type signature: str
|
||||
|
||||
:returns: The signature tree for the given signature.
|
||||
:rtype: :class:`SignatureTree`
|
||||
"""
|
13
dbus_fast/unpack.pxd
Normal file
13
dbus_fast/unpack.pxd
Normal file
|
@ -0,0 +1,13 @@
|
|||
"""cdefs for unpack.py"""
|
||||
|
||||
import cython
|
||||
|
||||
from .signature cimport Variant
|
||||
|
||||
|
||||
cpdef unpack_variants(object data)
|
||||
|
||||
@cython.locals(
|
||||
var=Variant
|
||||
)
|
||||
cdef _unpack_variants(object data)
|
24
dbus_fast/unpack.py
Normal file
24
dbus_fast/unpack.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from typing import Any
|
||||
|
||||
from .signature import Variant
|
||||
|
||||
|
||||
def unpack_variants(data: Any) -> Any:
|
||||
"""Unpack variants and remove signature info.
|
||||
|
||||
This function should only be used to unpack
|
||||
unmarshalled data as the checks are not
|
||||
idiomatic.
|
||||
"""
|
||||
return _unpack_variants(data)
|
||||
|
||||
|
||||
def _unpack_variants(data: Any) -> Any:
|
||||
if type(data) is dict:
|
||||
return {k: _unpack_variants(v) for k, v in data.items()}
|
||||
if type(data) is list:
|
||||
return [_unpack_variants(item) for item in data]
|
||||
if type(data) is Variant:
|
||||
var = data
|
||||
return _unpack_variants(var.value)
|
||||
return data
|
199
dbus_fast/validators.py
Normal file
199
dbus_fast/validators.py
Normal file
|
@ -0,0 +1,199 @@
|
|||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from .errors import (
|
||||
InvalidBusNameError,
|
||||
InvalidInterfaceNameError,
|
||||
InvalidMemberNameError,
|
||||
InvalidObjectPathError,
|
||||
)
|
||||
|
||||
_bus_name_re = re.compile(r"^[A-Za-z_-][A-Za-z0-9_-]*$")
|
||||
_path_re = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
_element_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_member_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def is_bus_name_valid(name: str) -> bool:
|
||||
"""Whether this is a valid bus name.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-bus
|
||||
|
||||
:param name: The bus name to validate.
|
||||
:type name: str
|
||||
|
||||
:returns: Whether the name is a valid bus name.
|
||||
:rtype: bool
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return False # type: ignore[unreachable]
|
||||
|
||||
if not name or len(name) > 255:
|
||||
return False
|
||||
|
||||
if name.startswith(":"):
|
||||
# a unique bus name
|
||||
return True
|
||||
|
||||
if name.startswith("."):
|
||||
return False
|
||||
|
||||
if name.find(".") == -1:
|
||||
return False
|
||||
|
||||
for element in name.split("."):
|
||||
if _bus_name_re.search(element) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def is_object_path_valid(path: str) -> bool:
|
||||
"""Whether this is a valid object path.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path
|
||||
|
||||
:param path: The object path to validate.
|
||||
:type path: str
|
||||
|
||||
:returns: Whether the object path is valid.
|
||||
:rtype: bool
|
||||
"""
|
||||
if not isinstance(path, str):
|
||||
return False # type: ignore[unreachable]
|
||||
|
||||
if not path:
|
||||
return False
|
||||
|
||||
if not path.startswith("/"):
|
||||
return False
|
||||
|
||||
if len(path) == 1:
|
||||
return True
|
||||
|
||||
for element in path[1:].split("/"):
|
||||
if _path_re.search(element) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def is_interface_name_valid(name: str) -> bool:
|
||||
"""Whether this is a valid interface name.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-interface
|
||||
|
||||
:param name: The interface name to validate.
|
||||
:type name: str
|
||||
|
||||
:returns: Whether the name is a valid interface name.
|
||||
:rtype: bool
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return False # type: ignore[unreachable]
|
||||
|
||||
if not name or len(name) > 255:
|
||||
return False
|
||||
|
||||
if name.startswith("."):
|
||||
return False
|
||||
|
||||
if name.find(".") == -1:
|
||||
return False
|
||||
|
||||
for element in name.split("."):
|
||||
if _element_re.search(element) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def is_member_name_valid(member: str) -> bool:
|
||||
"""Whether this is a valid member name.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-member
|
||||
|
||||
:param member: The member name to validate.
|
||||
:type member: str
|
||||
|
||||
:returns: Whether the name is a valid member name.
|
||||
:rtype: bool
|
||||
"""
|
||||
if not isinstance(member, str):
|
||||
return False # type: ignore[unreachable]
|
||||
|
||||
if not member or len(member) > 255:
|
||||
return False
|
||||
|
||||
if _member_re.search(member) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def assert_bus_name_valid(name: str) -> None:
|
||||
"""Raise an error if this is not a valid bus name.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-bus
|
||||
|
||||
:param name: The bus name to validate.
|
||||
:type name: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidBusNameError` - If this is not a valid bus name.
|
||||
"""
|
||||
if not is_bus_name_valid(name):
|
||||
raise InvalidBusNameError(name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def assert_object_path_valid(path: str) -> None:
|
||||
"""Raise an error if this is not a valid object path.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path
|
||||
|
||||
:param path: The object path to validate.
|
||||
:type path: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidObjectPathError` - If this is not a valid object path.
|
||||
"""
|
||||
if not is_object_path_valid(path):
|
||||
raise InvalidObjectPathError(path)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def assert_interface_name_valid(name: str) -> None:
|
||||
"""Raise an error if this is not a valid interface name.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-interface
|
||||
|
||||
:param name: The interface name to validate.
|
||||
:type name: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidInterfaceNameError` - If this is not a valid object path.
|
||||
"""
|
||||
if not is_interface_name_valid(name):
|
||||
raise InvalidInterfaceNameError(name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def assert_member_name_valid(member: str) -> None:
|
||||
"""Raise an error if this is not a valid member name.
|
||||
|
||||
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-member
|
||||
|
||||
:param member: The member name to validate.
|
||||
:type member: str
|
||||
|
||||
:raises:
|
||||
- :class:`InvalidMemberNameError` - If this is not a valid object path.
|
||||
"""
|
||||
if not is_member_name_valid(member):
|
||||
raise InvalidMemberNameError(member)
|
330
idasen/cli.py
Executable file
330
idasen/cli.py
Executable file
|
@ -0,0 +1,330 @@
|
|||
#!/usr/bin/python3
|
||||
import functools
|
||||
|
||||
from desk import IdasenDesk
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
import platform
|
||||
|
||||
HOME = os.path.expanduser("~")
|
||||
IDASEN_CONFIG_DIRECTORY = os.path.join(HOME, ".config", "idasen")
|
||||
IDASEN_CONFIG_PATH = os.path.join(IDASEN_CONFIG_DIRECTORY, "idasen.yaml")
|
||||
|
||||
DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"positions": {"stand": 1.1, "sit": 0.75},
|
||||
"mac_address": "AA:AA:AA:AA:AA:AA",
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
"mac_address": vol.All(str, vol.Length(min=17, max=36)),
|
||||
"positions": {
|
||||
str: vol.All(
|
||||
vol.Any(float, int),
|
||||
vol.Range(min=IdasenDesk.MIN_HEIGHT, max=IdasenDesk.MAX_HEIGHT),
|
||||
)
|
||||
},
|
||||
},
|
||||
extra=False,
|
||||
)
|
||||
|
||||
RESERVED_NAMES = {"init", "pair", "monitor", "height", "speed", "save", "delete"}
|
||||
|
||||
|
||||
def save_config(config: dict, path: str = IDASEN_CONFIG_PATH):
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(config, f)
|
||||
|
||||
|
||||
def load_config(path: str = IDASEN_CONFIG_PATH) -> dict:
|
||||
"""Load user config."""
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
config = yaml.load(f, Loader=yaml.FullLoader)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
# convert old config file format
|
||||
if "positions" not in config:
|
||||
config["positions"] = dict()
|
||||
config["positions"]["sit"] = config.pop(
|
||||
"sit_height", DEFAULT_CONFIG["positions"]["sit"]
|
||||
)
|
||||
config["positions"]["stand"] = config.pop(
|
||||
"stand_height", DEFAULT_CONFIG["positions"]["stand"]
|
||||
)
|
||||
|
||||
save_config(config, path)
|
||||
|
||||
try:
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as e:
|
||||
print(f"Invalid configuration: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
for position in config["positions"]:
|
||||
if position in RESERVED_NAMES:
|
||||
print(
|
||||
"Invalid configuration, "
|
||||
f"position with name '{position}' is a reserved name.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def add_common_args(parser: argparse.ArgumentParser):
|
||||
parser.add_argument(
|
||||
"--mac-address",
|
||||
dest="mac_address",
|
||||
type=str,
|
||||
help="MAC address of the Idasen desk.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v", action="count", default=0, help="Increase logging verbosity."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="store_true", help="Prints version information."
|
||||
)
|
||||
|
||||
|
||||
def get_parser(config: dict) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="ikea IDÅSEN desk control")
|
||||
add_common_args(parser)
|
||||
sub = parser.add_subparsers(dest="sub", help="Subcommands", required=False)
|
||||
|
||||
height_parser = sub.add_parser("height", help="Get the desk height.")
|
||||
speed_parser = sub.add_parser("speed", help="Get the desk speed.")
|
||||
monitor_parser = sub.add_parser("monitor", help="Monitor the desk position.")
|
||||
init_parser = sub.add_parser("init", help="Initialize a new configuration file.")
|
||||
save_parser = sub.add_parser("save", help="Save current desk position.")
|
||||
pair_parser = sub.add_parser("pair", help="Pair with device.")
|
||||
save_parser.add_argument("name", help="Position name")
|
||||
delete_parser = sub.add_parser("delete", help="Remove position with given name.")
|
||||
delete_parser.add_argument("name", help="Position name")
|
||||
|
||||
positions = config.get("positions", {})
|
||||
for name, value in positions.items():
|
||||
subcommand = sub.add_parser(name, help=f"Move the desk to {value}m.")
|
||||
add_common_args(subcommand)
|
||||
|
||||
init_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite any existing configuration files.",
|
||||
)
|
||||
|
||||
add_common_args(init_parser)
|
||||
add_common_args(pair_parser)
|
||||
add_common_args(height_parser)
|
||||
add_common_args(speed_parser)
|
||||
add_common_args(monitor_parser)
|
||||
add_common_args(save_parser)
|
||||
add_common_args(delete_parser)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def init(args: argparse.Namespace) -> int:
|
||||
if not args.force and os.path.isfile(IDASEN_CONFIG_PATH):
|
||||
print("Configuration file already exists.", file=sys.stderr)
|
||||
print("Use --force to overwrite existing configuration.", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
mac = await IdasenDesk.discover()
|
||||
if mac is not None:
|
||||
print(f"Discovered desk's MAC address: {mac}", file=sys.stderr)
|
||||
DEFAULT_CONFIG["mac_address"] = str(mac)
|
||||
else:
|
||||
print("Failed to discover desk's MAC address", file=sys.stderr)
|
||||
os.makedirs(IDASEN_CONFIG_DIRECTORY, exist_ok=True)
|
||||
with open(IDASEN_CONFIG_PATH, "w") as f:
|
||||
f.write("# https://newam.github.io/idasen/index.html#configuration\n")
|
||||
yaml.dump(DEFAULT_CONFIG, f)
|
||||
print(
|
||||
f"Created new configuration file at: {IDASEN_CONFIG_PATH}", file=sys.stderr
|
||||
)
|
||||
print("'idasen pair' can be used to pair to desk.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def pair(args: argparse.Namespace) -> Optional[int]:
|
||||
try:
|
||||
async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk:
|
||||
await desk.pair()
|
||||
except NotImplementedError as e:
|
||||
if platform.system() == "Darwin":
|
||||
print(
|
||||
"The pair subcommand does not function reliably on macOS.\n"
|
||||
"A pairing dialogue is shown if the OS deems that pairing is needed.\n"
|
||||
"Retrying can help.\n\n"
|
||||
"See docs at https://bleak.readthedocs.io/en/latest/backends/macos.html"
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
raise e
|
||||
return None
|
||||
|
||||
|
||||
async def monitor(args: argparse.Namespace) -> None:
|
||||
try:
|
||||
async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk:
|
||||
|
||||
async def printer(height: float, speed: float):
|
||||
print(f"{height:.3f} meters - {speed:.3f} meters/second", flush=True)
|
||||
|
||||
await desk.monitor(printer)
|
||||
while True:
|
||||
await asyncio.sleep(1000000)
|
||||
except (KeyboardInterrupt, asyncio.exceptions.CancelledError):
|
||||
pass
|
||||
|
||||
|
||||
async def height(args: argparse.Namespace):
|
||||
async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk:
|
||||
height = await desk.get_height()
|
||||
print(f"{height:.3f} meters")
|
||||
|
||||
|
||||
async def speed(args: argparse.Namespace):
|
||||
async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk:
|
||||
speed = await desk.get_speed()
|
||||
print(f"{speed:.3f} meters/second")
|
||||
|
||||
|
||||
async def move_to(args: argparse.Namespace, position: float) -> None:
|
||||
async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk:
|
||||
await desk.move_to_target(target=position)
|
||||
|
||||
|
||||
async def save(args: argparse.Namespace, config: dict) -> int:
|
||||
if args.name in RESERVED_NAMES:
|
||||
print(f"Position with name '{args.name}' is a reserved name.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
async with IdasenDesk(args.mac_address, exit_on_fail=True) as desk:
|
||||
height = await desk.get_height()
|
||||
|
||||
config["positions"][args.name] = height
|
||||
save_config(config)
|
||||
|
||||
print(f"Saved position '{args.name}' with height: {height}m.")
|
||||
return 0
|
||||
|
||||
|
||||
async def delete(args: argparse.Namespace, config: dict) -> int:
|
||||
position = config["positions"].pop(args.name, None)
|
||||
if args.name in RESERVED_NAMES:
|
||||
print(f"Position with name '{args.name}' is a reserved name.", file=sys.stderr)
|
||||
return 1
|
||||
elif position is None:
|
||||
print(f"Position with name '{args.name}' doesn't exist.", file=sys.stderr)
|
||||
else:
|
||||
save_config(config)
|
||||
print(f"Position with name '{args.name}' removed.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def from_config(
|
||||
args: argparse.Namespace,
|
||||
config: dict,
|
||||
parser: argparse.ArgumentParser,
|
||||
key: str,
|
||||
raise_error: bool = True,
|
||||
):
|
||||
if hasattr(args, key) and getattr(args, key) is None:
|
||||
if key in config:
|
||||
setattr(args, key, config[key])
|
||||
elif raise_error:
|
||||
parser.error(f"{key} must be provided via the CLI or the config file")
|
||||
|
||||
|
||||
def count_to_level(count: int) -> int:
|
||||
if count == 1:
|
||||
return logging.ERROR
|
||||
elif count == 2:
|
||||
return logging.WARNING
|
||||
elif count == 3:
|
||||
return logging.INFO
|
||||
elif count >= 4:
|
||||
return logging.DEBUG
|
||||
|
||||
return logging.CRITICAL
|
||||
|
||||
|
||||
def subcommand_to_callable(sub: str, config: dict) -> Callable:
|
||||
if sub == "init":
|
||||
return init
|
||||
elif sub == "pair":
|
||||
return pair
|
||||
elif sub == "monitor":
|
||||
return monitor
|
||||
elif sub == "height":
|
||||
return height
|
||||
elif sub == "speed":
|
||||
return speed
|
||||
elif sub == "save":
|
||||
return functools.partial(save, config=config)
|
||||
elif sub == "delete":
|
||||
return functools.partial(delete, config=config)
|
||||
elif sub in config.get("positions", {}):
|
||||
position = config["positions"][sub]
|
||||
return functools.partial(move_to, position=position)
|
||||
else:
|
||||
raise AssertionError(f"internal error, please report this bug {sub=}")
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None):
|
||||
config = load_config()
|
||||
parser = get_parser(config)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
from_config(args, config, parser, "mac_address", raise_error=args.sub != "init")
|
||||
|
||||
level = count_to_level(args.verbose)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stderr)
|
||||
handler.setLevel(level)
|
||||
formatter = logging.Formatter("{levelname} {name} {message}", style="{")
|
||||
handler.setFormatter(formatter)
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(level)
|
||||
|
||||
if args.version:
|
||||
version = importlib.metadata.version("idasen")
|
||||
print(version)
|
||||
sys.exit(0)
|
||||
elif args.sub is None:
|
||||
print("A subcommand is required")
|
||||
parser.print_usage()
|
||||
sys.exit(1)
|
||||
else:
|
||||
func = subcommand_to_callable(args.sub, config)
|
||||
|
||||
rc = asyncio.run(func(args))
|
||||
|
||||
if rc is None:
|
||||
rc = 0
|
||||
|
||||
sys.exit(rc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
466
idasen/desk.py
Normal file
466
idasen/desk.py
Normal file
|
@ -0,0 +1,466 @@
|
|||
from bleak import BleakClient
|
||||
from bleak import BleakScanner
|
||||
from bleak import BleakGATTCharacteristic
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import MutableMapping
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
from inspect import signature
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
_UUID_HEIGHT: str = "99fa0021-338a-1024-8a49-009c0215f78a"
|
||||
_UUID_COMMAND: str = "99fa0002-338a-1024-8a49-009c0215f78a"
|
||||
_UUID_REFERENCE_INPUT: str = "99fa0031-338a-1024-8a49-009c0215f78a"
|
||||
_UUID_ADV_SVC: str = "99fa0001-338a-1024-8a49-009c0215f78a"
|
||||
_UUID_DPG: str = "99fa0011-338a-1024-8a49-009c0215f78a"
|
||||
|
||||
_COMMAND_REFERENCE_INPUT_STOP: bytearray = bytearray([0x01, 0x80])
|
||||
_COMMAND_UP: bytearray = bytearray([0x47, 0x00])
|
||||
_COMMAND_DOWN: bytearray = bytearray([0x46, 0x00])
|
||||
_COMMAND_STOP: bytearray = bytearray([0xFF, 0x00])
|
||||
_COMMAND_WAKEUP: bytearray = bytearray([0xFE, 0x00])
|
||||
|
||||
|
||||
# height calculation offset in meters, assumed to be the same for all desks
|
||||
def _bytes_to_meters_and_speed(raw: bytearray) -> Tuple[float, float]:
|
||||
"""Converts a value read from the desk in bytes to height in meters and speed."""
|
||||
raw_len = len(raw)
|
||||
expected_len = 4
|
||||
assert (
|
||||
raw_len == expected_len
|
||||
), f"Expected raw value to be {expected_len} bytes long, got {raw_len} bytes"
|
||||
|
||||
int_raw, speed_raw = struct.unpack("<Hh", raw)
|
||||
meters = float(int(int_raw) / 10000) + IdasenDesk.MIN_HEIGHT
|
||||
speed = float(int(speed_raw) / 10000)
|
||||
|
||||
return meters, speed
|
||||
|
||||
|
||||
def _meters_to_bytes(meters: float) -> bytearray:
|
||||
"""Converts meters to bytes for setting the position on the desk"""
|
||||
int_raw: int = int((meters - IdasenDesk.MIN_HEIGHT) * 10000)
|
||||
return bytearray(struct.pack("<H", int_raw))
|
||||
|
||||
|
||||
def _is_desk(device: BLEDevice, adv: AdvertisementData) -> bool:
|
||||
return _UUID_ADV_SVC in adv.service_uuids
|
||||
|
||||
|
||||
class _DeskLoggingAdapter(logging.LoggerAdapter):
|
||||
"""Prepends logging messages with the desk MAC address."""
|
||||
|
||||
def process(
|
||||
self, msg: str, kwargs: MutableMapping[str, Any]
|
||||
) -> Tuple[str, MutableMapping[str, Any]]:
|
||||
return f"[{self.extra['mac']}] {msg}", kwargs # type: ignore
|
||||
|
||||
|
||||
class IdasenDesk:
|
||||
"""
|
||||
Idasen desk.
|
||||
|
||||
Args:
|
||||
mac: Bluetooth MAC address of the desk, or an instance of a BLEDevice.
|
||||
exit_on_fail: If set to True, failing to connect will call ``sys.exit(1)``,
|
||||
otherwise the exception will be raised.
|
||||
disconnected_callback:
|
||||
Callback that will be scheduled in the event loop when the client is
|
||||
disconnected. The callable must take one argument, which will be
|
||||
this client object.
|
||||
|
||||
Note:
|
||||
There is no locking to prevent you from running multiple movement
|
||||
coroutines simultaneously.
|
||||
|
||||
Example:
|
||||
Basic Usage::
|
||||
|
||||
from idasen import IdasenDesk
|
||||
|
||||
|
||||
async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
# call methods here...
|
||||
"""
|
||||
|
||||
#: Minimum desk height in meters.
|
||||
MIN_HEIGHT: float = 0.62
|
||||
|
||||
#: Maximum desk height in meters.
|
||||
MAX_HEIGHT: float = 1.27
|
||||
|
||||
#: Number of times to retry upon failure to connect.
|
||||
RETRY_COUNT: int = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mac: Union[BLEDevice, str],
|
||||
exit_on_fail: bool = False,
|
||||
disconnected_callback: Optional[Callable[[BleakClient], None]] = None,
|
||||
):
|
||||
self._exit_on_fail = exit_on_fail
|
||||
self._client = BleakClient(
|
||||
address_or_ble_device=mac,
|
||||
disconnected_callback=disconnected_callback,
|
||||
)
|
||||
self._mac = mac.address if isinstance(mac, BLEDevice) else mac
|
||||
self._logger = _DeskLoggingAdapter(
|
||||
logger=logging.getLogger(__name__), extra={"mac": self.mac}
|
||||
)
|
||||
self._moving = False
|
||||
self._move_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args, **kwargs):
|
||||
await self.disconnect()
|
||||
|
||||
async def pair(self):
|
||||
"""
|
||||
Pair with the desk.
|
||||
|
||||
This method is not available on macOS. Instead of manually initiating
|
||||
paring, the user will be prompted to pair automatically as soon as it
|
||||
is required.
|
||||
|
||||
See :py:meth:`bleak.BleakClient.pair` for more information.
|
||||
"""
|
||||
await self._client.pair()
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
Connect to the desk.
|
||||
|
||||
This method is an alternative to the context manager.
|
||||
When possible the context manager is preferred.
|
||||
|
||||
>>> async def example() -> bool:
|
||||
... desk = IdasenDesk(mac="AA:AA:AA:AA:AA:AA")
|
||||
... await desk.connect() # don't forget to call disconnect later!
|
||||
... return desk.is_connected
|
||||
>>> asyncio.run(example())
|
||||
True
|
||||
"""
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
await self._client.connect()
|
||||
await self.wakeup()
|
||||
return
|
||||
except Exception:
|
||||
if i >= self.RETRY_COUNT:
|
||||
self._logger.critical("Connection failed")
|
||||
if self._exit_on_fail:
|
||||
sys.exit(1)
|
||||
raise
|
||||
i += 1
|
||||
self._logger.warning(
|
||||
f"Failed to connect, retrying ({i}/{self.RETRY_COUNT})..."
|
||||
)
|
||||
await asyncio.sleep(0.3 * i)
|
||||
|
||||
async def disconnect(self):
|
||||
"""
|
||||
Disconnect from the desk.
|
||||
|
||||
This method is an alternative to the context manager.
|
||||
When possible the context manager is preferred.
|
||||
|
||||
>>> async def example() -> bool:
|
||||
... desk = IdasenDesk(mac="AA:AA:AA:AA:AA:AA")
|
||||
... await desk.connect()
|
||||
... await desk.disconnect()
|
||||
... return desk.is_connected
|
||||
>>> asyncio.run(example())
|
||||
False
|
||||
"""
|
||||
await self._client.disconnect()
|
||||
|
||||
async def monitor(self, callback: Callable[..., Awaitable[None]]):
|
||||
output_service_uuid = "99fa0020-338a-1024-8a49-009c0215f78a"
|
||||
output_char_uuid = "99fa0021-338a-1024-8a49-009c0215f78a"
|
||||
|
||||
# Determine the amount of callback parameters
|
||||
# 1st one is height, optional 2nd one is speed, more is not supported
|
||||
callback_param_count = len(signature(callback).parameters)
|
||||
if callback_param_count != 1 and callback_param_count != 2:
|
||||
raise ValueError(
|
||||
"Invalid callback provided, only 1 or 2 parameters are supported"
|
||||
)
|
||||
|
||||
return_speed_value = callback_param_count == 2
|
||||
previous_height = 0.0
|
||||
previous_speed = 0.0
|
||||
|
||||
async def output_listener(char: BleakGATTCharacteristic, data: bytearray):
|
||||
height, speed = _bytes_to_meters_and_speed(data)
|
||||
self._logger.debug(f"Got data: {height}m {speed}m/s")
|
||||
|
||||
nonlocal previous_height
|
||||
nonlocal previous_speed
|
||||
if abs(height - previous_height) < 0.001 and (
|
||||
not return_speed_value or abs(speed - previous_speed) < 0.001
|
||||
):
|
||||
return
|
||||
previous_height = height
|
||||
previous_speed = speed
|
||||
|
||||
if return_speed_value:
|
||||
await callback(height, speed)
|
||||
else:
|
||||
await callback(height)
|
||||
|
||||
for service in self._client.services:
|
||||
if service.uuid != output_service_uuid:
|
||||
continue
|
||||
|
||||
chr_output = service.get_characteristic(output_char_uuid)
|
||||
if chr_output is None:
|
||||
self._logger.error("No output characteristic found")
|
||||
return
|
||||
|
||||
self._logger.debug("Starting notify")
|
||||
await self._client.start_notify(chr_output, output_listener)
|
||||
return
|
||||
|
||||
self._logger.error("Output service not found")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
``True`` if the desk is connected.
|
||||
|
||||
>>> async def example() -> bool:
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... return desk.is_connected
|
||||
>>> asyncio.run(example())
|
||||
True
|
||||
"""
|
||||
return self._client.is_connected
|
||||
|
||||
@property
|
||||
def is_moving(self) -> bool:
|
||||
"""
|
||||
``True`` if the desk is currently being moved by this class.
|
||||
"""
|
||||
return self._moving
|
||||
|
||||
@property
|
||||
def mac(self) -> str:
|
||||
"""
|
||||
Desk MAC address.
|
||||
|
||||
>>> async def example() -> str:
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... return desk.mac
|
||||
>>> asyncio.run(example())
|
||||
'AA:AA:AA:AA:AA:AA'
|
||||
"""
|
||||
return self._mac
|
||||
|
||||
async def wakeup(self):
|
||||
"""
|
||||
Wakeup the controller from sleep.
|
||||
|
||||
This exists for compatibility with the Linak DPG1C controller,
|
||||
it is not necessary with the original idasen controller.
|
||||
|
||||
>>> async def example():
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.wakeup()
|
||||
>>> asyncio.run(example())
|
||||
"""
|
||||
# https://github.com/rhyst/linak-controller/issues/32#issuecomment-1784055470
|
||||
await self._client.write_gatt_char(_UUID_DPG, b"\x7F\x86\x00")
|
||||
await self._client.write_gatt_char(
|
||||
_UUID_DPG,
|
||||
b"\x7F\x86\x80\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D"
|
||||
b"\x0E\x0F\x10\x11",
|
||||
)
|
||||
await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_WAKEUP)
|
||||
|
||||
async def move_up(self):
|
||||
"""
|
||||
Move the desk upwards.
|
||||
|
||||
This command moves the desk upwards for a fixed duration
|
||||
(approximately one second) as set by your desk controller.
|
||||
|
||||
>>> async def example():
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.move_up()
|
||||
>>> asyncio.run(example())
|
||||
"""
|
||||
await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_UP, response=False)
|
||||
|
||||
async def move_down(self):
|
||||
"""
|
||||
Move the desk downwards.
|
||||
|
||||
This command moves the desk downwards for a fixed duration
|
||||
(approximately one second) as set by your desk controller.
|
||||
|
||||
>>> async def example():
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.move_down()
|
||||
>>> asyncio.run(example())
|
||||
"""
|
||||
await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_DOWN, response=False)
|
||||
|
||||
async def move_to_target(self, target: float):
|
||||
"""
|
||||
Move the desk to the target position.
|
||||
|
||||
Args:
|
||||
target: Target position in meters.
|
||||
|
||||
Raises:
|
||||
ValueError: Target exceeds maximum or minimum limits.
|
||||
|
||||
>>> async def example():
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.move_to_target(1.1)
|
||||
>>> asyncio.run(example())
|
||||
"""
|
||||
if target > self.MAX_HEIGHT:
|
||||
raise ValueError(
|
||||
f"target position of {target:.3f} meters exceeds maximum of "
|
||||
f"{self.MAX_HEIGHT:.3f}"
|
||||
)
|
||||
elif target < self.MIN_HEIGHT:
|
||||
raise ValueError(
|
||||
f"target position of {target:.3f} meters exceeds minimum of "
|
||||
f"{self.MIN_HEIGHT:.3f}"
|
||||
)
|
||||
|
||||
if self._moving:
|
||||
self._logger.error("Already moving")
|
||||
return
|
||||
self._moving = True
|
||||
|
||||
async def do_move() -> None:
|
||||
current_height = await self.get_height()
|
||||
if current_height == target:
|
||||
return
|
||||
|
||||
# Wakeup and stop commands are needed in order to
|
||||
# start the reference input for setting the position
|
||||
await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_WAKEUP)
|
||||
await self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_STOP)
|
||||
|
||||
data = _meters_to_bytes(target)
|
||||
|
||||
while self._moving:
|
||||
await self._client.write_gatt_char(_UUID_REFERENCE_INPUT, data)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Stop as soon as the speed is 0,
|
||||
# which means the desk has reached the target position
|
||||
speed = await self.get_speed()
|
||||
if speed == 0:
|
||||
break
|
||||
|
||||
self._move_task = asyncio.create_task(do_move())
|
||||
await self._move_task
|
||||
self._moving = False
|
||||
|
||||
async def stop(self):
|
||||
"""Stop desk movement."""
|
||||
self._moving = False
|
||||
if self._move_task:
|
||||
self._logger.debug("Desk was moving, waiting for it to stop")
|
||||
await self._move_task
|
||||
|
||||
await self._stop()
|
||||
|
||||
async def _stop(self):
|
||||
"""Send stop commands"""
|
||||
self._logger.debug("Sending stop commands")
|
||||
await asyncio.gather(
|
||||
self._client.write_gatt_char(_UUID_COMMAND, _COMMAND_STOP, response=False),
|
||||
self._client.write_gatt_char(
|
||||
_UUID_REFERENCE_INPUT, _COMMAND_REFERENCE_INPUT_STOP, response=False
|
||||
),
|
||||
)
|
||||
|
||||
async def get_height(self) -> float:
|
||||
"""
|
||||
Get the desk height in meters.
|
||||
|
||||
Returns:
|
||||
Desk height in meters.
|
||||
|
||||
>>> async def example() -> float:
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.move_to_target(1.0)
|
||||
... return await desk.get_height()
|
||||
>>> asyncio.run(example())
|
||||
1.0
|
||||
"""
|
||||
height, _ = await self.get_height_and_speed()
|
||||
return height
|
||||
|
||||
async def get_speed(self) -> float:
|
||||
"""
|
||||
Get the desk speed in meters per second.
|
||||
|
||||
Returns:
|
||||
Desk speed in meters per second.
|
||||
|
||||
>>> async def example() -> float:
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.move_to_target(1.0)
|
||||
... return await desk.get_speed()
|
||||
>>> asyncio.run(example())
|
||||
0.0
|
||||
"""
|
||||
_, speed = await self.get_height_and_speed()
|
||||
return speed
|
||||
|
||||
async def get_height_and_speed(self) -> Tuple[float, float]:
|
||||
"""
|
||||
Get the desk height in meters and speed in meters per second.
|
||||
|
||||
Returns:
|
||||
Tuple of desk height in meters and speed in meters per second.
|
||||
|
||||
>>> async def example() -> [float, float]:
|
||||
... async with IdasenDesk(mac="AA:AA:AA:AA:AA:AA") as desk:
|
||||
... await desk.move_to_target(1.0)
|
||||
... return await desk.get_height_and_speed()
|
||||
>>> asyncio.run(example())
|
||||
(1.0, 0.0)
|
||||
"""
|
||||
raw = await self._client.read_gatt_char(_UUID_HEIGHT)
|
||||
return _bytes_to_meters_and_speed(raw)
|
||||
|
||||
@staticmethod
|
||||
async def discover() -> Optional[str]:
|
||||
"""
|
||||
Try to find the desk's MAC address by discovering currently connected devices.
|
||||
|
||||
Returns:
|
||||
MAC address if found, ``None`` if not found.
|
||||
|
||||
>>> asyncio.run(IdasenDesk.discover())
|
||||
'AA:AA:AA:AA:AA:AA'
|
||||
"""
|
||||
try:
|
||||
device = await BleakScanner.find_device_by_filter(_is_desk)
|
||||
except Exception as e:
|
||||
raise e
|
||||
return None
|
||||
|
||||
if device is None:
|
||||
return None
|
||||
|
||||
return device.address
|
389
macparse/macaddress.py
Normal file
389
macparse/macaddress.py
Normal file
|
@ -0,0 +1,389 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
# Copyright 2021 Alexander Kozhevnikov <mentalisttraceur@gmail.com>
|
||||
|
||||
"""Like ``ipaddress``, but for hardware identifiers such as MAC addresses."""
|
||||
|
||||
__all__ = (
|
||||
'HWAddress',
|
||||
'OUI',
|
||||
'CDI32', 'CDI40',
|
||||
'MAC',
|
||||
'EUI48', 'EUI60', 'EUI64',
|
||||
'parse',
|
||||
)
|
||||
__version__ = '2.0.2'
|
||||
|
||||
|
||||
from functools import total_ordering as _total_ordering
|
||||
|
||||
|
||||
_HEX_DIGITS = "0123456789ABCDEFabcdef"
|
||||
|
||||
|
||||
def _name(obj):
|
||||
return type(obj).__name__
|
||||
|
||||
|
||||
def _class_names_in_proper_english(classes):
|
||||
class_names = [cls.__name__ for cls in classes]
|
||||
number_of_classes = len(classes)
|
||||
if number_of_classes < 2:
|
||||
return class_names[0]
|
||||
elif number_of_classes == 2:
|
||||
return ' or '.join(class_names)
|
||||
else:
|
||||
class_names[-1] = 'or ' + class_names[-1]
|
||||
return ', '.join(class_names)
|
||||
|
||||
|
||||
def _type_error(value, *classes):
|
||||
class_names = _class_names_in_proper_english(classes)
|
||||
return TypeError(repr(value) + ' has wrong type for ' + class_names)
|
||||
|
||||
|
||||
def _value_error(value, error, *classes):
|
||||
class_names = _class_names_in_proper_english(classes)
|
||||
return ValueError(repr(value) + ' ' + error + ' ' + class_names)
|
||||
|
||||
|
||||
@_total_ordering
|
||||
class HWAddress:
|
||||
"""Base class for hardware addresses.
|
||||
|
||||
Can be subclassed to create new address types
|
||||
by just defining a couple class attribures.
|
||||
|
||||
Attributes:
|
||||
size: An integer defined by each subclass to specify the size
|
||||
(in bits) of the hardware address.
|
||||
formats: A sequence of format strings defined by each subclass
|
||||
to specify what formats the class can parse. The first
|
||||
format string is also used for ``repr`` and ``str`` output.
|
||||
Each "x" in each format string stands for one hexadecimal
|
||||
digit. All other characters are literal. For example, for
|
||||
MAC addresses, the format strings are "xx-xx-xx-xx-xx-xx",
|
||||
"xx:xx:xx:xx:xx:xx", "xxxx.xxxx.xxxx", and "xxxxxxxxxxxx".
|
||||
"""
|
||||
|
||||
__slots__ = ('_address', '__weakref__')
|
||||
|
||||
formats = ()
|
||||
|
||||
def __init__(self, address):
|
||||
"""Initialize the hardware address object with the address given.
|
||||
|
||||
Arguments:
|
||||
address: An ``int``, ``bytes``, or ``str`` representation of
|
||||
the address, or another instance of an address which is
|
||||
either the same class, a subclass, or a superclass. If a
|
||||
string, the ``formats`` attribute of the class is used
|
||||
to parse it. If a byte string, it is read in big-endian.
|
||||
If an integer, its value bytes in big-endian are used as
|
||||
the address bytes.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``address`` is not one of the valid types.
|
||||
ValueError: If ``address`` is a string but does not match
|
||||
one of the formats, if ``address`` is a byte string
|
||||
but does not match the size, or if ``address`` is an
|
||||
integer with a value that is negative or too big.
|
||||
"""
|
||||
if isinstance(address, int):
|
||||
overflow = 1 << type(self).size
|
||||
if address >= overflow:
|
||||
raise _value_error(address, 'is too big for', type(self))
|
||||
if address < 0:
|
||||
raise ValueError('hardware address cannot be negative')
|
||||
self._address = address
|
||||
elif isinstance(address, bytes):
|
||||
length = len(address)
|
||||
size_in_bytes = (type(self).size + 7) >> 3
|
||||
if length != size_in_bytes:
|
||||
raise _value_error(address, 'has wrong length for', type(self))
|
||||
offset = (8 - type(self).size) & 7
|
||||
self._address = int.from_bytes(address, 'big') >> offset
|
||||
elif isinstance(address, str) and len(type(self).formats):
|
||||
self._address, _ = _parse(address, type(self))
|
||||
# Subclass being "cast" to superclass:
|
||||
elif isinstance(address, type(self)):
|
||||
self._address = int(address)
|
||||
# Superclass being "cast" to subclass:
|
||||
elif (isinstance(address, HWAddress)
|
||||
and isinstance(self, type(address))):
|
||||
self._address = int(address)
|
||||
else:
|
||||
raise _type_error(address, type(self))
|
||||
|
||||
def __repr__(self):
|
||||
"""Represent the hardware address as an unambiguous string."""
|
||||
try:
|
||||
address = repr(str(self))
|
||||
except TypeError:
|
||||
address = _hex(int(self), type(self).size)
|
||||
return _name(self) + '(' + address + ')'
|
||||
|
||||
def __str__(self):
|
||||
"""Get the canonical human-readable string of this hardware address."""
|
||||
formats = type(self).formats
|
||||
if not len(formats):
|
||||
raise TypeError(_name(self) + ' has no string format')
|
||||
result = []
|
||||
offset = (4 - type(self).size) & 3
|
||||
unconsumed_address_value = int(self) << offset
|
||||
for character in reversed(formats[0]):
|
||||
if character == 'x':
|
||||
nibble = unconsumed_address_value & 0xf
|
||||
result.append(_HEX_DIGITS[nibble])
|
||||
unconsumed_address_value >>= 4
|
||||
else:
|
||||
result.append(character)
|
||||
return ''.join(reversed(result))
|
||||
|
||||
def __bytes__(self):
|
||||
"""Get the big-endian byte string of this hardware address."""
|
||||
offset = (8 - type(self).size) & 7
|
||||
size_in_bytes = (type(self).size + 7) >> 3
|
||||
return (int(self) << offset).to_bytes(size_in_bytes, 'big')
|
||||
|
||||
def __int__(self):
|
||||
"""Get the raw integer value of this hardware address."""
|
||||
return self._address
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Check if this hardware address is equal to another.
|
||||
|
||||
Hardware addresses are equal if they are instances of the
|
||||
same class, and their raw bit strings are the same.
|
||||
"""
|
||||
if not isinstance(other, HWAddress):
|
||||
return NotImplemented
|
||||
return type(self) == type(other) and int(self) == int(other)
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Check if this hardware address is before another.
|
||||
|
||||
Hardware addresses are sorted by their raw bit strings,
|
||||
regardless of the exact hardware address class or size.
|
||||
|
||||
For example: ``OUI('00-00-00') < CDI32('00-00-00-00')``,
|
||||
and they both are less than ``OUI('00-00-01')``.
|
||||
|
||||
This order intuitively groups address prefixes like OUIs
|
||||
with (and just in front of) addresses like MAC addresses
|
||||
which have that prefix when sorting a list of them.
|
||||
"""
|
||||
if not isinstance(other, HWAddress):
|
||||
return NotImplemented
|
||||
class1 = type(self)
|
||||
class2 = type(other)
|
||||
size1 = class1.size
|
||||
size2 = class2.size
|
||||
bits1 = int(self)
|
||||
bits2 = int(other)
|
||||
if size1 > size2:
|
||||
bits2 <<= size1 - size2
|
||||
else:
|
||||
bits1 <<= size2 - size1
|
||||
return (bits1, size1, id(class1)) < (bits2, size2, id(class2))
|
||||
|
||||
def __hash__(self):
|
||||
"""Get the hash of this hardware address."""
|
||||
return hash((type(self), int(self)))
|
||||
|
||||
|
||||
def _hex(integer, bits):
|
||||
# Like the built-in function ``hex`` but pads the
|
||||
# output to ``bits`` worth of hex characters.
|
||||
#
|
||||
# Examples:
|
||||
# (integer=5, bits=32) -> '0x00000005'
|
||||
# (integer=0x1234, bits=32) -> '0x00001234'
|
||||
# (integer=0x1234, bits=16) -> '0x1234'
|
||||
return '0x' + hex((1 << (bits+3)) | integer)[3:]
|
||||
|
||||
|
||||
class OUI(HWAddress):
|
||||
"""Organizationally Unique Identifier."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
size = 24
|
||||
|
||||
formats = (
|
||||
'xx-xx-xx',
|
||||
'xx:xx:xx',
|
||||
'xxxxxx',
|
||||
)
|
||||
|
||||
|
||||
class _StartsWithOUI(HWAddress):
|
||||
__slots__ = ()
|
||||
|
||||
@property
|
||||
def oui(self):
|
||||
"""Get the OUI part of this hardware address."""
|
||||
return OUI(int(self) >> (type(self).size - OUI.size))
|
||||
|
||||
|
||||
class CDI32(_StartsWithOUI):
|
||||
"""32-bit Context Dependent Identifier (CDI-32)."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
size = 32
|
||||
|
||||
formats = (
|
||||
'xx-xx-xx-xx',
|
||||
'xx:xx:xx:xx',
|
||||
'xxxxxxxx',
|
||||
)
|
||||
|
||||
|
||||
class CDI40(_StartsWithOUI):
|
||||
"""40-bit Context Dependent Identifier (CDI-40)."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
size = 40
|
||||
|
||||
formats = (
|
||||
'xx-xx-xx-xx-xx',
|
||||
'xx:xx:xx:xx:xx',
|
||||
'xxxxxxxxxx',
|
||||
)
|
||||
|
||||
|
||||
class EUI48(_StartsWithOUI):
|
||||
"""48-Bit Extended Unique Identifier (EUI-48).
|
||||
|
||||
EUI-48 is also the modern official name for what
|
||||
many people are used to calling a "MAC address".
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
size = 48
|
||||
|
||||
formats = (
|
||||
'xx-xx-xx-xx-xx-xx',
|
||||
'xx:xx:xx:xx:xx:xx',
|
||||
'xxxx.xxxx.xxxx',
|
||||
'xxxxxxxxxxxx',
|
||||
)
|
||||
|
||||
|
||||
MAC = EUI48
|
||||
|
||||
|
||||
class EUI60(_StartsWithOUI):
|
||||
"""60-Bit Extended Unique Identifier (EUI-60)."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
size = 60
|
||||
|
||||
formats = (
|
||||
'x.x.x.x.x.x.x.x.x.x.x.x.x.x.x',
|
||||
'xx-xx-xx.x.x.x.x.x.x.x.x.x',
|
||||
'xxxxxxxxxxxxxxx',
|
||||
)
|
||||
|
||||
|
||||
class EUI64(_StartsWithOUI):
|
||||
"""64-Bit Extended Unique Identifier (EUI-64)."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
size = 64
|
||||
|
||||
formats = (
|
||||
'xx-xx-xx-xx-xx-xx-xx-xx',
|
||||
'xx:xx:xx:xx:xx:xx:xx:xx',
|
||||
'xxxx.xxxx.xxxx.xxxx',
|
||||
'xxxxxxxxxxxxxxxx',
|
||||
)
|
||||
|
||||
|
||||
def parse(value, *classes):
|
||||
"""Try parsing a value as several hardware address classes at once.
|
||||
|
||||
This lets you just write
|
||||
|
||||
address = macaddress.parse(user_input, EUI64, EUI48, ...)
|
||||
|
||||
instead of all of this:
|
||||
|
||||
try:
|
||||
address = macaddress.EUI64(user_input)
|
||||
except ValueError:
|
||||
try:
|
||||
address = macaddress.EUI48(user_input)
|
||||
except ValueError:
|
||||
...
|
||||
|
||||
Arguments:
|
||||
value: The value to parse as a hardware address. Either a
|
||||
string, byte string, or an instance of one of the classes.
|
||||
*classes: HWAddress subclasses to try to parse the string as.
|
||||
If the input address could parse as more than one of the
|
||||
classes, it is parsed as the first one.
|
||||
|
||||
Returns:
|
||||
HWAddress: The parsed hardware address if the value argument
|
||||
was a string or byte string, or the value argument itself
|
||||
if it was already an instance of one of the classes.
|
||||
|
||||
Raises:
|
||||
TypeError: If the value is not one of the valid types,
|
||||
or if no classes were passed in.
|
||||
ValueError: If the value could not be parsed as any
|
||||
of the given classes.
|
||||
"""
|
||||
if not classes:
|
||||
raise TypeError('parse() requires at least one class argument')
|
||||
if isinstance(value, str):
|
||||
address, cls = _parse(value, *classes)
|
||||
return cls(address)
|
||||
elif isinstance(value, bytes):
|
||||
max_size = len(value) * 8
|
||||
min_size = max_size - 7
|
||||
for cls in classes:
|
||||
if min_size <= cls.size <= max_size:
|
||||
return cls(value)
|
||||
raise _value_error(value, 'has wrong length for', *classes)
|
||||
elif isinstance(value, classes):
|
||||
return value
|
||||
raise _type_error(value, *classes)
|
||||
|
||||
|
||||
def _parse(string, *classes):
|
||||
length = len(string)
|
||||
if length < 1:
|
||||
raise ValueError('hardware address cannot be an empty string')
|
||||
candidates = {}
|
||||
for cls in classes:
|
||||
for format_ in cls.formats:
|
||||
if len(format_) == length:
|
||||
candidates.setdefault(format_, cls)
|
||||
candidates = sorted(candidates.items())
|
||||
address = 0
|
||||
start = 0
|
||||
end = len(candidates)
|
||||
for index in range(length):
|
||||
character = string[index]
|
||||
if character in _HEX_DIGITS:
|
||||
address <<= 4
|
||||
address += int(character, 16)
|
||||
character = 'x'
|
||||
elif character == 'x':
|
||||
character = ''
|
||||
while start < end and candidates[start][0][index] < character:
|
||||
start += 1
|
||||
while start < end and candidates[end - 1][0][index] > character:
|
||||
end -= 1
|
||||
if start >= end:
|
||||
raise _value_error(string, 'cannot be parsed as', *classes)
|
||||
_, cls = candidates[start]
|
||||
offset = (4 - cls.size) & 3
|
||||
address >>= offset
|
||||
return address, cls
|
78
main.py
Normal file
78
main.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
import idasen as ida
|
||||
from idasen.desk import IdasenDesk
|
||||
from macparse import macaddress
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
VERSION = "idasit 0.0.1"
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="CLI for controlling a desk.")
|
||||
|
||||
parser.add_argument('-m', '--mac', required=True, help="MAC address of the desk")
|
||||
parser.add_argument('--monitor', action='store_true', help="Print height and speed while you move the desk")
|
||||
parser.add_argument('--height', action='store_true', help="Print the desk height")
|
||||
parser.add_argument('--move', type=float, help="Move the desk to a specific position")
|
||||
|
||||
parser.add_argument('--discover', action='store_true', help="Print the desk MAC")
|
||||
parser.add_argument('--pair', action='store_true', help="Pair with the desk")
|
||||
|
||||
parser.add_argument('-v', '--version', action='version', version=VERSION)
|
||||
|
||||
args = parser.parse_args()
|
||||
return vars(args)
|
||||
|
||||
|
||||
|
||||
# MAC = 'F8:9D:F4:10:90:DF'
|
||||
# MAC = 'F8:9D:F4:10:90:D4'
|
||||
SIT = 0.7793
|
||||
STAND = 1.2116
|
||||
|
||||
async def pair_desk(MAC):
|
||||
async with IdasenDesk(MAC, exit_on_fail=True) as desk:
|
||||
await desk.pair()
|
||||
print('paired')
|
||||
|
||||
async def discover_desk():
|
||||
mac = await IdasenDesk.discover()
|
||||
print(mac)
|
||||
|
||||
|
||||
async def monitor(MAC) -> None:
|
||||
try:
|
||||
async with IdasenDesk(MAC, exit_on_fail=True) as desk:
|
||||
|
||||
async def printer(height: float, speed: float):
|
||||
print(f"{height:.3f} meters - {speed:.3f} meters/second", flush=True)
|
||||
|
||||
await desk.monitor(printer)
|
||||
while True:
|
||||
await asyncio.sleep(1000000)
|
||||
except (KeyboardInterrupt, asyncio.exceptions.CancelledError):
|
||||
pass
|
||||
|
||||
async def height(MAC):
|
||||
async with IdasenDesk(MAC, exit_on_fail=True) as desk:
|
||||
height = await desk.get_height()
|
||||
print(f"{height:.3f} meters")
|
||||
|
||||
async def move_to(MAC, position: float) -> None:
|
||||
async with IdasenDesk(MAC, exit_on_fail=True) as desk:
|
||||
await desk.move_to_target(target=position)
|
||||
def validate(mac):
|
||||
try:
|
||||
macaddress.MAC(mac)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_args()
|
||||
mac = args['mac']
|
||||
if not validate(mac):
|
||||
print(f'Not a valid MAC address. Use --discover to find the correct MAC address.')
|
||||
sys.exit(2)
|
||||
asyncio.run(discover_desk())
|
88
voluptuous/__init__.py
Normal file
88
voluptuous/__init__.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
"""Schema validation for Python data structures.
|
||||
|
||||
Given eg. a nested data structure like this:
|
||||
|
||||
{
|
||||
'exclude': ['Users', 'Uptime'],
|
||||
'include': [],
|
||||
'set': {
|
||||
'snmp_community': 'public',
|
||||
'snmp_timeout': 15,
|
||||
'snmp_version': '2c',
|
||||
},
|
||||
'targets': {
|
||||
'localhost': {
|
||||
'exclude': ['Uptime'],
|
||||
'features': {
|
||||
'Uptime': {
|
||||
'retries': 3,
|
||||
},
|
||||
'Users': {
|
||||
'snmp_community': 'monkey',
|
||||
'snmp_port': 15,
|
||||
},
|
||||
},
|
||||
'include': ['Users'],
|
||||
'set': {
|
||||
'snmp_community': 'monkeys',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
A schema like this:
|
||||
|
||||
>>> settings = {
|
||||
... 'snmp_community': str,
|
||||
... 'retries': int,
|
||||
... 'snmp_version': All(Coerce(str), Any('3', '2c', '1')),
|
||||
... }
|
||||
>>> features = ['Ping', 'Uptime', 'Http']
|
||||
>>> schema = Schema({
|
||||
... 'exclude': features,
|
||||
... 'include': features,
|
||||
... 'set': settings,
|
||||
... 'targets': {
|
||||
... 'exclude': features,
|
||||
... 'include': features,
|
||||
... 'features': {
|
||||
... str: settings,
|
||||
... },
|
||||
... },
|
||||
... })
|
||||
|
||||
Validate like so:
|
||||
|
||||
>>> schema({
|
||||
... 'set': {
|
||||
... 'snmp_community': 'public',
|
||||
... 'snmp_version': '2c',
|
||||
... },
|
||||
... 'targets': {
|
||||
... 'exclude': ['Ping'],
|
||||
... 'features': {
|
||||
... 'Uptime': {'retries': 3},
|
||||
... 'Users': {'snmp_community': 'monkey'},
|
||||
... },
|
||||
... },
|
||||
... }) == {
|
||||
... 'set': {'snmp_version': '2c', 'snmp_community': 'public'},
|
||||
... 'targets': {
|
||||
... 'exclude': ['Ping'],
|
||||
... 'features': {'Uptime': {'retries': 3},
|
||||
... 'Users': {'snmp_community': 'monkey'}}}}
|
||||
True
|
||||
"""
|
||||
|
||||
# flake8: noqa
|
||||
# fmt: off
|
||||
from voluptuous.schema_builder import *
|
||||
from voluptuous.util import *
|
||||
from voluptuous.validators import *
|
||||
|
||||
from voluptuous.error import * # isort: skip
|
||||
|
||||
# fmt: on
|
||||
|
||||
__version__ = '0.15.2'
|
||||
__author__ = 'alecthomas'
|
219
voluptuous/error.py
Normal file
219
voluptuous/error.py
Normal file
|
@ -0,0 +1,219 @@
|
|||
# fmt: off
|
||||
import typing
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Base validation exception."""
|
||||
|
||||
|
||||
class SchemaError(Error):
|
||||
"""An error was encountered in the schema."""
|
||||
|
||||
|
||||
class Invalid(Error):
|
||||
"""The data was invalid.
|
||||
|
||||
:attr msg: The error message.
|
||||
:attr path: The path to the error, as a list of keys in the source data.
|
||||
:attr error_message: The actual error message that was raised, as a
|
||||
string.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
path: typing.Optional[typing.List[typing.Hashable]] = None,
|
||||
error_message: typing.Optional[str] = None,
|
||||
error_type: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
Error.__init__(self, message)
|
||||
self._path = path or []
|
||||
self._error_message = error_message or message
|
||||
self.error_type = error_type
|
||||
|
||||
@property
|
||||
def msg(self) -> str:
|
||||
return self.args[0]
|
||||
|
||||
@property
|
||||
def path(self) -> typing.List[typing.Hashable]:
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
return self._error_message
|
||||
|
||||
def __str__(self) -> str:
|
||||
path = ' @ data[%s]' % ']['.join(map(repr, self.path)) if self.path else ''
|
||||
output = Exception.__str__(self)
|
||||
if self.error_type:
|
||||
output += ' for ' + self.error_type
|
||||
return output + path
|
||||
|
||||
def prepend(self, path: typing.List[typing.Hashable]) -> None:
|
||||
self._path = path + self.path
|
||||
|
||||
|
||||
class MultipleInvalid(Invalid):
|
||||
def __init__(self, errors: typing.Optional[typing.List[Invalid]] = None) -> None:
|
||||
self.errors = errors[:] if errors else []
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return 'MultipleInvalid(%r)' % self.errors
|
||||
|
||||
@property
|
||||
def msg(self) -> str:
|
||||
return self.errors[0].msg
|
||||
|
||||
@property
|
||||
def path(self) -> typing.List[typing.Hashable]:
|
||||
return self.errors[0].path
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
return self.errors[0].error_message
|
||||
|
||||
def add(self, error: Invalid) -> None:
|
||||
self.errors.append(error)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.errors[0])
|
||||
|
||||
def prepend(self, path: typing.List[typing.Hashable]) -> None:
|
||||
for error in self.errors:
|
||||
error.prepend(path)
|
||||
|
||||
|
||||
class RequiredFieldInvalid(Invalid):
|
||||
"""Required field was missing."""
|
||||
|
||||
|
||||
class ObjectInvalid(Invalid):
|
||||
"""The value we found was not an object."""
|
||||
|
||||
|
||||
class DictInvalid(Invalid):
|
||||
"""The value found was not a dict."""
|
||||
|
||||
|
||||
class ExclusiveInvalid(Invalid):
|
||||
"""More than one value found in exclusion group."""
|
||||
|
||||
|
||||
class InclusiveInvalid(Invalid):
|
||||
"""Not all values found in inclusion group."""
|
||||
|
||||
|
||||
class SequenceTypeInvalid(Invalid):
|
||||
"""The type found is not a sequence type."""
|
||||
|
||||
|
||||
class TypeInvalid(Invalid):
|
||||
"""The value was not of required type."""
|
||||
|
||||
|
||||
class ValueInvalid(Invalid):
|
||||
"""The value was found invalid by evaluation function."""
|
||||
|
||||
|
||||
class ContainsInvalid(Invalid):
|
||||
"""List does not contain item"""
|
||||
|
||||
|
||||
class ScalarInvalid(Invalid):
|
||||
"""Scalars did not match."""
|
||||
|
||||
|
||||
class CoerceInvalid(Invalid):
|
||||
"""Impossible to coerce value to type."""
|
||||
|
||||
|
||||
class AnyInvalid(Invalid):
|
||||
"""The value did not pass any validator."""
|
||||
|
||||
|
||||
class AllInvalid(Invalid):
|
||||
"""The value did not pass all validators."""
|
||||
|
||||
|
||||
class MatchInvalid(Invalid):
|
||||
"""The value does not match the given regular expression."""
|
||||
|
||||
|
||||
class RangeInvalid(Invalid):
|
||||
"""The value is not in given range."""
|
||||
|
||||
|
||||
class TrueInvalid(Invalid):
|
||||
"""The value is not True."""
|
||||
|
||||
|
||||
class FalseInvalid(Invalid):
|
||||
"""The value is not False."""
|
||||
|
||||
|
||||
class BooleanInvalid(Invalid):
|
||||
"""The value is not a boolean."""
|
||||
|
||||
|
||||
class UrlInvalid(Invalid):
|
||||
"""The value is not a URL."""
|
||||
|
||||
|
||||
class EmailInvalid(Invalid):
|
||||
"""The value is not an email address."""
|
||||
|
||||
|
||||
class FileInvalid(Invalid):
|
||||
"""The value is not a file."""
|
||||
|
||||
|
||||
class DirInvalid(Invalid):
|
||||
"""The value is not a directory."""
|
||||
|
||||
|
||||
class PathInvalid(Invalid):
|
||||
"""The value is not a path."""
|
||||
|
||||
|
||||
class LiteralInvalid(Invalid):
|
||||
"""The literal values do not match."""
|
||||
|
||||
|
||||
class LengthInvalid(Invalid):
|
||||
pass
|
||||
|
||||
|
||||
class DatetimeInvalid(Invalid):
|
||||
"""The value is not a formatted datetime string."""
|
||||
|
||||
|
||||
class DateInvalid(Invalid):
|
||||
"""The value is not a formatted date string."""
|
||||
|
||||
|
||||
class InInvalid(Invalid):
|
||||
pass
|
||||
|
||||
|
||||
class NotInInvalid(Invalid):
|
||||
pass
|
||||
|
||||
|
||||
class ExactSequenceInvalid(Invalid):
|
||||
pass
|
||||
|
||||
|
||||
class NotEnoughValid(Invalid):
|
||||
"""The value did not pass enough validations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TooManyValid(Invalid):
|
||||
"""The value passed more than expected validations."""
|
||||
|
||||
pass
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue