Compare commits
No commits in common. "master" and "screenshots" have entirely different histories.
master
...
screenshot
|
@ -1,18 +0,0 @@
|
|||
version: 2
|
||||
jobs:
|
||||
build:
|
||||
working_directory: ~/tuhi
|
||||
docker:
|
||||
- image: fedora:latest
|
||||
steps:
|
||||
- run:
|
||||
command: |
|
||||
dnf install -y meson gettext python3-devel pygobject3-devel python3-flake8 desktop-file-utils libappstream-glib python3-pytest python3-pyxdg python3-pyyaml python3-svgwrite
|
||||
- checkout
|
||||
- run:
|
||||
command: |
|
||||
meson builddir
|
||||
ninja -C builddir test
|
||||
- store_artifacts:
|
||||
path: ~/tuhi/builddir/meson-logs
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.flatpak*
|
||||
flatpak*
|
||||
tuhi.egg-info
|
||||
__pycache__
|
||||
*.swp
|
340
COPYING
|
@ -1,340 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Library General Public License instead.) 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
|
||||
this service 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 make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. 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.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
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
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the 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 a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE 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.
|
||||
|
||||
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
|
||||
convey 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 2 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, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision 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, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This 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 Library General
|
||||
Public License instead of this License.
|
404
DBusInterface.md
|
@ -1,404 +0,0 @@
|
|||
DBus Interface
|
||||
--------------
|
||||
|
||||
Tuhi has two main components. A DBus session daemon that handles
|
||||
communication with the device and a GTK application that provides the
|
||||
graphical user interface.
|
||||
|
||||
|
||||
The DBus session daemon uses the following interface:
|
||||
|
||||
```
|
||||
org.freedesktop.tuhi1.Manager
|
||||
|
||||
Property: Devices (ao)
|
||||
Array of object paths to known (previously registered, but not necessarily
|
||||
connected) devices. Note that a "registered" device is one that has been
|
||||
initialized via the Wacom SmartPad custom protocol. A device does not
|
||||
need to be paired over Bluetooth to register.
|
||||
|
||||
Property: Searching (b)
|
||||
Indicates whether the daemon is currently searching for devices.
|
||||
|
||||
This property is set to True when a StartSearching() request initiates
|
||||
the search for device connections. When the StartSearching() request
|
||||
completes upon timeout, or when StopSearching() is called, the property
|
||||
is set to False.
|
||||
|
||||
When a pariable device is found, the UnregisteredDevice signal is sent to
|
||||
the caller that initiated the search process.
|
||||
|
||||
Read-only
|
||||
|
||||
Property: JSONDataVersions (au)
|
||||
Specifies the JSON file format versions the server supports. The
|
||||
client must request one of these versions in Device.GetJSONData().
|
||||
|
||||
Read-only, constant
|
||||
|
||||
Method: StartSearch() -> ()
|
||||
Start searching for available devices ready for registering
|
||||
for an unspecified timeout. When the timeout expires or an error
|
||||
occurs, a SearchStopped signal is sent indicating success or error.
|
||||
|
||||
If a client that successfully initated a listening process calls
|
||||
StartSearching() again, that call is ignored and no signal is
|
||||
generated for that call.
|
||||
|
||||
Method: StopSearch() -> ()
|
||||
Stop listening to available devices ready for registering. If called after
|
||||
StartSearch() and before a SearchStopped signal has been received,
|
||||
this method triggers the SearchStopped signal. That signal indicates
|
||||
success or an error.
|
||||
|
||||
If this method is called before StartSearch() or after the
|
||||
SearchStopped signal, it is ignored and no signal is generated.
|
||||
|
||||
Note that between calling StopSearch() and the SearchStopped signal
|
||||
arriving, UnregisteredDevice signals may still arrive.
|
||||
|
||||
Signal: UnregisteredDevice(o)
|
||||
Indicates that a device can be registered. This signal may be
|
||||
sent after a StartSearch() call and before SearchStopped(). This
|
||||
signal is sent once per available device and only to the client that
|
||||
initiated the search process with StartSearch.
|
||||
|
||||
When this signal is sent, a org.freedesktop.tuhi1.Device object was
|
||||
created, the object path is the argument to this signal.
|
||||
|
||||
A client must immediately call Register() on that object if
|
||||
registering with that object is desired. See the documentation for
|
||||
that interface for details.
|
||||
|
||||
When the search timeout expires, the device may be removed by the
|
||||
daemon again. Note that until the device is registered, the device is not
|
||||
listed in the managers Devices property.
|
||||
|
||||
Signal: SearchStopped(i)
|
||||
Sent when the search has stopped. An argument of 0 indicates a
|
||||
successful termination of the search process, either when a device
|
||||
has been registered or the timeout expired.
|
||||
|
||||
If the errno is -EBUSY, the daemon is already searching for devices
|
||||
on behalf of another client. In this case, this client should wait for
|
||||
the Searching property to change and StartSearching() once the
|
||||
property is set to False.
|
||||
|
||||
Once this signal has been sent, all devices announced through
|
||||
UnregisteredDevice signals should be considered invalidated. Attempting to
|
||||
Register() one of the devices after the SearchStopped() signal may result
|
||||
in an error.
|
||||
|
||||
In case of error, the argument is a negative errno.
|
||||
|
||||
org.freedesktop.tuhi1.Device
|
||||
|
||||
Interface to a device known by tuhi. Each object in Manager.Devices
|
||||
implements this interface.
|
||||
|
||||
Property: BlueZDevice (o)
|
||||
Object path to the org.bluez.Device1 device that is this device.
|
||||
Read-only
|
||||
|
||||
Property: Dimensions (uu)
|
||||
The physical dimensions (width, height) in µm
|
||||
Read-only
|
||||
|
||||
Property: BatteryPercent (u)
|
||||
The last known battery charge level in percent. This charge level is
|
||||
only accurate when the BatteryState is other than Unknown.
|
||||
|
||||
When the BatteryState is Unknown and BatteryPercent is nonzero, the
|
||||
value is the last known percentage value.
|
||||
|
||||
Read-only
|
||||
|
||||
Property: BatteryState (u)
|
||||
An enum describing the battery state. Permitted enum values are
|
||||
|
||||
0: Unknown
|
||||
1: Charging
|
||||
2: Discharging
|
||||
|
||||
'Unknown' may refer to a state that could not be read, a state
|
||||
that has not yet been updated, or a state that has not updated within
|
||||
a daemon-internal time period. Thus, a device that is connected but
|
||||
does not regularly send battery updates may eventually switch to
|
||||
'Unknown'.
|
||||
|
||||
Read-only
|
||||
|
||||
Property: DrawingsAvailable (at)
|
||||
An array of timestamps of the available drawings. The timestamp of
|
||||
each drawing can be used as argument to GetJSONData(). Timestamps are
|
||||
in seconds since the Epoch and may be used to display information to
|
||||
the user or sort data.
|
||||
Read-only
|
||||
|
||||
Property: Listening (b)
|
||||
Indicates whether the daemon is currently listening for the device.
|
||||
|
||||
This property is set to True when a StartListening() request initiates
|
||||
the search for device connections. When the StartListening() request
|
||||
completes upon timeout, or when StopListening() is called, the property
|
||||
is set to False.
|
||||
|
||||
When the user press the button on the device, the daemon connects
|
||||
to the device, downloads all drawings from the device and disconnects
|
||||
from the device.
|
||||
|
||||
If successfull, the drawings are deleted from the device. The data is
|
||||
held by the daemon in non-persistent storage until the daemon is stopped
|
||||
or we run out of memory, whichever happens earlier.
|
||||
Use GetJSONData() to retrieve the data from the daemon.
|
||||
|
||||
DO NOT RELY ON THE DAEMON FOR PERMANENT STORAGE
|
||||
|
||||
When drawings become available from the device, the DrawingsAvailable
|
||||
property updates to the number of available drawings.
|
||||
When the button is pressed multiple times, any new data is appended
|
||||
to the existing list of drawings as long as this property is True.
|
||||
|
||||
Read-only
|
||||
|
||||
Property: Live(b)
|
||||
Indicates whether the device is currently in Live mode. When in live
|
||||
mode, the device does not store drawings internally for a later sync
|
||||
but instead fowards the events immediately, similar to a traditional
|
||||
graphics tablet. See StartLive() for more details.
|
||||
|
||||
Read-only
|
||||
|
||||
Method: Register() -> (i)
|
||||
Register the device. If the device is already registered, calls to
|
||||
this method immediately return success.
|
||||
|
||||
Otherwise, the device is registered and this function returns success (0)
|
||||
or a negative errno on failure.
|
||||
|
||||
Method: StartListening() -> ()
|
||||
Listen for data from this device and connect to the device when it
|
||||
becomes available. The daemon listens to the device until the client
|
||||
calls StopListening() or the client disconnects, whichever happens
|
||||
earlier.
|
||||
|
||||
The ListeningStopped signal is sent when the listening terminates,
|
||||
either on success or with an error. A client should handle this signal
|
||||
to be notified of any errors.
|
||||
|
||||
When the daemon starts listening, the Listening property is updated
|
||||
accordingly.
|
||||
|
||||
If a client that successfully initated a listening process calls
|
||||
StartListening() again, that call is ignored and no signal is
|
||||
generated for that call.
|
||||
|
||||
Method: StopListening() -> ()
|
||||
Stop listening for data on this device. If called after
|
||||
StartListening(), this method triggers the ListenStopped signal.
|
||||
That signal indicates success or an error.
|
||||
|
||||
If this method is called before StartListening() or after the
|
||||
ListeningStopped signal, it is ignored and no signal is generated.
|
||||
|
||||
Note that between calling StopListening() and the ListeningStopped
|
||||
signal arriving, the property DrawingsAvailable may still be updated
|
||||
and it's the responsibility of the client to fetch the JSON data.
|
||||
|
||||
Method: StartLive(fd: h) -> (i)
|
||||
Starts live mode on this device. This disables offline storage of
|
||||
drawing data on the device and instead switches the device to a mode
|
||||
where it immediately reports the pen data, similar to a traditional
|
||||
graphics tablet.
|
||||
|
||||
The LiveStopped signal is sent when live mode terminates, either on
|
||||
success or with an error. A client should handle this signal to be
|
||||
notified of any errors.
|
||||
|
||||
When live mode enables, the Live property is updated accordingly.
|
||||
|
||||
If a client that successfully initated a listening process calls
|
||||
StartListening() again, that call is ignored and no signal is
|
||||
generated for that call.
|
||||
|
||||
The fd argument is a file descriptor that will be used to forward
|
||||
events to. The format is the one used by the Linux kernel's UHID
|
||||
device, see linux/uhid.h for details.
|
||||
|
||||
Method: StopLive() - >()
|
||||
Stop live mode on this device. If called after StartLive(), this
|
||||
method triggers the LiveStopped signal. That signal indicates
|
||||
success or an error.
|
||||
|
||||
If this method is called before StartLive() or after the LiveStopped
|
||||
signal, it is ignored and no signal is generated.
|
||||
|
||||
Note that between calling StopLive() and the LiveStopped signal
|
||||
arriving, the device may still send events. It's the responsibility of
|
||||
the client to handle events until the LiveStopped signal arrives.
|
||||
|
||||
Method: GetJSONData(file-version: u, timestamp: t) -> (s)
|
||||
Returns a JSON file with the drawings specified by the timestamp
|
||||
argument. The requested timestamp must be one of the entries in the
|
||||
DrawingsAvailable property value. The file-version argument specifies
|
||||
the file format version the client requests. See section JSON FILE
|
||||
FORMAT for the format of the returned data.
|
||||
|
||||
Returns a string representing the JSON data from the last drawings or
|
||||
the empty string if the timestamp is not available or the file format
|
||||
version is outside the server-supported range advertised in
|
||||
Manager.JSONDataVersions.
|
||||
|
||||
Signal: ButtonPressRequired()
|
||||
Sent when the user is expected to press the physical button on the
|
||||
device. A client should display a notification in response, if the
|
||||
user does not press the button during the (firmware-specific) timeout
|
||||
the current operation will fail.
|
||||
|
||||
Signal: ListeningStopped(i)
|
||||
Sent when the listen process has stopped. An argument of 0 indicates a
|
||||
successful termination, i.e. in response to the client calling
|
||||
StopListening(). Otherwise, the argument is a negative errno
|
||||
indicating the type of error.
|
||||
|
||||
If the errno is -EBUSY, the daemon is already listening to the device
|
||||
on behalf of another client. In this case, this client should wait for
|
||||
the Listening property to change and StartListening() once the
|
||||
property is set to False.
|
||||
|
||||
If the error is -EBADE, the device is not ready for registering/in
|
||||
listening mode and registration/listening was requested. In
|
||||
this case, the client should indicate to the user that the device
|
||||
needs to be registered first or switched to listening mode.
|
||||
|
||||
If the error is -EACCES, the device is not registered with the daemon
|
||||
or incorrectly registered. This may happen when the device was
|
||||
registered with another host since the last connection.
|
||||
|
||||
The following other errnos may be sent by the daemon:
|
||||
-EPROTO: the daemon has encountered a protocol error with the device.
|
||||
-ETIME: timeout while communicating with the device.
|
||||
|
||||
These errnos indicate a bug in the daemon, and the client should
|
||||
display a message to that effect.
|
||||
|
||||
Signal: LiveStopped(i)
|
||||
Sent when live mode is stopped. An argument of 0 indicates a
|
||||
successful termination, i.e. in response to the client calling
|
||||
StopLive(). Otherwise, the argument is a negative errno
|
||||
indicating the type of error.
|
||||
|
||||
If the errno is -EBUSY, the daemon has already enabled live mode on
|
||||
device on behalf of another client. In this case, this client should
|
||||
wait for the Live property to change and StartLive() once the property
|
||||
is set to False.
|
||||
|
||||
If the error is -EBADE, the device is not ready for live mode, most
|
||||
likely because it is in registration mode. In this case, the client
|
||||
should indicate to the user that the device needs to be registered
|
||||
first.
|
||||
|
||||
If the error is -EACCES, the device is not registered with the daemon
|
||||
or incorrectly registered. This may happen when the device was
|
||||
registered with another host since the last connection.
|
||||
|
||||
The following other errnos may be sent by the daemon:
|
||||
-EPROTO: the daemon has encountered a protocol error with the device.
|
||||
-ETIME: timeout while communicating with the device.
|
||||
|
||||
These errnos indicate a bug in the daemon, and the client should
|
||||
display a message to that effect.
|
||||
|
||||
Signal: SyncState(i)
|
||||
An enum to represent the current synchronization state of the device.
|
||||
When on (1), Tuhi is currently trying to download data from the
|
||||
device. When off (0), Tuhi is not currently connecting to the device.
|
||||
|
||||
This signal should be used for UI feedback.
|
||||
|
||||
This signal is only send when the device is **not** in Live mode.
|
||||
```
|
||||
|
||||
JSON File Format
|
||||
----------------
|
||||
|
||||
The current file format version is 1. A server may only support a subset of
|
||||
historical file formats, this subset is advertized as list of versions in
|
||||
the **org.freedesktop.tuhi1.Manager.JSONDataVersions** property. Likewise, a
|
||||
client may only support a subset of the possible formats. A client should
|
||||
always pick the highest format supported by both the client and the server.
|
||||
|
||||
Below is the example file format (with comments, not present in the real
|
||||
files). The JSON objects are "drawing" (the root object), "strokes",
|
||||
"points". Pseudo-code is used to illustrate the objects in the file.
|
||||
|
||||
```
|
||||
class Drawing {
|
||||
version: uint32
|
||||
devicename: string
|
||||
sessionid: string // used for debugging
|
||||
dimensions: [uint32, uint32] // x/y physical dimensions in µm
|
||||
timestamp: uint64
|
||||
strokes: [ Stroke, Stroke, ...]
|
||||
}
|
||||
```
|
||||
|
||||
A session id is a random string that identifies a Tuhi session. This is
|
||||
debugging information only, it makes it possible to associate a JSON file
|
||||
with the corresponding sequence in the log. Do not use in clients.
|
||||
|
||||
The **strokes** list contains all strokes of a single drawing, each stroke
|
||||
consisting of a number of **points**.
|
||||
|
||||
```
|
||||
class Stroke {
|
||||
points: [Point, Point, ...]
|
||||
}
|
||||
```
|
||||
|
||||
The **points** list contains the actual pen data.
|
||||
|
||||
```
|
||||
class Point {
|
||||
toffset: uint32
|
||||
position: [uint32, uint32]
|
||||
pressure: uint32
|
||||
}
|
||||
```
|
||||
|
||||
An expanded file looks like this:
|
||||
|
||||
```
|
||||
{
|
||||
"version" : 1, // JSON file format version number
|
||||
"devicename": "Wacom Bamboo Spark",
|
||||
"sessionid": "somerandomstring-1",
|
||||
"dimensions": [ 100000, 200000], // width/height in µm
|
||||
"timestamp" : 12345,
|
||||
"strokes" : [
|
||||
{
|
||||
"points": [
|
||||
// all items in a point are optional. Unknown dictionary
|
||||
// entries must be ignored as future devices may add
|
||||
// new axes.
|
||||
{ "toffset" : 12366, "position" : [ 100, 200 ], "pressure" : 1000 },
|
||||
{ "toffset" : 12368, "pressure" : 800 },
|
||||
{ "toffset" : 12366, "position" : [ 120, 202 ] },
|
||||
]
|
||||
},
|
||||
{ "points" : ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Units used by this interface
|
||||
----------------------------
|
||||
|
||||
* Physical distances for x/y axes are in µm from the sensor's top-left
|
||||
position. (Note that on the Spark and on the Slate at least, the sensor
|
||||
is turned 90 degrees clockwise, so (0,0) is at the 'natural' top-right
|
||||
corner)
|
||||
* Stylus pressure is normalized to a range of [0, 0xffff], inclusive.
|
||||
* Timestamps are in seconds in unix epoch, time offsets are in ms after the
|
||||
most recent timestamp.
|
||||
|
180
README.md
|
@ -1,179 +1 @@
|
|||
![tuhi-logo](data/org.freedesktop.Tuhi.svg)
|
||||
|
||||
Tuhi
|
||||
=====
|
||||
|
||||
Tuhi is a GTK application that connects to and fetches the data from the
|
||||
Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). Users can save the
|
||||
data as SVGs.
|
||||
|
||||
Tuhi is the Māori word for "to draw".
|
||||
|
||||
Supported Devices
|
||||
-----------------
|
||||
|
||||
Devices tested and known to be supported:
|
||||
|
||||
* Bamboo Spark
|
||||
* Bamboo Slate
|
||||
* Intuos Pro Paper
|
||||
|
||||
Building Tuhi
|
||||
-------------
|
||||
|
||||
To build and run Tuhi from the repository directly:
|
||||
|
||||
```
|
||||
$> git clone http://github.com/tuhiproject/tuhi
|
||||
$> cd tuhi
|
||||
$> meson builddir
|
||||
$> ninja -C builddir
|
||||
$> ./builddir/tuhi.devel
|
||||
```
|
||||
|
||||
Tuhi requires Python v3.6 or above.
|
||||
|
||||
Installing Tuhi
|
||||
---------------
|
||||
|
||||
To install and run Tuhi:
|
||||
|
||||
```
|
||||
$> git clone http://github.com/tuhiproject/tuhi
|
||||
$> cd tuhi
|
||||
$> meson builddir
|
||||
$> ninja -C builddir install
|
||||
```
|
||||
|
||||
Run Tuhi with:
|
||||
|
||||
```
|
||||
$> tuhi
|
||||
```
|
||||
|
||||
Tuhi requires Python v3.6 or above.
|
||||
|
||||
Flatpak
|
||||
-------
|
||||
|
||||
```
|
||||
$> git clone http://github.com/tuhiproject/tuhi
|
||||
$> cd tuhi
|
||||
$> flatpak-builder flatpak_builddir org.freedesktop.Tuhi.json --install --user --force-clean
|
||||
$> flatpak run org.freedesktop.Tuhi
|
||||
```
|
||||
|
||||
Note that Flatpak's containers use different XDG directories. This affects
|
||||
Tuhi being able to remember devices and the data storage. Switching between
|
||||
the Flatpak and a normal installation requires re-registering the device and
|
||||
previously downloaded drawings may become inaccessible.
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
Tuhi is licensed under the GPLv2 or later.
|
||||
|
||||
Registering devices
|
||||
-------------------
|
||||
|
||||
For a device to work with Tuhi, it must be registered first. This is
|
||||
achieved by holiding the device button for 6 or more seconds until the blue
|
||||
LED starts blinking. Only in that mode can Tuhi detect it during
|
||||
`Searching` and register it.
|
||||
|
||||
Registration sends a randomly generated UUID to the device. Subsequent
|
||||
connections must use that UUID as identifier for the tablet device to
|
||||
respond. Without knowing that UUID, other applications cannot connect.
|
||||
|
||||
A device can only be registered with one application at a time. Thus, when a
|
||||
device is registered with Tuhi, other applications (e.g. Wacom Inkspace)
|
||||
cannot not connect to the device anymore. Likewise, when registered with
|
||||
another application, Tuhi cannot connect.
|
||||
|
||||
To make the tablet connect again, simply re-register with the respective
|
||||
application or Tuhi, whichever desired.
|
||||
|
||||
This is not registering the device with some cloud service, vendor, or
|
||||
other networked service. It is a communication between Tuhi and the firmware
|
||||
on the device only. It is merely a process of "your ID is now $foo" followed
|
||||
by "hi $foo, I want to connect".
|
||||
|
||||
The word "register" was chosen because "pairing" is already in use by
|
||||
Bluetooth.
|
||||
|
||||
Packages
|
||||
--------
|
||||
|
||||
Arch Linux: [tuhi-git](https://aur.archlinux.org/packages/tuhi-git/)
|
||||
|
||||
Device notes
|
||||
============
|
||||
|
||||
When following any device notes below, replace the example bluetooth
|
||||
addresses with your device's bluetooth address.
|
||||
|
||||
Bamboo Spark
|
||||
------------
|
||||
|
||||
The Bluetooth connection on the Bamboo Spark behaves differently depending
|
||||
on whether there are drawings pending or not. Generally, if no drawings are
|
||||
pending, it is harder to connect to the device. Save yourself the pain and
|
||||
make sure you have drawings pending while debugging.
|
||||
|
||||
### If the device has no drawings available:
|
||||
|
||||
* start `bluetoothctl`, commands below are to be issued in its interactive shell
|
||||
* enable discovery mode (`scan on`)
|
||||
* hold the Bamboo Spark button until the blue light is flashing
|
||||
* You should see the device itself show up, but none of its services
|
||||
```
|
||||
[NEW] Device E2:43:03:67:0E:01 Bamboo Spark
|
||||
```
|
||||
* While the LED is still flashing, `connect E2:43:03:67:0E:01`
|
||||
```
|
||||
Attempting to connect to E2:43:03:67:0E:01
|
||||
[CHG] Device E2:43:03:67:0E:01 Connected: yes
|
||||
... lots of services being resolved
|
||||
[CHG] Device E2:43:03:67:0E:01 ServicesResolved: yes
|
||||
[CHG] Device E2:43:03:67:0E:01 ServicesResolved: no
|
||||
[CHG] Device E2:43:03:67:0E:01 Connected: no
|
||||
```
|
||||
Note how the device disconnects again at the end. Doesn't matter, now you
|
||||
have the services cached.
|
||||
* Don't forget to eventually turn disable discovery mode off (`scan off`)
|
||||
|
||||
Now you have the device cached in bluez and you can work with that data.
|
||||
However, you **cannot connect to the device while it has no drawings
|
||||
pending**. Running `connect` and pressing the Bamboo Spark button shortly
|
||||
does nothing.
|
||||
|
||||
### If the device has drawings available:
|
||||
|
||||
* start `bluetoothctl`, commands below are to be issued in its interactive shell
|
||||
* enable discovery mode (`scan on`)
|
||||
* press the Bamboo Spark button shortly
|
||||
* You should see the device itself show up, but none of its services
|
||||
```
|
||||
[NEW] Device E2:43:03:67:0E:01 Bamboo Spark
|
||||
```
|
||||
* `connect E2:43:03:67:0E:01`, then press the Bamboo Spark button
|
||||
```
|
||||
Attempting to connect to E2:43:03:67:0E:01
|
||||
[CHG] Device E2:43:03:67:0E:01 Connected: yes
|
||||
... lots of services being resolved
|
||||
[CHG] Device E2:43:03:67:0E:01 ServicesResolved: yes
|
||||
[CHG] Device E2:43:03:67:0E:01 ServicesResolved: no
|
||||
[CHG] Device E2:43:03:67:0E:01 Connected: no
|
||||
```
|
||||
Note how the device disconnects again at the end. Doesn't matter, now you
|
||||
have the services cached.
|
||||
* `connect E2:43:03:67:0E:01`, then press the Bamboo Spark button re-connects to the device
|
||||
The device will disconnect after approximately 10s. You need to start
|
||||
issuing the commands to talk to the controller before that happens.
|
||||
* Don't forget to eventually turn disable discovery mode off (`scan off`)
|
||||
|
||||
You **must** run `connect` before pressing the button. Just pressing the
|
||||
button does nothing unless bluez is trying to connect to the device.
|
||||
|
||||
**Warning**: A successful communication with the controller deletes the
|
||||
drawings from the controller, so you may not be able to re-connect.
|
||||
This branch is for keeping screenshots that we want to use in the wiki
|
||||
|
|
After Width: | Height: | Size: 5.8 KiB |
|
@ -1,86 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg10"
|
||||
sodipodi:docname="input-tablet-missing-symbolic.svg"
|
||||
inkscape:version="0.92.4 (unknown)">
|
||||
<metadata
|
||||
id="metadata16">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs14" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1016"
|
||||
id="namedview12"
|
||||
showgrid="false"
|
||||
inkscape:zoom="29.5"
|
||||
inkscape:cx="15.281847"
|
||||
inkscape:cy="7.8893951"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg10" />
|
||||
<g
|
||||
color="#000"
|
||||
font-weight="400"
|
||||
font-family="sans-serif"
|
||||
white-space="normal"
|
||||
fill="#2e3436"
|
||||
id="g8"
|
||||
style="fill:#474747;fill-opacity:1;opacity:0.35">
|
||||
<path
|
||||
d="M12.403 4.15L7.45 9.102 7 11l1.87-.39 5-5c.549-.593.63-1.11.13-1.61s-1.17-.278-1.597.15z"
|
||||
style="line-height:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;isolation:auto;mix-blend-mode:normal;marker:none;fill:#474747;fill-opacity:1"
|
||||
clip-rule="evenodd"
|
||||
overflow="visible"
|
||||
fill-rule="evenodd"
|
||||
image-rendering="optimizeQuality"
|
||||
shape-rendering="geometricPrecision"
|
||||
text-rendering="geometricPrecision"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M2.191 6L.263 15h15.473L14 6.897 12.316 8.58l.946 4.42H2.738l1.07-5h3.33l1.999-2zm9.776 0l-2 2h.1l2-2z"
|
||||
style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;fill:#474747;fill-opacity:1"
|
||||
overflow="visible"
|
||||
fill-rule="evenodd"
|
||||
id="path4" />
|
||||
<path
|
||||
d="M8 0v1.5a.506.506 0 0 1-.147.356.484.484 0 0 1-.345.144H4.496a1.498 1.498 0 0 0-1.297 2.248c.266.463.761.752 1.295.752h3.01c.18 0 .34.092.43.248A.506.506 0 0 1 8 5.5V6h1v-.5A1.498 1.498 0 0 0 7.504 4h-3.01a.491.491 0 0 1-.428-.248.507.507 0 0 1 0-.504.488.488 0 0 1 .43-.248h3.012c.396 0 .777-.16 1.056-.441C8.844 2.277 9 1.897 9 1.5V0z"
|
||||
style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;fill:#474747;fill-opacity:1"
|
||||
overflow="visible"
|
||||
id="path6" />
|
||||
</g>
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4581"
|
||||
overflow="visible"
|
||||
style="color:#bebebe;overflow:visible;fill:#474747;marker:none"
|
||||
d="m 11,11 h 1.375 L 13.5,12.094 14.594,11 H 16 v 1.469 L 14.906,13.531 16,14.594 V 16 H 14.562 L 13.5,14.937 12.437,16 H 11 V 14.594 L 12.063,13.531 11,12.47 Z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 4.0 KiB |
|
@ -1,41 +0,0 @@
|
|||
gnome = import('gnome')
|
||||
|
||||
metainfodir = join_paths(datadir, 'metainfo')
|
||||
|
||||
conf = configuration_data()
|
||||
conf.set('version', meson.project_version())
|
||||
conf.set('url', 'https://github.com/tuhiproject/tuhi')
|
||||
conf.set('version_date', version_date)
|
||||
|
||||
about_dialog = configure_file(input: 'ui/AboutDialog.ui.in',
|
||||
output: 'AboutDialog.ui',
|
||||
configuration: conf)
|
||||
|
||||
install_data('org.freedesktop.Tuhi.svg', install_dir: icondir_scalable)
|
||||
install_data('org.freedesktop.Tuhi-symbolic.svg', install_dir: icondir_symbolic)
|
||||
|
||||
i18n.merge_file(input: 'org.freedesktop.Tuhi.desktop.in',
|
||||
output: 'org.freedesktop.Tuhi.desktop',
|
||||
type: 'desktop',
|
||||
po_dir: podir,
|
||||
install: true,
|
||||
install_dir: desktopdir)
|
||||
|
||||
appdata = configure_file(input: 'org.freedesktop.Tuhi.appdata.xml.in.in',
|
||||
output: 'org.freedesktop.Tuhi.appdata.xml.in',
|
||||
configuration: conf)
|
||||
|
||||
i18n.merge_file(input: appdata,
|
||||
output: 'org.freedesktop.Tuhi.appdata.xml',
|
||||
type: 'xml',
|
||||
po_dir: podir,
|
||||
install: true,
|
||||
install_dir: metainfodir)
|
||||
|
||||
|
||||
gnome.compile_resources('tuhi', 'tuhi.gresource.xml',
|
||||
source_dir: '.',
|
||||
dependencies: [about_dialog],
|
||||
gresource_bundle: true,
|
||||
install: true,
|
||||
install_dir: pkgdatadir)
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" version="1.0"><path style="marker:none" d="M8 1a7 7 0 0 0-7 7 7 7 0 0 0 7 7 7 7 0 0 0 7-7 7 7 0 0 0-7-7zm0 1.75a5.25 5.25 0 0 1 3.063.99v3.102L9.97 8.396C9.334 8.164 8.67 8 8 8c-.694 0-1.332.194-1.906.484l-1.14-1.627V3.732A5.25 5.25 0 0 1 8 2.75zM3.953 4.658v2.358a.5.5 0 0 0 .092.287L5.25 9.018a8.634 8.634 0 0 0-1.717 1.734A5.25 5.25 0 0 1 2.75 8a5.25 5.25 0 0 1 1.203-3.342zm8.11.02A5.25 5.25 0 0 1 13.25 8a5.25 5.25 0 0 1-.404 2.018A12.62 12.62 0 0 0 10.904 8.8l1.067-1.514A.5.5 0 0 0 12.062 7V4.678zM5.625 9.55l1.967 2.799a.5.5 0 0 0 .816 0l1.826-2.596c.748.527 1.294 1.212 1.643 1.777A5.25 5.25 0 0 1 8 13.25a5.25 5.25 0 0 1-4.379-2.36 7.701 7.701 0 0 1 2.004-1.34z"/></svg>
|
Before Width: | Height: | Size: 743 B |
|
@ -1,55 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>org.freedesktop.Tuhi</id>
|
||||
<metadata_license>FSFAP</metadata_license>
|
||||
<project_license>GPL-2.0+</project_license>
|
||||
<content_rating type="oars-1.0" />
|
||||
<name>Tuhi</name>
|
||||
<summary>Utility to download drawings from the Wacom Ink range of devices</summary>
|
||||
<description>
|
||||
<p>
|
||||
Tuhi is a graphical user interface to download drawings stored on
|
||||
tablet devices from the Wacom Ink range, e.g. Intuos Pro Paper or
|
||||
Bamboo Slate.
|
||||
</p>
|
||||
<p>
|
||||
Tuhi requires Tuhi, the daemon to actually communicate with the
|
||||
devices. ThiGui is merely a front end to Tuhi, Tuhi must be
|
||||
installed and running when Tuhi is launched.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<kudos>
|
||||
<kudo>AppMenu</kudo>
|
||||
<kudo>HiDpiIcon</kudo>
|
||||
<kudo>ModernToolkit</kudo>
|
||||
</kudos>
|
||||
|
||||
<launchable type="desktop-id">org.freedesktop.Tuhi.desktop</launchable>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Tuhi's main window</caption>
|
||||
<image>https://raw.githubusercontent.com/tuhiproject/tuhi/screenshots/main-window.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Tuhi's main window (zoomed)</caption>
|
||||
<image>https://raw.githubusercontent.com/tuhiproject/tuhi/screenshots/main-window-zoomed.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<url type="homepage">https://github.com/tuhiproject/tuhi/</url>
|
||||
<url type="bugtracker">https://github.com/tuhiproject/tuhi/issues</url>
|
||||
<url type="help">https://github.com/tuhiproject/tuhi/wiki</url>
|
||||
<project_group>GNOME</project_group>
|
||||
|
||||
<translation type="gettext">tuhi</translation>
|
||||
|
||||
<provides>
|
||||
<binary>tuhi</binary>
|
||||
</provides>
|
||||
|
||||
<releases>
|
||||
<release version="@version@" date="@version_date@"/>
|
||||
</releases>
|
||||
</component>
|
|
@ -1,11 +0,0 @@
|
|||
[Desktop Entry]
|
||||
Name=Tuhi
|
||||
Comment=Utility to download drawings from the Wacom Ink range of devices
|
||||
Exec=tuhi
|
||||
# Translators: Do NOT translate or transliterate this text (this is an icon file name)!
|
||||
Icon=org.freedesktop.Tuhi
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
Categories=GTK;GNOME;Utility;
|
||||
# Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon!
|
||||
Keywords=tablet;wacom;ink;
|
Before Width: | Height: | Size: 10 KiB |
|
@ -1,343 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="90.545494mm"
|
||||
height="108.63927mm"
|
||||
viewBox="0 0 90.545494 108.63927"
|
||||
version="1.1"
|
||||
id="svg4220"
|
||||
inkscape:version="0.92.4 (unknown)"
|
||||
sodipodi:docname="tuhi-logo.svg">
|
||||
<defs
|
||||
id="defs4214">
|
||||
<filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter2743">
|
||||
<feBlend
|
||||
inkscape:collect="always"
|
||||
mode="multiply"
|
||||
in2="BackgroundImage"
|
||||
id="feBlend2745" />
|
||||
</filter>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient2824"
|
||||
id="radialGradient2826"
|
||||
cx="187.53024"
|
||||
cy="400.20236"
|
||||
fx="187.53024"
|
||||
fy="400.20236"
|
||||
r="125.53025"
|
||||
gradientTransform="matrix(2.1630662,0,0,2.2573179,-218.11009,-508.31733)"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient2824">
|
||||
<stop
|
||||
style="stop-color:#99c1f1;stop-opacity:1"
|
||||
offset="0"
|
||||
id="stop2820" />
|
||||
<stop
|
||||
style="stop-color:#ffffff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop2822" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient19060"
|
||||
id="radialGradient19062"
|
||||
cx="312.94751"
|
||||
cy="468.45197"
|
||||
fx="312.94751"
|
||||
fy="468.45197"
|
||||
r="31.197359"
|
||||
gradientTransform="matrix(7.4428525,-0.06410799,0.03419264,3.9697205,-2030.4372,-1291.0653)"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient19060">
|
||||
<stop
|
||||
style="stop-color:#f6f5f4;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop19056" />
|
||||
<stop
|
||||
style="stop-color:#f6f5f4;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop19058" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient19068"
|
||||
id="radialGradient19070"
|
||||
cx="361.52719"
|
||||
cy="468.23438"
|
||||
fx="361.52719"
|
||||
fy="468.23438"
|
||||
r="26.937635"
|
||||
gradientTransform="matrix(21.848521,-1.5084157e-8,0,10.64316,-7579.1667,-4418.1367)"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient19068">
|
||||
<stop
|
||||
style="stop-color:#deddda;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop19064" />
|
||||
<stop
|
||||
style="stop-color:#deddda;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop19066" />
|
||||
</linearGradient>
|
||||
<inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect2775"
|
||||
is_visible="true" />
|
||||
<inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect2812"
|
||||
is_visible="true" />
|
||||
<inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect2787"
|
||||
is_visible="true" />
|
||||
<inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect2793"
|
||||
is_visible="true" />
|
||||
<inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect2832"
|
||||
is_visible="true" />
|
||||
<inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect919"
|
||||
is_visible="true" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4"
|
||||
inkscape:cx="198.2222"
|
||||
inkscape:cy="289.89222"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1016"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata4217">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-77.191538,-69.56727)">
|
||||
<g
|
||||
id="g916"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,36.4195,-13.303175)">
|
||||
<text
|
||||
transform="matrix(11.258872,0,0,11.258872,-3120.4945,-7002.1351)"
|
||||
id="text2749"
|
||||
y="685.86847"
|
||||
x="299.15558"
|
||||
style="font-style:normal;font-variant:normal;font-weight:800;font-stretch:normal;font-size:4.00001669px;line-height:1.25;font-family:Montserrat;-inkscape-font-specification:'Montserrat, Ultra-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;filter:url(#filter2743)"
|
||||
xml:space="preserve"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:800;font-stretch:normal;font-size:4.00001669px;font-family:Montserrat;-inkscape-font-specification:'Montserrat, Ultra-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:1.43622279px;writing-mode:lr-tb;text-anchor:start"
|
||||
y="685.86847"
|
||||
x="299.15558"
|
||||
id="tspan2747"
|
||||
sodipodi:role="line">TUHI</tspan></text>
|
||||
<circle
|
||||
r="164.75587"
|
||||
cy="482.96701"
|
||||
cx="323.85492"
|
||||
id="path2769"
|
||||
style="opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
sodipodi:nodetypes="cssccscssc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2818"
|
||||
d="m 192,387.06666 c 0,0 24,18 29,33 5,15 -16,36 -11,60 5,24 102,117 102,117 l 37,-39 c 0,0 26,-11 45,12 19,23 17,51 17,51 0,0 -33.66769,28 -88,28 -86.00581,0 -163,-75.02518 -163,-148 0,-84.14868 32,-114 32,-114 z"
|
||||
style="opacity:1;vector-effect:none;fill:url(#radialGradient2826);fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
sodipodi:nodetypes="ccscsc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2814"
|
||||
d="m 478,432.06666 -121,123 c 0,0 29.40381,8.59099 40.40381,23.59099 11,15 19.59619,38.40901 19.59619,38.40901 0,0 45.01692,-46.29522 60,-69 17.74207,-26.88564 19.94072,-117.72188 1,-116 z"
|
||||
style="opacity:1;vector-effect:none;fill:#2ec27e;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2816"
|
||||
d="m 254,335.06666 v 202 c 0,0 -38,-23 -41,-55 -3,-32 19,-33 10,-56 -9,-23 -30,-43 -30,-43 0,0 19,-28 36,-37 17,-9 25,-11 25,-11 z"
|
||||
style="opacity:1;vector-effect:none;fill:#ffbe6f;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:url(#radialGradient19062);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.01129822px;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="M 281.60528,565.34496 V 323.06666 L 339.73001,317.64617 344,565.06666 Z"
|
||||
id="path19034"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccccc" />
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:#26a269;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="M 383.70593,329.28221 392.5,433.56666 386.93816,519.77377 478,432.06666 c 0,0 -6.16678,-39.80619 -36.05826,-62.23672 -29.89149,-22.43054 -58.23581,-40.54773 -58.23581,-40.54773 z"
|
||||
id="path1029"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
sodipodi:nodetypes="ccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path19036"
|
||||
d="m 393.60528,515.34496 -12,-189.2783 -41.87527,-8.42049 L 344,565.06666 Z"
|
||||
style="opacity:1;vector-effect:none;fill:url(#radialGradient19070);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.01129822px;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2771"
|
||||
d="M 251.73001,331.64617 V 535.4697 l 60.98796,60.98796 60.98796,-60.98796 13.23223,-15.69593 -3.23223,-190.49156"
|
||||
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
inkscape:original-d="m 229.8097,598.93253 c 0,0 40.04773,-49.37615 78.2315,-46.54772 38.18376,2.82842 121.53658,60.79036 121.53658,60.79036 0,0 -6.02082,2.55115 -30.76956,16.16296 0,0 12.79899,-12.69239 8.55635,-24.71321 -2.12132,-6.01041 14.94029,-2.66966 -6.65343,-10.13782 -27.73025,-9.59047 -59.17897,-34.5557 -85.91258,-33.03123 -45.25484,2.12132 -84.98886,37.47666 -84.98886,37.47666 z"
|
||||
inkscape:path-effect="#path-effect2775"
|
||||
sodipodi:nodetypes="csccsscc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2773"
|
||||
d="m 229.8097,598.93253 c 18.63146,-24.94869 47.41641,-42.07573 78.2315,-46.54772 23.85631,-3.46211 48.74281,0.54992 70.3025,11.33368 21.55969,10.78376 39.69613,28.29102 51.23408,49.45668 l -30.76956,16.16296 c 4.04327,-2.45035 7.19045,-6.34513 8.73726,-10.81275 1.54681,-4.46763 1.48164,-9.4746 -0.18091,-13.90046 -1.42959,-3.80568 -3.95561,-7.09666 -6.65343,-10.13782 -10.53158,-11.87188 -24.22068,-20.81019 -39.11132,-26.29877 -14.89064,-5.48859 -30.95363,-7.57407 -46.80126,-6.73246 -31.60479,1.67841 -62.4348,15.2732 -84.98886,37.47666 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
sodipodi:nodetypes="cssccscscc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2779"
|
||||
d="m 184,393.31666 c 0,0 21,14.25 23,34.25 2,20 -7.5,33.5 -3,55 4.5,21.5 48.73001,60.40304 48.73001,60.40304 l -1,-7.5 c 0,0 -29.73001,-28.40304 -34.23001,-48.40304 -4.5,-20 16.5,-34.5 16.5,-34.5 0,0 -0.5,-18.5 -9.5,-35 -9,-16.5 -30.5,-38.75 -30.5,-38.75 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2781"
|
||||
d="m 281.60528,565.34496 31.11269,31.1127 37.65344,-37.47666 c 0,0 -22.27386,-4.06586 -36.94633,-1.76777 -14.67246,2.2981 -31.8198,8.13173 -31.8198,8.13173 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
inkscape:original-d="m 372.5,325.06666 8.46447,157.14645 c 1.29174,23.98178 -4.75854,42.35355 -4.75854,42.35355 l -6.20593,13.5 34,-29 c 0,0 -6.29289,1.29289 -6.79289,-2.70711 -0.25,-2 -1.52145,4.44912 -2.39645,-8.00983 -0.875,-12.45894 -2.06066,-43.11885 0.68934,-52.86885 5.5,-19.5 20,-40.41421 16,-57.41421 -4,-17 -19.5,-31.5 -19.5,-31.5 0,0 11.5,20.5 11.5,35 0,14.5 -11,42 -11,42 l -8.79407,-104.28445 z"
|
||||
inkscape:path-effect="#path-effect2812"
|
||||
sodipodi:nodetypes="cscccsssscsccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2783"
|
||||
d="m 372.5,325.06666 8.46447,157.14645 c 0.77051,14.30478 0.29769,28.95001 -4.75854,42.35355 l -6.20593,13.5 34,-29 c -1.26273,0.17615 -2.57058,0.018 -3.75495,-0.45396 -1.18438,-0.472 -2.24249,-1.25677 -3.03794,-2.25315 -0.88219,-1.10503 -1.43104,-2.44567 -1.77167,-3.81802 -0.34063,-1.37234 -0.48278,-2.78497 -0.62478,-4.19181 -1.7796,-17.6309 -3.66068,-35.69058 0.68934,-52.86885 2.45856,-9.70888 6.83445,-18.80399 10.41984,-28.15555 3.5854,-9.35157 6.42323,-19.27888 5.58016,-29.25866 -1.07881,-12.77031 -8.55057,-24.84007 -19.5,-31.5 6.80897,10.39036 10.81941,22.59603 11.5,35 0.80581,14.68601 -3.09878,29.59441 -11,42 l -8.79407,-104.28445 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
transform="translate(-4.9497475,-11.667262)"
|
||||
inkscape:original-d="m 417,359.06666 c 0,0 5.5,27.5 3,44.5 -2.5,17 -17,40 -16.5,55 0.5,15 10,55.5 10,55.5 l 6,-7 c 0,0 -1.5,-4 1.5,-14 3,-10 29.5,-31.5 31.5,-45 2,-13.5 6.5,-37 -2.5,-52.5 -9,-15.5 -33,-36.5 -33,-36.5 z"
|
||||
inkscape:path-effect="#path-effect2787"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2785"
|
||||
d="m 417,359.06666 c 4.78201,14.25026 5.82605,29.73683 3,44.5 -3.60326,18.82328 -13.27514,36.10821 -16.5,55 -3.22759,18.90777 0.37603,38.90783 10,55.5 l 6,-7 c -0.42409,-4.70468 0.0888,-9.49195 1.5,-14 2.75215,-8.79191 8.73429,-16.16487 14.84677,-23.05759 6.11249,-6.89272 12.58118,-13.67861 16.65323,-21.94241 4.01011,-8.1381 5.48528,-17.41259 4.83711,-26.46187 -0.64817,-9.04929 -3.36526,-17.88129 -7.33711,-26.03813 -7.27671,-14.94391 -18.86275,-27.75877 -33,-36.5 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
sodipodi:nodetypes="cscscsssssccccscsscscscssccssscscsccc"
|
||||
inkscape:original-d="m 408.24264,501.64204 c 0,0 -18.4698,26.5434 -33.47605,47.08592 -1.52463,2.08711 -1.94454,4.24263 -1.94454,4.24263 0,0 3.0052,1.06067 7.95495,-4.59619 4.94975,-5.65685 15.55635,-23.68808 26.51651,-28.28427 10.96015,-4.5962 12.55114,-0.70711 18.20799,-9.89949 5.65686,-9.19239 33.05725,-27.57717 41.54253,-24.39519 8.48528,3.18198 12.02081,14.84924 8.48528,21.2132 -3.53553,6.36396 -10.96016,16.26346 -19.44544,12.37437 -8.48528,-3.88909 -7.60139,-21.03643 -1.59098,-22.09709 6.0104,-1.06066 13.43503,-1.76777 12.02081,3.18198 -1.41421,4.94975 -7.42462,10.96016 -9.19239,7.42462 -1.76776,-3.53553 3.53554,-6.36396 3.53554,-6.36396 0,0 0.35355,-2.12132 -3.18198,-0.35355 -3.53554,1.76777 -6.18719,7.95495 -1.591,10.78338 4.5962,2.82843 13.43503,-0.70711 13.78859,-6.36396 0.35355,-5.65685 4.94974,-15.02602 -2.47488,-13.61181 -7.42462,1.41421 -22.80419,9.54595 -22.80419,15.55636 0,6.0104 -2.2981,9.19238 0.88388,10.96015 3.18198,1.76776 10.07627,5.12653 7.95495,14.31892 -2.12132,9.19238 -9.01561,42.4264 -15.37957,43.1335 -6.36396,0.70711 -30.05203,-7.42461 -29.69848,-21.56675 0.35355,-14.14213 9.89949,-27.93072 18.03122,-26.51651 8.13173,1.41422 19.79899,9.54594 17.67767,17.67767 -2.12132,8.13173 -5.3033,16.61701 -13.78858,12.72792 -8.48528,-3.88908 -7.42463,-13.08147 -5.3033,-14.49568 2.12132,-1.41422 11.84403,-0.35356 11.84403,-0.35356 0,0 -6.18718,-6.01041 -10.07627,-5.3033 -3.88909,0.70711 -11.31371,10.6066 -8.13173,15.9099 3.18198,5.3033 6.01041,12.72793 10.96016,12.02082 4.94975,-0.70711 18.38477,-2.12133 19.79899,-9.8995 4.97465,-11.33861 3.53553,-23.68808 -8.83884,-29.34493 -12.37437,-5.65685 -24.74873,-4.24264 -28.28427,-1.41421 -3.53553,2.82843 -14.49569,27.57716 -10.6066,36.76955 3.88908,9.19239 11.07108,28.38477 11.07108,28.38477 L 350.37141,558.981 Z"
|
||||
inkscape:path-effect="#path-effect2793"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2791"
|
||||
d="m 408.24264,501.64204 c -13.78074,13.59145 -25.16581,29.60518 -33.47605,47.08592 -0.66795,1.40504 -1.31621,2.81943 -1.94454,4.24263 2.83424,-1.18218 5.51522,-2.7312 7.95495,-4.59619 5.15257,-3.93877 9.12373,-9.18352 13.09242,-14.31309 3.96869,-5.12956 8.08429,-10.29021 13.42409,-13.97118 5.6942,-3.92527 12.51195,-5.97689 18.20799,-9.89949 7.0353,-4.84489 11.89868,-12.2058 18.22057,-17.95055 3.16094,-2.87237 6.74936,-5.36859 10.80242,-6.71575 4.05305,-1.34716 8.617,-1.46449 12.51954,0.27111 3.79704,1.68869 6.74361,5.06436 8.19124,8.95969 1.44763,3.89533 1.44662,8.26092 0.29404,12.25351 -1.18959,4.12079 -3.65012,7.94028 -7.18724,10.36615 -1.76855,1.21294 -3.78906,2.06767 -5.90209,2.43384 -2.11304,0.36618 -4.31698,0.23844 -6.35611,-0.42562 -2.26746,-0.73841 -4.31588,-2.14359 -5.80738,-4.00426 -1.49149,-1.86067 -2.41832,-4.1711 -2.60865,-6.54816 -0.19033,-2.37706 0.35923,-4.81065 1.56776,-6.8664 1.20853,-2.05575 3.07258,-3.72241 5.25729,-4.67827 2.06755,-0.90459 4.42962,-1.16661 6.62059,-0.62561 2.19098,0.541 4.18906,1.90334 5.40022,3.80759 0.87774,1.38004 1.33523,3.03942 1.19834,4.6692 -0.13689,1.62978 -0.88538,3.21651 -2.11607,4.29371 -1.23068,1.07719 -2.94192,1.61115 -4.55476,1.33971 -0.80642,-0.13572 -1.58078,-0.46599 -2.22993,-0.96332 -0.64915,-0.49733 -1.17106,-1.16167 -1.48997,-1.91468 -0.545,-1.28686 -0.46315,-2.82269 0.21554,-4.04433 0.67869,-1.22165 1.93946,-2.10252 3.32,-2.31963 -0.9897,-0.44355 -2.11905,-0.56903 -3.18198,-0.35355 -1.13033,0.22914 -2.17613,0.84165 -2.94462,1.70163 -0.76849,0.85998 -1.25842,1.96113 -1.4036,3.10528 -0.14518,1.14414 0.0521,2.32669 0.54162,3.37094 0.48957,1.04426 1.26781,1.94839 2.2156,2.60553 1.19875,0.83114 2.65914,1.26193 4.11772,1.27968 1.45859,0.0178 2.91201,-0.36961 4.20062,-1.05318 2.57723,-1.36712 4.42665,-3.86612 5.47025,-6.59046 0.87641,-2.28789 1.23552,-4.79588 0.85624,-7.21635 -0.37927,-2.42047 -1.52178,-4.74355 -3.33112,-6.39546 -2.10662,-1.92333 -5.03922,-2.84786 -7.88747,-2.69137 -2.84825,0.15649 -5.59552,1.34839 -7.80079,3.15776 -2.20527,1.80938 -3.88384,4.21335 -5.0316,6.8248 -1.14775,2.61146 -1.77936,5.42897 -2.08433,8.26517 -0.39472,3.67092 -0.24165,7.44381 0.88388,10.96015 1.66875,5.21347 5.33084,9.51486 7.95495,14.31892 4.24616,7.7736 5.69922,17.2357 3.02421,25.67981 -1.33751,4.22206 -3.68265,8.13743 -6.86607,11.21648 -3.18342,3.07904 -7.20819,5.3045 -11.53771,6.23721 -6.67927,1.43892 -13.91436,-0.31824 -19.38109,-4.41678 -5.46673,-4.09854 -9.14354,-10.41905 -10.31739,-17.14997 -1.039,-5.9577 -0.11666,-12.34728 3.11371,-17.45985 1.61519,-2.55628 3.78747,-4.7692 6.35784,-6.36187 2.57037,-1.59267 5.53904,-2.5562 8.55967,-2.69479 4.68239,-0.21483 9.42201,1.62539 12.7355,4.94076 3.31349,3.31538 5.15079,8.05424 4.94217,12.73691 -0.15821,3.55115 -1.50891,7.10411 -4.03503,9.60499 -1.26307,1.25044 -2.80481,2.22583 -4.49229,2.7838 -1.68748,0.55797 -3.5195,0.69298 -5.26126,0.33913 -1.52695,-0.31022 -2.97823,-0.99581 -4.17701,-1.99117 -1.19877,-0.99536 -2.14147,-2.29973 -2.69488,-3.75628 -0.55341,-1.45656 -0.71444,-3.06235 -0.44489,-4.597 0.26955,-1.53465 0.97086,-2.99332 2.01348,-4.15123 1.47699,-1.6403 3.64015,-2.64231 5.84645,-2.70817 2.2063,-0.0659 4.42537,0.80533 5.99758,2.35461 -0.83032,-1.87095 -2.30571,-3.44833 -4.11707,-4.40168 -1.81136,-0.95335 -3.94689,-1.27645 -5.9592,-0.90162 -1.64243,0.30593 -3.19096,1.06618 -4.47325,2.13713 -1.28228,1.07094 -2.29989,2.44728 -2.99851,3.96488 -1.39725,3.0352 -1.49076,6.57145 -0.65997,9.80789 0.70318,2.73932 2.05418,5.31638 3.94663,7.41804 1.89245,2.10166 4.32662,3.72025 7.01353,4.60278 3.88852,1.27721 8.27621,0.9557 11.93702,-0.87471 3.6608,-1.8304 6.55063,-5.14764 7.86197,-9.02479 1.74906,-5.16128 1.84659,-10.86839 0.27489,-16.08641 -1.5717,-5.21803 -4.80505,-9.92186 -9.11373,-13.25852 -3.9886,-3.08879 -8.89889,-5.00232 -13.93501,-5.29732 -5.03612,-0.295 -10.17482,1.05052 -14.34926,3.88311 -5.59244,3.79479 -9.21432,10.0567 -10.741,16.6404 -1.52668,6.5837 -1.11203,13.48669 0.1344,20.12915 1.8839,10.03963 5.65993,19.72089 11.07108,28.38477 L 350.37141,558.981 Z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2795"
|
||||
d="M 320.14259,595.04345 480.30227,433.82311 478.5345,420.74163 Z"
|
||||
style="display:inline;opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;filter:url(#filter2743)" />
|
||||
<circle
|
||||
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
id="circle2828"
|
||||
cx="323.85492"
|
||||
cy="482.96701"
|
||||
r="164.75587" />
|
||||
<path
|
||||
sodipodi:nodetypes="csscsssc"
|
||||
inkscape:original-d="m 415.77878,351.44516 c 0,0 14.14214,27.57716 4.94975,47.37615 -9.19239,19.79899 -20.50609,27.57717 -16.97056,48.79037 3.53554,21.21321 4.94975,49.49748 4.94975,49.49748 0,0 1.41422,-21.92031 14.84925,-36.06245 13.43502,-14.14213 23.33452,-29.69848 24.04162,-41.01219 0.70711,-11.31371 -6.36396,-33.23402 -12.02081,-43.84062 -5.65686,-10.6066 -19.799,-24.74874 -19.799,-24.74874 z"
|
||||
inkscape:path-effect="#path-effect2832"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path2830"
|
||||
d="m 415.77878,351.44516 c 6.66723,14.71457 8.43151,31.60124 4.94975,47.37615 -3.71595,16.83593 -13.0944,31.9906 -16.97056,48.79037 -3.79489,16.44751 -2.02692,34.12713 4.94975,49.49748 2.58222,-12.82271 7.65377,-25.13932 14.84925,-36.06245 4.39576,-6.673 9.55862,-12.80866 14.08106,-19.39646 4.52244,-6.58781 8.45132,-13.76883 9.96056,-21.61573 1.46699,-7.6272 0.56679,-15.58677 -1.7655,-22.99533 -2.33229,-7.40856 -6.0558,-14.3115 -10.25531,-20.84529 -5.72236,-8.90309 -12.36918,-17.21162 -19.799,-24.74874 z"
|
||||
style="opacity:1;vector-effect:none;fill:#57e389;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<g
|
||||
id="g943">
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="m 421,360.31666 c 9.84034,13.04807 13.17467,30.76766 8.75,46.5 -3.62954,12.90519 -11.94894,23.93674 -17.25,36.25 -7.27736,16.90381 -8.6376,36.28802 -3.79228,54.0425 -3.40482,-18.16177 -1.27226,-37.32371 6.04228,-54.2925 5.11724,-11.87134 12.71442,-22.7472 16,-35.25 4.23542,-16.1173 0.51939,-34.12576 -9.75,-47.25 z"
|
||||
id="path917"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:path-effect="#path-effect919"
|
||||
inkscape:original-d="m 421,360.31666 c 0,0 12.5,30.75 8.75,46.5 -3.75,15.75 -13.5,18.25 -17.25,36.25 -3.75,18 -3.79228,54.0425 -3.79228,54.0425 0,0 -0.45772,-37.0425 6.04228,-54.2925 6.5,-17.25 15.75,-16.75 16,-35.25 0.25,-18.5 -9.75,-47.25 -9.75,-47.25 z"
|
||||
sodipodi:nodetypes="csscssc" />
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:7.22890568;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="m 406.5,472.56666 c 4.28396,-3.19203 8.74878,-5.57022 13.37348,-7.22891 -4.45783,1.35399 -8.91565,3.02517 -13.37348,7.22891 z"
|
||||
id="path921"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccc" />
|
||||
<path
|
||||
sodipodi:nodetypes="ccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path923"
|
||||
d="m 407.2955,462.93233 c 6.66231,-4.51902 13.60588,-7.88587 20.7981,-10.23411 -6.9327,1.91687 -13.86539,4.28279 -20.7981,10.23411 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.72632408;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.72632408;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="m 410.74265,450.2928 c 6.66231,-4.51902 13.60588,-7.88587 20.7981,-10.23411 -6.9327,1.91687 -13.86539,4.28279 -20.7981,10.23411 z"
|
||||
id="path925"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccc" />
|
||||
<path
|
||||
sodipodi:nodetypes="ccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path927"
|
||||
d="m 415.41467,439.3855 c 7.36942,-7.08228 21.13145,-12.80008 28.32367,-15.14832 -6.9327,1.91687 -21.0803,4.72213 -28.32367,15.14832 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.72632408;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:7.7705822;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="m 424.17768,422.09692 c 5.10786,-5.36257 13.78013,-7.04185 18.76517,-8.81989 -4.80516,1.45142 -13.22485,2.43973 -18.76517,8.81989 z"
|
||||
id="path929"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccc" />
|
||||
<path
|
||||
sodipodi:nodetypes="ccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path931"
|
||||
d="m 428.86226,411.04837 c 5.10786,-5.36257 8.47683,-6.86507 13.46187,-8.64311 -4.80516,1.45142 -8.98221,1.46745 -13.46187,8.64311 z"
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:7.7705822;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal" />
|
||||
<path
|
||||
style="opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:7.7705822;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
|
||||
d="m 431.51391,399.82305 c 2.36782,-3.32964 4.85291,-5.62763 9.83795,-7.40567 -4.80516,1.45142 -8.00994,2.88166 -9.83795,7.40567 z"
|
||||
id="path933"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccc" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 50 KiB |
|
@ -1,14 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/org/freedesktop/Tuhi">
|
||||
<file preprocess="xml-stripblanks">AboutDialog.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/Drawing.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/DrawingPerspective.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/Flowbox.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/MainWindow.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/SetupPerspective.ui</file>
|
||||
<file preprocess="xml-stripblanks">ui/ErrorPerspective.ui</file>
|
||||
<file preprocess="xml-stripblanks">input-tablet-missing-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">ui/AppMenu.ui</file>
|
||||
</gresource>
|
||||
</gresources>
|
|
@ -1,35 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.20.0 -->
|
||||
<interface domain="tuhi">
|
||||
<requires lib="gtk+" version="3.4"/>
|
||||
<object class="GtkAboutDialog" id="about_dialog">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="modal">True</property>
|
||||
<property name="type_hint">normal</property>
|
||||
<property name="program_name">Tuhi</property>
|
||||
<property name="version">@version@</property>
|
||||
<property name="copyright">Copyright © 2019 Tuhi Developers</property>
|
||||
<property name="website">@url@</property>
|
||||
<property name="website_label" translatable="yes">Visit Tuhi’s website</property>
|
||||
<property name="logo_icon_name">org.freedesktop.Tuhi</property>
|
||||
<property name="license_type">gpl-2-0</property>
|
||||
<child internal-child="vbox">
|
||||
<object class="GtkBox">
|
||||
<property name="can_focus">False</property>
|
||||
<child internal-child="action_area">
|
||||
<object class="GtkButtonBox">
|
||||
<property name="can_focus">False</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
|
@ -1,26 +0,0 @@
|
|||
<interface>
|
||||
<menu id="primary-menu">
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Portrait</attribute>
|
||||
<attribute name="action">win.orientation</attribute>
|
||||
<attribute name="target">portrait</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Landscape</attribute>
|
||||
<attribute name="action">win.orientation</attribute>
|
||||
<attribute name="target">landscape</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Help</attribute>
|
||||
<attribute name="action">app.help</attribute>
|
||||
</item>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">About</attribute>
|
||||
<attribute name="action">app.about</attribute>
|
||||
</item>
|
||||
</section>
|
||||
</menu>
|
||||
</interface>
|
|
@ -1,151 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<object class="GtkImage" id="icon_download">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">document-save-as-symbolic</property>
|
||||
</object>
|
||||
<object class="GtkImage" id="icon_remove">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">edit-delete-symbolic</property>
|
||||
</object>
|
||||
<object class="GtkImage" id="image_rotate_left">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">object-rotate-left-symbolic</property>
|
||||
</object>
|
||||
<object class="GtkImage" id="image_rotate_right">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">object-rotate-right-symbolic</property>
|
||||
</object>
|
||||
<template class="Drawing" parent="GtkEventBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<signal name="enter-notify-event" handler="_on_enter" swapped="no"/>
|
||||
<signal name="leave-notify-event" handler="_on_leave" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkBox" id="box_drawing">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">20</property>
|
||||
<property name="margin_right">20</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="box_toolbar">
|
||||
<property name="height_request">20</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="margin_left">10</property>
|
||||
<property name="margin_right">10</property>
|
||||
<property name="margin_top">10</property>
|
||||
<property name="margin_bottom">10</property>
|
||||
<property name="hexpand">True</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btn_rotate_left">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="image">image_rotate_left</property>
|
||||
<signal name="clicked" handler="_on_rotate_button_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btn_rotate_right">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="image">image_rotate_right</property>
|
||||
<signal name="clicked" handler="_on_rotate_button_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btn_download">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="image">icon_download</property>
|
||||
<signal name="clicked" handler="_on_download_button_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSeparator">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">10</property>
|
||||
<property name="margin_right">10</property>
|
||||
<property name="orientation">vertical</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">4</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btn_remove">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="image">icon_remove</property>
|
||||
<signal name="clicked" handler="_on_delete_button_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">6</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkImage" id="image_svg">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="margin_bottom">10</property>
|
||||
<property name="stock">gtk-missing-image</property>
|
||||
<style>
|
||||
<class name="bg-paper"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<style>
|
||||
<class name="drawing"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
|
@ -1,147 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<template class="DrawingPerspective" parent="GtkStack">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="baseline_position">top</property>
|
||||
<child>
|
||||
<object class="GtkOverlay">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkViewport" id="viewport">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="box_all_drawings">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<style>
|
||||
<class name="bg-white"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="index">-1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child type="overlay">
|
||||
<object class="GtkRevealer" id="overlay_undo">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="transition_type">none</property>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="margin_left">12</property>
|
||||
<property name="margin_right">4</property>
|
||||
<property name="margin_start">12</property>
|
||||
<property name="margin_end">4</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="notification_delete_undo">
|
||||
<property name="label" translatable="yes">Undo delete drawing</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="margin_right">6</property>
|
||||
<property name="margin_end">6</property>
|
||||
<signal name="clicked" handler="_on_undo_clicked" swapped="no"/>
|
||||
<style>
|
||||
<class name="text-button"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="notification_delete_close">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="focus_on_click">False</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="relief">none</property>
|
||||
<signal name="clicked" handler="_on_undo_close_clicked" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">window-close-symbolic</property>
|
||||
<property name="icon_size">2</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="image-button"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="app-notification"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="label_sync">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">Press the button on the device to synchronize drawings</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">page0</property>
|
||||
<property name="title" translatable="yes">page0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
|
@ -1,117 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<template class="ErrorPerspective" parent="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">TuhiGUI is an interactive GUI to download data from Tuhi.
|
||||
|
||||
Tuhi connects to tablets of the Wacom Ink range. It allows you to download the drawings stored on those devices as SVGs for processing later.
|
||||
|
||||
Tuhi is a DBus server that needs to be running for the Tuhi GUI to connect to it. Connecting to the DBus server should take less than a second. If you read this far, your Tuhi DBus server is not running or responsing and needs to be restarted.</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="max_width_chars">55</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="margin_top">20</property>
|
||||
<property name="margin_bottom">20</property>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_right">30</property>
|
||||
<property name="active">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">Connecting to Tuhi</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">This should take less than a second. Make sure the Tuhi DBus server is running.</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="max_width_chars">55</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
|
@ -1,56 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<template class="Flowbox" parent="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="label_date">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="margin_left">10</property>
|
||||
<property name="margin_top">10</property>
|
||||
<property name="margin_bottom">10</property>
|
||||
<property name="hexpand">False</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
<attribute name="size" value="20000"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSeparator">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">20</property>
|
||||
<property name="margin_right">20</property>
|
||||
<property name="margin_bottom">20</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkFlowBox" id="flowbox_drawings">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="homogeneous">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
|
@ -1,206 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<object class="GtkAdjustment" id="adjustment_zoom">
|
||||
<property name="upper">15</property>
|
||||
<property name="step_increment">1.0000000002235174</property>
|
||||
<property name="page_increment">1</property>
|
||||
<signal name="value-changed" handler="_on_zoom_changed" swapped="no"/>
|
||||
</object>
|
||||
<template class="MainWindow" parent="GtkApplicationWindow">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="default_width">1000</property>
|
||||
<property name="default_height">700</property>
|
||||
<child type="titlebar">
|
||||
<object class="GtkHeaderBar" id="headerbar">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="show_close_button">True</property>
|
||||
<child>
|
||||
<object class="GtkImage" id="image_missing_tablet">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">10</property>
|
||||
<property name="resource">/org/freedesktop/Tuhi/input-tablet-missing-symbolic.svg</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkSpinner" id="spinner_sync">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">10</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child type="title">
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuButton" id="menubutton1">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">open-menu-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkImage" id="image_battery">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_right">10</property>
|
||||
<property name="icon_name">battery-missing-symbolic</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">zoom-out-symbolic</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScale" id="scale_zoom">
|
||||
<property name="width_request">100</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="adjustment">adjustment_zoom</property>
|
||||
<property name="round_digits">0</property>
|
||||
<property name="draw_value">False</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="icon_name">zoom-in-symbolic</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkOverlay">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkStack" id="stack_perspectives">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="transition_type">crossfade</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="index">-1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child type="overlay">
|
||||
<object class="GtkRevealer" id="overlay_reauth">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="transition_type">none</property>
|
||||
<child>
|
||||
<object class="GtkFrame">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label_xalign">0</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">start</property>
|
||||
<property name="margin_left">12</property>
|
||||
<property name="margin_right">4</property>
|
||||
<property name="margin_start">12</property>
|
||||
<property name="margin_end">4</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_right">10</property>
|
||||
<property name="label" translatable="yes">Authorization error while connecting to the device </property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="notification_reauth">
|
||||
<property name="label" translatable="yes">Register</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="margin_right">6</property>
|
||||
<property name="margin_end">6</property>
|
||||
<signal name="clicked" handler="_on_reauth_clicked" swapped="no"/>
|
||||
<style>
|
||||
<class name="text-button"/>
|
||||
</style>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="app-notification"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
|
@ -1,278 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.22.1 -->
|
||||
<interface>
|
||||
<requires lib="gtk+" version="3.20"/>
|
||||
<template class="SetupDialog" parent="GtkDialog">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="title" translatable="yes">Initial Device Setup</property>
|
||||
<property name="modal">True</property>
|
||||
<property name="window_position">center-on-parent</property>
|
||||
<property name="destroy_with_parent">True</property>
|
||||
<property name="type_hint">dialog</property>
|
||||
<property name="gravity">center</property>
|
||||
<child type="titlebar">
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child internal-child="vbox">
|
||||
<object class="GtkBox">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">2</property>
|
||||
<child internal-child="action_area">
|
||||
<object class="GtkButtonBox">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="layout_style">end</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btn_quit">
|
||||
<property name="label" translatable="yes">Quit</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="stack">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">10</property>
|
||||
<property name="margin_right">10</property>
|
||||
<property name="margin_top">10</property>
|
||||
<property name="margin_bottom">100</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="homogeneous">True</property>
|
||||
<property name="baseline_position">bottom</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="label" translatable="yes">Hold the button on the device until the blue light is flashing.</property>
|
||||
<property name="justify">center</property>
|
||||
<property name="wrap">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_right">20</property>
|
||||
<property name="active">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">Searching for device</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">page0</property>
|
||||
<property name="title" translatable="yes">page0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="homogeneous">True</property>
|
||||
<property name="baseline_position">bottom</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="label_devicename_p1">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="label" translatable="yes">Connecting to LE Paper</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="max_width_chars">55</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_right">20</property>
|
||||
<property name="active">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">Connecting to device...</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">page1</property>
|
||||
<property name="title" translatable="yes">page1</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="homogeneous">True</property>
|
||||
<property name="baseline_position">bottom</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="label" translatable="yes">Press the button on the device now!</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkSpinner">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_right">20</property>
|
||||
<property name="active">True</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">waiting for reply</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="name">page2</property>
|
||||
<property name="title" translatable="yes">page2</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<action-widgets>
|
||||
<action-widget response="-6">btn_quit</action-widget>
|
||||
</action-widgets>
|
||||
</template>
|
||||
</interface>
|
After Width: | Height: | Size: 8.0 KiB |
After Width: | Height: | Size: 13 KiB |
|
@ -1,3 +0,0 @@
|
|||
[Device]
|
||||
Address=E2:43:03:67:0E:01
|
||||
UUID=dead00beef00
|
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 32 KiB |
183
meson.build
|
@ -1,183 +0,0 @@
|
|||
project('tuhi',
|
||||
version: '0.3',
|
||||
license: 'GPLv2',
|
||||
meson_version: '>= 0.48.0')
|
||||
# The tag date of the project_version(), update when the version bumps.
|
||||
version_date='2019-09-12'
|
||||
|
||||
# Dependencies
|
||||
dependency('python3', required: true)
|
||||
dependency('pygobject-3.0', required: true)
|
||||
|
||||
prefix = get_option('prefix')
|
||||
datadir = join_paths(prefix, get_option('datadir'))
|
||||
localedir = join_paths(prefix, get_option('localedir'))
|
||||
pkgdatadir = join_paths(datadir, meson.project_name())
|
||||
bindir = join_paths(prefix, get_option('bindir'))
|
||||
podir = join_paths(meson.source_root(), 'po')
|
||||
desktopdir = join_paths(datadir, 'applications')
|
||||
icondir = join_paths(datadir, 'icons', 'hicolor')
|
||||
icondir_scalable = join_paths(icondir, 'scalable', 'apps')
|
||||
icondir_symbolic = join_paths(icondir, 'symbolic', 'apps')
|
||||
metainfodir = join_paths(datadir, 'metainfo')
|
||||
libexecdir = join_paths(get_option('prefix'), get_option('libexecdir'), 'tuhi')
|
||||
|
||||
|
||||
i18n = import('i18n')
|
||||
|
||||
subdir('po')
|
||||
subdir('data')
|
||||
|
||||
pymod = import('python')
|
||||
|
||||
# external python modules that are required for running Tuhi
|
||||
python_modules = [
|
||||
'svgwrite',
|
||||
'xdg',
|
||||
'gi',
|
||||
'yaml',
|
||||
]
|
||||
if meson.version().version_compare('>=0.51')
|
||||
py3 = pymod.find_installation(modules: python_modules)
|
||||
else
|
||||
py3 = pymod.find_installation()
|
||||
|
||||
foreach module: python_modules
|
||||
if run_command(py3, '-c', 'import @0@'.format(module)).returncode() != 0
|
||||
error('Failed to find required python module \'@0@\'.'.format(module))
|
||||
endif
|
||||
endforeach
|
||||
endif
|
||||
python_dir = py3.get_install_dir()
|
||||
install_subdir('tuhi',
|
||||
install_dir: python_dir,
|
||||
exclude_directories: '__pycache__')
|
||||
|
||||
# We have three startup scripts:
|
||||
# - tuhi: starts server and GUI
|
||||
# - tuhi-gui: starts the GUI only
|
||||
# - tuhi-server: starts the server only
|
||||
#
|
||||
# tuhi-server can run as-is, we don't need meson for it. But for the other
|
||||
# two we build a {name}.devel version that uses the in-tree files.
|
||||
# For that we need to replace a few paths, in the installed versions we just
|
||||
# use the normal dirs.
|
||||
#
|
||||
config_tuhi = configuration_data()
|
||||
config_tuhi.set('libexecdir', libexecdir)
|
||||
config_tuhi.set('devel', '')
|
||||
|
||||
config_tuhi_devel = configuration_data()
|
||||
config_tuhi_devel.set('libexecdir', '')
|
||||
config_tuhi_devel.set('devel', '''
|
||||
tuhi_gui = '@1@/tuhi-gui.devel'
|
||||
tuhi_server = '@0@/tuhi-server.py'
|
||||
tuhi_live = '@0@/tuhi-live.py'
|
||||
print('Running from source tree, using local files')
|
||||
'''.format(meson.source_root(), meson.build_root()))
|
||||
|
||||
config_tuhigui = configuration_data()
|
||||
config_tuhigui.set('pkgdatadir', pkgdatadir)
|
||||
config_tuhigui.set('localedir', localedir)
|
||||
config_tuhigui.set('devel', '')
|
||||
|
||||
config_tuhigui_devel = config_tuhigui
|
||||
config_tuhigui_devel.set('pkgdatadir', join_paths(meson.build_root(), 'data'))
|
||||
config_tuhigui_devel.set('localedir', join_paths(meson.build_root(), 'po'))
|
||||
config_tuhigui_devel.set('devel', '''
|
||||
sys.path.insert(1, '@0@')
|
||||
print('Running from source tree, using local files')
|
||||
'''.format(meson.source_root(), meson.build_root()))
|
||||
|
||||
configure_file(input: 'tuhi.in',
|
||||
output: 'tuhi',
|
||||
configuration: config_tuhi,
|
||||
install_dir: bindir)
|
||||
|
||||
configure_file(input: 'tuhi.in',
|
||||
output: 'tuhi.devel',
|
||||
configuration: config_tuhi_devel)
|
||||
|
||||
configure_file(input: 'tuhi-gui.in',
|
||||
output: 'tuhi-gui',
|
||||
configuration: config_tuhigui,
|
||||
install_dir: libexecdir)
|
||||
|
||||
configure_file(input: 'tuhi-gui.in',
|
||||
output: 'tuhi-gui.devel',
|
||||
configuration: config_tuhigui_devel)
|
||||
|
||||
configure_file(input: 'tuhi-server.py',
|
||||
output: 'tuhi-server',
|
||||
copy: true,
|
||||
install_dir: libexecdir)
|
||||
|
||||
configure_file(input: 'tuhi-live.py',
|
||||
output: 'tuhi-live',
|
||||
copy: true,
|
||||
install_dir: libexecdir)
|
||||
|
||||
meson.add_install_script('meson_install.sh')
|
||||
|
||||
desktop_file = i18n.merge_file(input: 'data/org.freedesktop.Tuhi.desktop.in',
|
||||
output: 'org.freedesktop.Tuhi.desktop',
|
||||
type: 'desktop',
|
||||
po_dir: podir,
|
||||
install: true,
|
||||
install_dir: desktopdir)
|
||||
|
||||
conf = configuration_data()
|
||||
conf.set('version', meson.project_version())
|
||||
conf.set('url', 'https://github.com/tuhiproject/tuhi')
|
||||
conf.set('version_date', version_date)
|
||||
|
||||
appdata_intl = configure_file(input: 'data/org.freedesktop.Tuhi.appdata.xml.in.in',
|
||||
output: 'org.freedesktop.Tuhi.appdata.xml.in',
|
||||
configuration: conf)
|
||||
|
||||
appdata = i18n.merge_file(input: appdata_intl,
|
||||
output: 'org.freedesktop.Tuhi.appdata.xml',
|
||||
type: 'xml',
|
||||
po_dir: podir,
|
||||
install: true,
|
||||
install_dir: metainfodir)
|
||||
|
||||
install_data('data/org.freedesktop.Tuhi.svg', install_dir: icondir)
|
||||
|
||||
flake8 = find_program('flake8-3', required: false)
|
||||
if flake8.found()
|
||||
test('flake8', flake8,
|
||||
args: ['--ignore=E501,W504',
|
||||
join_paths(meson.source_root(), 'tuhi'),
|
||||
join_paths(meson.source_root(), 'tuhi', 'gui')])
|
||||
test('flake8-tools', flake8,
|
||||
args: ['--ignore=E501,W504',
|
||||
join_paths(meson.source_root(), 'tools')])
|
||||
# the tests need different flake exclusions
|
||||
test('flake8-tests', flake8,
|
||||
args: ['--ignore=E501,W504,F403,F405',
|
||||
join_paths(meson.source_root(), 'test/')])
|
||||
endif
|
||||
|
||||
desktop_validate = find_program('desktop-file-validate', required: false)
|
||||
if desktop_validate.found()
|
||||
test('desktop-file-validate', desktop_validate, args: [desktop_file])
|
||||
endif
|
||||
|
||||
appstream_util = find_program('appstream-util', required: false)
|
||||
if appstream_util.found()
|
||||
test('appstream-util validate-relax', appstream_util,
|
||||
args: ['validate-relax', appdata])
|
||||
endif
|
||||
|
||||
pytest = find_program('pytest-3', required: false)
|
||||
if pytest.found()
|
||||
test('unittest', pytest,
|
||||
args: [join_paths(meson.source_root(), 'test')],
|
||||
timeout: 180)
|
||||
endif
|
||||
|
||||
# A wrapper to start tuhi at the same time as tuhigui, used by the flatpak
|
||||
configure_file(input: 'tools/tuhi-gui-flatpak.py',
|
||||
output: 'tuhi-gui-flatpak.py',
|
||||
copy: true)
|
|
@ -1,11 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
if [ -z $DESTDIR ]; then
|
||||
PREFIX=${MESON_INSTALL_PREFIX:-/usr}
|
||||
|
||||
# Update icon cache
|
||||
gtk-update-icon-cache -f -t $PREFIX/share/icons/hicolor
|
||||
|
||||
# Install new schemas
|
||||
#glib-compile-schemas $PREFIX/share/glib-2.0/schemas/
|
||||
fi
|
|
@ -1,71 +0,0 @@
|
|||
{
|
||||
"app-id": "org.freedesktop.Tuhi",
|
||||
"runtime": "org.gnome.Platform",
|
||||
"runtime-version": "3.32",
|
||||
"sdk": "org.gnome.Sdk",
|
||||
"command": "tuhi",
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--socket=x11",
|
||||
"--socket=wayland",
|
||||
"--talk-name=org.freedesktop.tuhi1",
|
||||
"--own-name=org.freedesktop.tuhi1",
|
||||
"--system-talk-name=org.bluez"
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"name": "pyxdg",
|
||||
"buildsystem": "simple",
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://gitlab.freedesktop.org/xdg/pyxdg.git",
|
||||
"tag": "rel-0.26",
|
||||
"commit": "7db14dcf4c4305c3859a2d9fcf9f5da2db328330"
|
||||
}
|
||||
],
|
||||
"build-commands": [
|
||||
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "python-pyparsing",
|
||||
"buildsystem": "simple",
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/pyparsing/pyparsing/releases/download/pyparsing_2.4.2/pyparsing-2.4.2.tar.gz",
|
||||
"sha512": "27e5959eb1cf0c4d899746d2d32f5f000c3753278bdbbb670d24a077053e5c08caf8429f684186c502f6d9bf358702e0a8b3fea40cd2b50807cf02ea38c750dd"
|
||||
}
|
||||
],
|
||||
"build-commands": [
|
||||
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "python-svgwrite",
|
||||
"buildsystem": "simple",
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/mozman/svgwrite.git",
|
||||
"tag": "v1.3.1",
|
||||
"commit": "13633ad13d7a4b3253d1304d31db8fc2c8d1dd9e"
|
||||
}
|
||||
],
|
||||
"build-commands": [
|
||||
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tuhi",
|
||||
"buildsystem": "meson",
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
# Language list must be in alphabetical order
|
||||
pl
|
16
po/POTFILES
|
@ -1,16 +0,0 @@
|
|||
data/org.freedesktop.Tuhi.appdata.xml.in.in
|
||||
data/org.freedesktop.Tuhi.desktop.in
|
||||
|
||||
data/ui/AboutDialog.ui.in
|
||||
data/ui/AppMenu.ui
|
||||
data/ui/Drawing.ui
|
||||
data/ui/DrawingPerspective.ui
|
||||
data/ui/ErrorPerspective.ui
|
||||
data/ui/MainWindow.ui
|
||||
data/ui/SetupPerspective.ui
|
||||
|
||||
tuhi/gui/application.py
|
||||
tuhi/gui/config.py
|
||||
tuhi/gui/drawing.py
|
||||
tuhi/gui/drawingperspective.py
|
||||
tuhi/gui/window.py
|
37
po/README.md
|
@ -1,37 +0,0 @@
|
|||
i18n
|
||||
====
|
||||
|
||||
This directory contains the translations of Tuhi
|
||||
|
||||
For errors in translations, please [file an
|
||||
issue](https://github.com/tuhiproject/tuhi/issues/new).
|
||||
|
||||
New or updated translations are always welcome. To start a new translation, run:
|
||||
|
||||
$ meson translation-build
|
||||
$ ninja -C translation-build tuhi-pot
|
||||
# Now you can optionally remove the build directory
|
||||
$ rm -rf translation-build
|
||||
$ cp po/tuhi.pot po/$lang.po
|
||||
|
||||
where `$lang` is the language code of your target language, e.g. `nl` for Dutch
|
||||
or `en_GB` for British English. Edit the
|
||||
[LINGUAS](https://github.com/tuhiproject/tuhi/blob/master/tuhigui/po/LINGUAS) file and
|
||||
add your language code, keeping the list sorted alphabetically. Finally, open
|
||||
the `.po` file you just created and translate all the strings. Don't forget to
|
||||
fill in the information in the header!
|
||||
|
||||
To update an existing translation, run:
|
||||
|
||||
$ meson translation-build
|
||||
$ ninja -C translation-build tuhi-update-po
|
||||
# Now you can optionally remove the build directory
|
||||
$ rm -rf translation-build
|
||||
|
||||
and update the `po/$lang.po` file of your target language.
|
||||
|
||||
When you are done translating, file a pull request on
|
||||
[GitHub](https://github.com/tuhiproject/tuhi) or, if you don't know how to, [open
|
||||
an issue](https://github.com/tuhiproject/tuhi/issues/new) and attach the `.po`
|
||||
file there.
|
||||
|
|
@ -1 +0,0 @@
|
|||
i18n.gettext(meson.project_name(), preset: 'glib')
|
208
po/pl.po
|
@ -1,208 +0,0 @@
|
|||
# Polish translation for tuhi.
|
||||
# Copyright © 2019 the tuhi authors.
|
||||
# This file is distributed under the same license as the tuhi package.
|
||||
# Piotr Drąg <piotrdrag@gmail.com>, 2019.
|
||||
# Aviary.pl <community-poland@mozilla.org>, 2019.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: tuhi\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-09-15 12:38+0200\n"
|
||||
"PO-Revision-Date: 2019-09-15 12:55+0200\n"
|
||||
"Last-Translator: Piotr Drąg <piotrdrag@gmail.com>\n"
|
||||
"Language-Team: Polish <community-poland@mozilla.org>\n"
|
||||
"Language: pl\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
|
||||
"|| n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
#: data/org.freedesktop.Tuhi.appdata.xml.in.in:7
|
||||
#: data/org.freedesktop.Tuhi.desktop.in:3
|
||||
msgid "Tuhi"
|
||||
msgstr "Tuhi"
|
||||
|
||||
#: data/org.freedesktop.Tuhi.appdata.xml.in.in:8
|
||||
#: data/org.freedesktop.Tuhi.desktop.in:4
|
||||
msgid "Utility to download drawings from the Wacom Ink range of devices"
|
||||
msgstr "Narzędzie do pobierania rysunków z rodziny urządzeń Wacom Ink"
|
||||
|
||||
#: data/org.freedesktop.Tuhi.appdata.xml.in.in:10
|
||||
msgid ""
|
||||
"Tuhi is a graphical user interface to download drawings stored on tablet "
|
||||
"devices from the Wacom Ink range, e.g. Intuos Pro Paper or Bamboo Slate."
|
||||
msgstr ""
|
||||
"Tuhi to graficzny interfejs użytkownika do pobierania rysunków "
|
||||
"przechowywanych na tabletach z rodziny Wacom Ink, np. Intuos Pro Paper "
|
||||
"i Bamboo Slate."
|
||||
|
||||
#: data/org.freedesktop.Tuhi.appdata.xml.in.in:15
|
||||
msgid ""
|
||||
"Tuhi requires Tuhi, the daemon to actually communicate with the devices. "
|
||||
"ThiGui is merely a front end to Tuhi, Tuhi must be installed and running "
|
||||
"when Tuhi is launched."
|
||||
msgstr ""
|
||||
"TuhiGUI wymaga Tuhi, usługi komunikującej się z urządzeniem. TuhiGUI to "
|
||||
"interfejs dla usługi Tuhi, która musi być zainstalowana i uruchomiona, aby "
|
||||
"narzędzie Tuhi mogło działać."
|
||||
|
||||
#: data/org.freedesktop.Tuhi.appdata.xml.in.in:32
|
||||
msgid "Tuhi's main window"
|
||||
msgstr "Główne okno Tuhi"
|
||||
|
||||
#: data/org.freedesktop.Tuhi.appdata.xml.in.in:36
|
||||
msgid "Tuhi's main window (zoomed)"
|
||||
msgstr "Główne okno Tuhi (powiększone)"
|
||||
|
||||
#. Translators: Do NOT translate or transliterate this text (this is an icon file name)!
|
||||
#: data/org.freedesktop.Tuhi.desktop.in:7
|
||||
msgid "org.freedesktop.Tuhi"
|
||||
msgstr "org.freedesktop.Tuhi"
|
||||
|
||||
#. Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon!
|
||||
#: data/org.freedesktop.Tuhi.desktop.in:12
|
||||
msgid "tablet;wacom;ink;"
|
||||
msgstr "tablet;wacom;ink;"
|
||||
|
||||
#: data/ui/AboutDialog.ui.in:13
|
||||
msgid "Visit Tuhi’s website"
|
||||
msgstr "Witryna programu Tuhi"
|
||||
|
||||
#: data/ui/AppMenu.ui:5
|
||||
msgid "Portrait"
|
||||
msgstr "Pionowo"
|
||||
|
||||
#: data/ui/AppMenu.ui:10
|
||||
msgid "Landscape"
|
||||
msgstr "Poziomo"
|
||||
|
||||
#: data/ui/AppMenu.ui:17
|
||||
msgid "Help"
|
||||
msgstr "Pomoc"
|
||||
|
||||
#: data/ui/AppMenu.ui:21
|
||||
msgid "About"
|
||||
msgstr "O programie"
|
||||
|
||||
#: data/ui/DrawingPerspective.ui:68
|
||||
msgid "Undo delete drawing"
|
||||
msgstr "Cofnij usunięcie rysunku"
|
||||
|
||||
#: data/ui/DrawingPerspective.ui:132
|
||||
msgid "Press the button on the device to synchronize drawings"
|
||||
msgstr "Proszę nacisnąć przycisk na urządzeniu, aby zsynchronizować rysunki"
|
||||
|
||||
#: data/ui/DrawingPerspective.ui:143 data/ui/SetupPerspective.ui:121
|
||||
msgid "page0"
|
||||
msgstr "strona 0"
|
||||
|
||||
#: data/ui/ErrorPerspective.ui:21
|
||||
msgid ""
|
||||
"TuhiGUI is an interactive GUI to download data from Tuhi.\n"
|
||||
"\n"
|
||||
"Tuhi connects to tablets of the Wacom Ink range. It allows you to download "
|
||||
"the drawings stored on those devices as SVGs for processing later.\n"
|
||||
"\n"
|
||||
"Tuhi is a DBus server that needs to be running for the Tuhi GUI to connect "
|
||||
"to it. Connecting to the DBus server should take less than a second. If you "
|
||||
"read this far, your Tuhi DBus server is not running or responsing and needs "
|
||||
"to be restarted."
|
||||
msgstr ""
|
||||
"TuhiGUI to interaktywny interfejs użytkownika do pobierania danych z usługi "
|
||||
"Tuhi.\n"
|
||||
"\n"
|
||||
"Tuhi łączy się z tabletami z rodziny Wacom Ink. Umożliwia pobieranie obrazów "
|
||||
"przechowywanych na tych urządzeniach jako pliki SVG do przetwarzania "
|
||||
"w późniejszym czasie.\n"
|
||||
"\n"
|
||||
"Tuhi to serwer D-Bus, który musi być uruchomiony, aby interfejs Tuhi mógł "
|
||||
"się z nim połączyć. Połączenie z serwerem D-Bus powinno zająć mniej niż "
|
||||
"sekundę. Jeśli jeszcze czytasz ten tekst, to znaczy że serwer D-Bus Tuhi nie "
|
||||
"jest uruchomiony lub nie odpowiada i wymaga ponownego uruchomienia."
|
||||
|
||||
#: data/ui/ErrorPerspective.ui:69
|
||||
msgid "Connecting to Tuhi"
|
||||
msgstr "Łączenie z usługą Tuhi"
|
||||
|
||||
#: data/ui/ErrorPerspective.ui:96
|
||||
msgid ""
|
||||
"This should take less than a second. Make sure the Tuhi DBus server is "
|
||||
"running."
|
||||
msgstr ""
|
||||
"To powinno zająć mniej niż sekundę. Proszę się upewnić, że serwer D-Bus Tuhi "
|
||||
"jest uruchomiony."
|
||||
|
||||
#: data/ui/MainWindow.ui:166
|
||||
msgid "Authorization error while connecting to the device "
|
||||
msgstr "Błąd upoważnienia podczas łączenia z urządzeniem "
|
||||
|
||||
#: data/ui/MainWindow.ui:176
|
||||
msgid "Register"
|
||||
msgstr "Zarejestruj"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:7
|
||||
msgid "Initial Device Setup"
|
||||
msgstr "Pierwsza konfiguracja urządzenia"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:30
|
||||
msgid "Quit"
|
||||
msgstr "Zakończ"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:70
|
||||
msgid "Hold the button on the device until the blue light is flashing."
|
||||
msgstr ""
|
||||
"Proszę przytrzymać przycisk na urządzeniu, aż niebieska dioda zacznie migać."
|
||||
|
||||
#: data/ui/SetupPerspective.ui:103
|
||||
msgid "Searching for device"
|
||||
msgstr "Wyszukiwanie urządzenia"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:137
|
||||
msgid "Connecting to LE Paper"
|
||||
msgstr "Łączenie z LE Paper"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:170
|
||||
msgid "Connecting to device..."
|
||||
msgstr "Łączenie z urządzeniem…"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:188
|
||||
msgid "page1"
|
||||
msgstr "1. strona"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:206
|
||||
msgid "Press the button on the device now!"
|
||||
msgstr "Proszę teraz nacisnąć przycisk na urządzeniu."
|
||||
|
||||
#: data/ui/SetupPerspective.ui:240
|
||||
msgid "waiting for reply"
|
||||
msgstr "oczekiwanie na odpowiedź"
|
||||
|
||||
#: data/ui/SetupPerspective.ui:258
|
||||
msgid "page2"
|
||||
msgstr "2. strona"
|
||||
|
||||
#: tuhi/gui/drawing.py:100
|
||||
msgid "Please choose a file"
|
||||
msgstr "Proszę wybrać plik"
|
||||
|
||||
#. Translators: the default filename to save to
|
||||
#: tuhi/gui/drawing.py:108
|
||||
msgid "untitled.svg"
|
||||
msgstr "bez tytułu.svg"
|
||||
|
||||
#. Translators: filter name to show all/any files
|
||||
#: tuhi/gui/drawing.py:112
|
||||
msgid "Any files"
|
||||
msgstr "Wszystkie pliki"
|
||||
|
||||
#. Translators: filter to show svg files only
|
||||
#: tuhi/gui/drawing.py:116
|
||||
msgid "SVG files"
|
||||
msgstr "Pliki SVG"
|
||||
|
||||
#: tuhi/gui/window.py:68
|
||||
#, python-brace-format
|
||||
msgid "Connecting to {device.name}"
|
||||
msgstr "Łączenie z urządzeniem {device.name}"
|
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 11 KiB |
|
@ -1,590 +0,0 @@
|
|||
#!/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# 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 2 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/>.
|
||||
#
|
||||
|
||||
import calendar
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa
|
||||
|
||||
from tuhi.protocol import *
|
||||
|
||||
|
||||
SUCCESS = NordicData([0xb3, 0x1, 0x00])
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
def test_hex_string(self):
|
||||
values = [
|
||||
([0x00, 0x12], '00 12'),
|
||||
([0x00], '00'),
|
||||
([0xab], 'ab'),
|
||||
([0x00, 0x12, 0xab, 0xdf], '00 12 ab df'),
|
||||
((16).to_bytes(1, byteorder='little'), '10'),
|
||||
((1024).to_bytes(2, byteorder='little'), '00 04'),
|
||||
([], '')
|
||||
]
|
||||
|
||||
for v in values:
|
||||
self.assertEqual(as_hex_string(v[0]), v[1])
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
as_hex_string(1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
as_hex_string('0x00')
|
||||
|
||||
def test_protocol_version(self):
|
||||
values = [
|
||||
('INTUOS_PRO', ProtocolVersion.INTUOS_PRO),
|
||||
('intuos_pro', ProtocolVersion.INTUOS_PRO),
|
||||
('intuos-pro', ProtocolVersion.INTUOS_PRO),
|
||||
('SLATE', ProtocolVersion.SLATE),
|
||||
('slate', ProtocolVersion.SLATE),
|
||||
('SPARK', ProtocolVersion.SPARK),
|
||||
('spark', ProtocolVersion.SPARK),
|
||||
]
|
||||
|
||||
for v in values:
|
||||
self.assertEqual(ProtocolVersion.from_string(v[0]), v[1])
|
||||
|
||||
# No real reason we couldn't support those but right now they
|
||||
# aren't, so let's test for it.
|
||||
with self.assertRaises(ValueError):
|
||||
ProtocolVersion.from_string('Slate')
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
ProtocolVersion.from_string('IntuosPro')
|
||||
|
||||
def test_little_u16(self):
|
||||
values = [
|
||||
(1, [0x01, 0x00]),
|
||||
(256, [0x00, 0x01]),
|
||||
]
|
||||
|
||||
for v in values:
|
||||
self.assertEqual(little_u16(v[0]), bytes(v[1]))
|
||||
self.assertEqual(little_u16(v[1]), v[0])
|
||||
|
||||
invalid = [0x10000, -1, [0x00, 0x00, 0x00]]
|
||||
for v in invalid:
|
||||
with self.assertRaises(AssertionError):
|
||||
little_u16(v)
|
||||
|
||||
def test_little_u32(self):
|
||||
values = [
|
||||
(1, [0x01, 0x00, 0x00, 0x00]),
|
||||
(256, [0x00, 0x01, 0x00, 0x00]),
|
||||
(0x10000, [0x00, 0x00, 0x01, 0x00]),
|
||||
(0x1000000, [0x00, 0x00, 0x00, 0x01]),
|
||||
]
|
||||
|
||||
for v in values:
|
||||
self.assertEqual(little_u32(v[0]), bytes(v[1]))
|
||||
self.assertEqual(little_u32(v[1]), v[0])
|
||||
|
||||
invalid = [0x100000000, -1, [0x00, 0x00, 0x00, 0x00, 0x00]]
|
||||
for v in invalid:
|
||||
with self.assertRaises(AssertionError):
|
||||
little_u32(v)
|
||||
|
||||
|
||||
class TestProtocolAny(unittest.TestCase):
|
||||
protocol_version = ProtocolVersion.ANY
|
||||
|
||||
def test_get_protocol(self):
|
||||
self.assertIsNotNone(Protocol(self.protocol_version, callback=None))
|
||||
|
||||
def test_has_all_messages(self):
|
||||
p = Protocol(self.protocol_version, callback=None)
|
||||
for m in Interactions:
|
||||
# Some messages expect an argument and fail, that's fine for
|
||||
# this test. We're looking for KeyErrors here if a message
|
||||
# doesn't exist so we try each message with one of the likely
|
||||
# arguments that will pass
|
||||
args = [None, '101010', [0x12], Mode.LIVE]
|
||||
for arg in args:
|
||||
try:
|
||||
if arg is None:
|
||||
p.get(m)
|
||||
else:
|
||||
p.get(m, arg)
|
||||
except TypeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
|
||||
def test_connect(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xe6)
|
||||
self.assertEqual(request.length, 6)
|
||||
return SUCCESS
|
||||
|
||||
if cb is None:
|
||||
cb = _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
with self.assertRaises(TypeError):
|
||||
p.execute(Interactions.CONNECT) # missing argument
|
||||
|
||||
uuid = 'abcdef123456'
|
||||
msg = p.execute(Interactions.CONNECT, uuid)
|
||||
self.assertEqual(msg.uuid, uuid)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
p.execute(Interactions.CONNECT, 'too-long-an-id')
|
||||
|
||||
with self.assertRaises(binascii.Error):
|
||||
uuid = 'uvwxyz123456'
|
||||
p.execute(Interactions.CONNECT, uuid)
|
||||
|
||||
def test_get_name(self, cb=None, name='test dev name'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xbb)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
return NordicData([0xbc, len(name)] + list(bytes(name, encoding='ascii')))
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_NAME)
|
||||
self.assertEqual(msg.name, name)
|
||||
|
||||
def test_set_name(self, cb=None, name='test dev name'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xbb)
|
||||
self.assertEqual(request.length, len(name) + 1)
|
||||
self.assertEqual(request[-1], 0xa) # spark needs a trailing linebreak
|
||||
self.assertEqual(bytes(request[:-1]).decode('utf-8'), name)
|
||||
return SUCCESS
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.SET_NAME, name=name)
|
||||
|
||||
def test_get_time(self, cb=None, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb6)
|
||||
self.assertEqual(request.length, 1)
|
||||
t = time.strftime('%y%m%d%H%M%S', time.gmtime(ts))
|
||||
t = [int(i) for i in binascii.unhexlify(t)]
|
||||
return NordicData([0xbd, len(t)] + t)
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_TIME)
|
||||
self.assertEqual(msg.timestamp, int(ts))
|
||||
|
||||
def test_set_time(self, cb=None, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb6)
|
||||
self.assertEqual(request.length, 6)
|
||||
str_timestamp = ''.join([f'{b:02x}' for b in request])
|
||||
t = calendar.timegm(time.strptime(str_timestamp, '%y%m%d%H%M%S'))
|
||||
self.assertEqual(int(t), int(ts))
|
||||
return SUCCESS
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.SET_TIME, timestamp=ts)
|
||||
|
||||
def test_get_fw(self, cb=None, fw='abcdef-123456'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb7)
|
||||
self.assertEqual(request.length, 1)
|
||||
data = [int(c, 16) for c in fw.split('-')[request[0]]]
|
||||
return NordicData([0xb8, len(data) + 1, 0x00] + data)
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_FIRMWARE)
|
||||
self.assertEqual(msg.firmware, fw)
|
||||
|
||||
def test_get_battery(self, cb=None, battery=(1, 78)):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb9)
|
||||
self.assertEqual(request.length, 1)
|
||||
return NordicData([0xba, 2, battery[1], battery[0]])
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_BATTERY)
|
||||
self.assertEqual(msg.battery_is_charging, battery[0])
|
||||
self.assertEqual(msg.battery_percent, battery[1])
|
||||
|
||||
def test_get_width(self, cb=None):
|
||||
# this is hardcoded for the spark
|
||||
p = Protocol(self.protocol_version, callback=None)
|
||||
msg = p.execute(Interactions.GET_WIDTH)
|
||||
self.assertEqual(msg.width, 21000)
|
||||
|
||||
def test_get_height(self, cb=None):
|
||||
# this is hardcoded for the spark
|
||||
p = Protocol(self.protocol_version, callback=None)
|
||||
msg = p.execute(Interactions.GET_HEIGHT)
|
||||
self.assertEqual(msg.height, 14800)
|
||||
|
||||
def test_get_point_size(self, cb=None):
|
||||
# this is hardcoded for the spark
|
||||
p = Protocol(self.protocol_version, callback=None)
|
||||
msg = p.execute(Interactions.GET_POINT_SIZE)
|
||||
self.assertEqual(msg.point_size, 10)
|
||||
|
||||
def test_unknown_e3(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xe3)
|
||||
self.assertEqual(request.length, 1)
|
||||
return SUCCESS
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.UNKNOWN_E3)
|
||||
|
||||
def test_filetransfer_reporting_type(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xec)
|
||||
self.assertEqual(request.length, 6)
|
||||
self.assertEqual(request, [0x06, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
return SUCCESS
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.SET_FILE_TRANSFER_REPORTING_TYPE)
|
||||
|
||||
def test_set_mode(self, cb=None):
|
||||
for mode in Mode:
|
||||
mode = Mode.LIVE
|
||||
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb1)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], mode)
|
||||
return SUCCESS
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.SET_MODE, mode)
|
||||
|
||||
def test_get_strokes(self, cb=None, count=1024, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
# this is a weird double call, see the protocol
|
||||
# We reply 0xc7 first, and then 0xcd
|
||||
if request is not None:
|
||||
self.assertEqual(request.opcode, 0xc5)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
data = list(count.to_bytes(4, byteorder='big'))
|
||||
return NordicData([0xc7, len(data)] + data)
|
||||
else:
|
||||
t = time.strftime('%y%m%d%H%M%S', time.gmtime(ts))
|
||||
data = [int(i) for i in binascii.unhexlify(t)]
|
||||
return NordicData([0xcd, len(data)] + data)
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_STROKES)
|
||||
self.assertEqual(msg.count, count)
|
||||
self.assertEqual(msg.timestamp, int(ts))
|
||||
|
||||
def test_available_files_count(self, cb=None, ndata=1234):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xc1)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
data = list(ndata.to_bytes(2, byteorder='big'))
|
||||
return NordicData([0xc2, len(data)] + data)
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.AVAILABLE_FILES_COUNT)
|
||||
self.assertEqual(msg.count, ndata)
|
||||
|
||||
def test_download_oldest_file(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xc3)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
return NordicData([0xc8, 1, 0xbe])
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.DOWNLOAD_OLDEST_FILE)
|
||||
|
||||
def test_delete_oldest_file(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xca)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
# no reply
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.DELETE_OLDEST_FILE)
|
||||
|
||||
def test_register_complete(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xe5)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
return SUCCESS
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
p.execute(Interactions.REGISTER_COMPLETE)
|
||||
|
||||
def test_register_press_button(self, cb=None, uuid=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xe3)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x01)
|
||||
# no reply
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.REGISTER_PRESS_BUTTON, uuid=uuid)
|
||||
self.assertEqual(msg.uuid, uuid)
|
||||
|
||||
def test_error_invalid_state(self):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
return NordicData([0xb3, 0x1, 0x1])
|
||||
|
||||
p = Protocol(self.protocol_version, callback=_cb)
|
||||
|
||||
# a "random" collection of requests that we want to check for
|
||||
with self.assertRaises(DeviceError) as cm:
|
||||
p.execute(Interactions.CONNECT, uuid='abcdef123456')
|
||||
self.assertEqual(cm.exception.errorcode, DeviceError.ErrorCode.GENERAL_ERROR)
|
||||
|
||||
with self.assertRaises(DeviceError) as cm:
|
||||
p.execute(Interactions.GET_STROKES)
|
||||
self.assertEqual(cm.exception.errorcode, DeviceError.ErrorCode.GENERAL_ERROR)
|
||||
|
||||
with self.assertRaises(DeviceError) as cm:
|
||||
p.execute(Interactions.SET_MODE, Mode.PAPER)
|
||||
self.assertEqual(cm.exception.errorcode, DeviceError.ErrorCode.GENERAL_ERROR)
|
||||
|
||||
|
||||
class TestProtocolSpark(TestProtocolAny):
|
||||
protocol_version = ProtocolVersion.SPARK
|
||||
|
||||
def test_register_wait_for_button(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertIsNone(request)
|
||||
return NordicData([0xe4, 0x00])
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.REGISTER_WAIT_FOR_BUTTON)
|
||||
self.assertEqual(msg.protocol_version, self.protocol_version)
|
||||
|
||||
|
||||
class TestProtocolSlate(TestProtocolSpark):
|
||||
protocol_version = ProtocolVersion.SLATE
|
||||
|
||||
def test_get_width(self, cb=None, width=1234):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xea)
|
||||
self.assertEqual(request.length, 2)
|
||||
self.assertEqual(request[0], 3)
|
||||
|
||||
data = [0x03, 0x00] + list(width.to_bytes(4, byteorder='little'))
|
||||
return NordicData([0xeb, len(data)] + data)
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_WIDTH)
|
||||
self.assertEqual(msg.width, width)
|
||||
|
||||
def test_get_height(self, cb=None, height=4321):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xea)
|
||||
self.assertEqual(request.length, 2)
|
||||
self.assertEqual(request[0], 4)
|
||||
|
||||
data = [0x04, 0x00] + list(height.to_bytes(4, byteorder='little'))
|
||||
return NordicData([0xeb, len(data)] + data)
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_HEIGHT)
|
||||
self.assertEqual(msg.height, height)
|
||||
|
||||
def test_get_strokes(self, cb=None, count=1024, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xcc)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
c = list(count.to_bytes(4, byteorder='little'))
|
||||
t = time.strftime('%y%m%d%H%M%S', time.gmtime(ts))
|
||||
t = [int(i) for i in binascii.unhexlify(t)]
|
||||
data = c + t
|
||||
return NordicData([0xcf, len(data)] + data)
|
||||
|
||||
super().test_get_strokes(cb or _cb, count=count, ts=ts)
|
||||
|
||||
def test_available_files_count(self, cb=None, ndata=1234):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xc1)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
data = list(ndata.to_bytes(2, byteorder='little'))
|
||||
return NordicData([0xc2, len(data)] + data)
|
||||
|
||||
super().test_available_files_count(cb or _cb, ndata=ndata)
|
||||
|
||||
def test_delete_oldest_file(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xca)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
return SUCCESS
|
||||
|
||||
super().test_delete_oldest_file(cb or _cb)
|
||||
|
||||
def test_register_press_button(self, cb=None, uuid='abcdef123456'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xe7)
|
||||
self.assertEqual(request.length, 6)
|
||||
# no reply
|
||||
|
||||
super().test_register_press_button(cb or _cb, uuid)
|
||||
|
||||
def test_register_wait_for_button(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertIsNone(request)
|
||||
return NordicData([0xe4, 0x00])
|
||||
|
||||
super().test_register_wait_for_button(cb or _cb)
|
||||
|
||||
|
||||
class TestProtocolIntuosPro(TestProtocolSlate):
|
||||
protocol_version = ProtocolVersion.INTUOS_PRO
|
||||
|
||||
def test_connect(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xe6)
|
||||
self.assertEqual(request.length, 6)
|
||||
return NordicData([0x50, 0x06] + request) # replies with the uuid
|
||||
|
||||
super().test_connect(cb or _cb)
|
||||
|
||||
def test_get_name(self, cb=None, name='test dev name'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xdb)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
return NordicData([0xbc, len(name)] + list(bytes(name, encoding='ascii')))
|
||||
|
||||
super().test_get_name(cb or _cb, name=name)
|
||||
|
||||
def test_set_name(self, cb=None, name='test dev name'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xdb)
|
||||
self.assertEqual(request.length, len(name))
|
||||
self.assertEqual(bytes(request).decode('utf-8'), name)
|
||||
return SUCCESS
|
||||
|
||||
super().test_set_name(cb or _cb, name=name)
|
||||
|
||||
def test_get_time(self, cb=None, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xd6)
|
||||
self.assertEqual(request.length, 1)
|
||||
t = list(int(ts).to_bytes(length=4, byteorder='little')) + [0x00, 0x00]
|
||||
return NordicData([0xbd, len(t)] + t)
|
||||
|
||||
super().test_get_time(cb or _cb, ts=ts)
|
||||
|
||||
def test_set_time(self, cb=None, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb6)
|
||||
self.assertEqual(request.length, 6)
|
||||
t = int.from_bytes(request[0:4], byteorder='little')
|
||||
self.assertEqual(int(t), int(ts))
|
||||
return SUCCESS
|
||||
|
||||
super().test_set_time(cb or _cb, ts=ts)
|
||||
|
||||
def test_get_fw(self, cb=None, fw='anything-string'):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xb7)
|
||||
self.assertEqual(request.length, 1)
|
||||
data = bytes(fw.split('-')[request[0]].encode('utf8'))
|
||||
return NordicData([0xb8, len(data) + 1, 0x00] + list(data))
|
||||
|
||||
super().test_get_fw(cb or _cb, fw=fw)
|
||||
|
||||
def test_get_strokes(self, cb=None, count=1024, ts=time.time()):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xcc)
|
||||
self.assertEqual(request.length, 1)
|
||||
self.assertEqual(request[0], 0x00)
|
||||
c = list(count.to_bytes(4, byteorder='little'))
|
||||
t = list(int(ts).to_bytes(4, byteorder='little'))
|
||||
data = c + t
|
||||
return NordicData([0xcf, len(data)] + data)
|
||||
|
||||
super().test_get_strokes(cb or _cb, count=count, ts=ts)
|
||||
|
||||
def test_register_wait_for_button(self, cb=None):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertIsNone(request)
|
||||
return NordicData([0x53, 0x00])
|
||||
|
||||
super().test_register_wait_for_button(cb or _cb)
|
||||
|
||||
def test_get_point_size(self, cb=None, pointsize=12):
|
||||
def _cb(request, requires_reply=True, userdata=None, timeout=5):
|
||||
self.assertEqual(request.opcode, 0xea)
|
||||
self.assertEqual(request.length, 2)
|
||||
self.assertEqual(request[0], 0x14)
|
||||
ps = little_u32(pointsize)
|
||||
return NordicData([0xeb, 6, 0x14, 0x00] + list(ps))
|
||||
|
||||
cb = cb or _cb
|
||||
|
||||
p = Protocol(self.protocol_version, callback=cb)
|
||||
msg = p.execute(Interactions.GET_POINT_SIZE)
|
||||
self.assertEqual(msg.point_size, pointsize - 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(sys.argv[1:])
|
|
@ -1,466 +0,0 @@
|
|||
#!/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# 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 2 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/>.
|
||||
#
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import xdg.BaseDirectory
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import logging
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa
|
||||
|
||||
from tuhi.protocol import *
|
||||
from tuhi.util import flatten
|
||||
|
||||
logger = logging.getLogger('tuhi') # piggyback the debug messages
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class TestLogFiles(unittest.TestCase):
|
||||
'''
|
||||
Special test class that loads a yaml file created by tuhi compiles
|
||||
the StrokeData from it. This class autogenerates its own tests, see
|
||||
the main() handling.
|
||||
'''
|
||||
def load_pen_data(self, filename):
|
||||
with open(filename) as fd:
|
||||
yml = yaml.load(fd, Loader=yaml.Loader)
|
||||
# all recv lists that have source PEN
|
||||
pendata = [d['recv'] for d in yml['data'] if 'recv' in d and 'source' in d and d['source'] == 'PEN']
|
||||
return list(flatten(pendata))
|
||||
|
||||
def _test_file(self, fname):
|
||||
data = self.load_pen_data(fname)
|
||||
if not data: # Recordings without Pen data can be skipped
|
||||
raise unittest.SkipTest()
|
||||
StrokeFile(data)
|
||||
|
||||
|
||||
class TestStrokeParsers(unittest.TestCase):
|
||||
def test_identify_file_header(self):
|
||||
data = [0x67, 0x82, 0x69, 0x65]
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.FILE_HEADER)
|
||||
data = [0x62, 0x38, 0x62, 0x74]
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.FILE_HEADER)
|
||||
|
||||
others = [
|
||||
# with header
|
||||
[0xff, 0x62, 0x38, 0x62, 0x74],
|
||||
[0xff, 0x67, 0x82, 0x69, 0x65],
|
||||
# wrong size
|
||||
[0x67, 0x82, 0x69],
|
||||
[0x67, 0x82],
|
||||
[0x67],
|
||||
[0x62, 0x38, 0x62],
|
||||
[0x62, 0x38],
|
||||
[0x62],
|
||||
# wrong numbers
|
||||
[0x67, 0x82, 0x69, 0x64],
|
||||
[0x62, 0x38, 0x62, 0x73],
|
||||
]
|
||||
for data in others:
|
||||
self.assertNotEqual(StrokeDataType.identify(data), StrokeDataType.FILE_HEADER, msg=data)
|
||||
|
||||
def test_identify_stroke_header(self):
|
||||
data = [0xff, 0xfa] # two bytes are enough to identify
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.STROKE_HEADER)
|
||||
|
||||
data = [0x3, 0xfa] # lowest bits set, not a correct packet but identify doesn't care
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.STROKE_HEADER)
|
||||
|
||||
data = [0xfc, 0xfa] # lowest bits unset, must be something else
|
||||
self.assertNotEqual(StrokeDataType.identify(data), StrokeDataType.STROKE_HEADER)
|
||||
|
||||
def test_identify_stroke_point(self):
|
||||
data = [0xff, 0xff, 0xff] # three bytes are enough to identify
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.POINT)
|
||||
|
||||
data = [0xff, 0xff, 0xff, 1, 2, 3, 4, 5, 6]
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.POINT)
|
||||
|
||||
# wrong header, but observed in the wild
|
||||
data = [0xbf, 0xff, 0xff, 1, 2, 3, 4, 5, 6]
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.POINT)
|
||||
|
||||
data = [0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] # stroke end
|
||||
self.assertNotEqual(StrokeDataType.identify(data), StrokeDataType.POINT)
|
||||
|
||||
data = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] # EOF
|
||||
self.assertNotEqual(StrokeDataType.identify(data), StrokeDataType.POINT)
|
||||
|
||||
def test_identify_stroke_lost_point(self):
|
||||
data = [0xff, 0xdd, 0xdd]
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.LOST_POINT)
|
||||
|
||||
def test_identify_eof(self):
|
||||
data = [0xff] * 9
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.EOF)
|
||||
|
||||
def test_identify_stroke_end(self):
|
||||
data = [0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.STROKE_END)
|
||||
|
||||
def test_identify_delta(self):
|
||||
for i in range(256):
|
||||
data = [i, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]
|
||||
if i & 0x3 == 0:
|
||||
self.assertEqual(StrokeDataType.identify(data), StrokeDataType.DELTA, f'packet: {data}')
|
||||
else:
|
||||
self.assertNotEqual(StrokeDataType.identify(data), StrokeDataType.DELTA, f'packet: {data}')
|
||||
|
||||
def test_parse_stroke_header(self):
|
||||
F_NEW_LAYER = 0x40
|
||||
F_PEN_ID = 0x80
|
||||
pen_type = 3
|
||||
flags = F_NEW_LAYER | pen_type
|
||||
|
||||
data = [0xff, 0xfa, flags, 0x1f, 0x73, 0x53, 0x5d, 0x2e, 0x01]
|
||||
packet = StrokeHeader(data)
|
||||
self.assertEqual(packet.size, 9)
|
||||
self.assertEqual(packet.is_new_layer, 1)
|
||||
self.assertEqual(packet.pen_id, 0)
|
||||
self.assertEqual(packet.pen_type, pen_type)
|
||||
self.assertEqual(packet.timestamp, 1565750047)
|
||||
|
||||
# new layer off
|
||||
flags = pen_type
|
||||
data = [0xff, 0xfa, flags, 0x1f, 0x73, 0x53, 0x5d, 0x2e, 0x01]
|
||||
packet = StrokeHeader(data)
|
||||
self.assertEqual(packet.size, 9)
|
||||
self.assertEqual(packet.is_new_layer, 0)
|
||||
self.assertEqual(packet.pen_id, 0)
|
||||
self.assertEqual(packet.pen_type, pen_type)
|
||||
self.assertEqual(packet.timestamp, 1565750047)
|
||||
|
||||
# pen type change
|
||||
pen_type = 1
|
||||
flags = F_NEW_LAYER | pen_type
|
||||
data = [0xff, 0xfa, flags, 0x1f, 0x73, 0x53, 0x5d, 0x2e, 0x01]
|
||||
packet = StrokeHeader(data)
|
||||
self.assertEqual(packet.size, 9)
|
||||
self.assertEqual(packet.is_new_layer, 1)
|
||||
self.assertEqual(packet.pen_id, 0)
|
||||
self.assertEqual(packet.pen_type, pen_type)
|
||||
self.assertEqual(packet.timestamp, 1565750047)
|
||||
|
||||
# with pen id
|
||||
flags = F_NEW_LAYER | F_PEN_ID | pen_type
|
||||
pen_id = [0xff, 0x0a, 0x87, 0x75, 0x80, 0x28, 0x42, 0x00, 0x10]
|
||||
data = [0xff, 0xfa, flags, 0x1f, 0x73, 0x53, 0x5d, 0x2e, 0x01] + pen_id
|
||||
packet = StrokeHeader(data)
|
||||
self.assertEqual(packet.size, 18)
|
||||
self.assertEqual(packet.is_new_layer, 1)
|
||||
self.assertEqual(packet.pen_id, 0x100042288075870a)
|
||||
self.assertEqual(packet.pen_type, pen_type)
|
||||
self.assertEqual(packet.timestamp, 1565750047)
|
||||
|
||||
def test_parse_stroke_point(self):
|
||||
# 0xff means 2 bytes each for abs coords
|
||||
data = [0xff, 0xff, 0xff, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]
|
||||
packet = StrokePoint(data)
|
||||
self.assertEqual(packet.size, 9)
|
||||
self.assertEqual(packet.x, 0x0201)
|
||||
self.assertEqual(packet.y, 0x0403)
|
||||
self.assertEqual(packet.p, 0x0605)
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertIsNone(packet.dp)
|
||||
|
||||
# 0xbf means: 1 byte for pressure delta, i.e. the 0x6 is skipped
|
||||
data = [0xbf, 0xff, 0xff, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]
|
||||
packet = StrokePoint(data)
|
||||
self.assertEqual(packet.size, 8)
|
||||
self.assertEqual(packet.x, 0x0201)
|
||||
self.assertEqual(packet.y, 0x0403)
|
||||
self.assertIsNone(packet.p)
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertEqual(packet.dp, 0x5)
|
||||
|
||||
def test_parse_lost_point(self):
|
||||
data = [0xff, 0xdd, 0xdd, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]
|
||||
packet = StrokeLostPoint(data)
|
||||
self.assertEqual(packet.size, 9)
|
||||
self.assertEqual(packet.nlost, 0x0201)
|
||||
|
||||
def test_parse_eof(self):
|
||||
data = [0xff] * 9
|
||||
packet = StrokeEOF(data)
|
||||
self.assertEqual(packet.size, 9)
|
||||
|
||||
data = [0xfc] + [0xff] * 6
|
||||
packet = StrokeEOF(data)
|
||||
self.assertEqual(packet.size, 7)
|
||||
|
||||
def test_parse_delta(self):
|
||||
x_delta = 0b00001000 # noqa
|
||||
x_abs = 0b00001100 # noqa
|
||||
y_delta = 0b00100000 # noqa
|
||||
y_abs = 0b00110000 # noqa
|
||||
p_delta = 0b10000000 # noqa
|
||||
p_abs = 0b11000000 # noqa
|
||||
|
||||
flags = x_delta
|
||||
data = [flags, 1]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertEqual(packet.dx, 1)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertIsNone(packet.dp)
|
||||
self.assertIsNone(packet.x)
|
||||
self.assertIsNone(packet.y)
|
||||
self.assertIsNone(packet.p)
|
||||
|
||||
flags = y_delta
|
||||
data = [flags, 2]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertEqual(packet.dy, 2)
|
||||
self.assertIsNone(packet.dp)
|
||||
self.assertIsNone(packet.x)
|
||||
self.assertIsNone(packet.y)
|
||||
self.assertIsNone(packet.p)
|
||||
|
||||
flags = p_delta
|
||||
data = [flags, 3]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertEqual(packet.dp, 3)
|
||||
self.assertIsNone(packet.x)
|
||||
self.assertIsNone(packet.y)
|
||||
self.assertIsNone(packet.p)
|
||||
|
||||
flags = x_delta | p_delta
|
||||
data = [flags, 3, 5]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertEqual(packet.dx, 3)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertEqual(packet.dp, 5)
|
||||
self.assertIsNone(packet.x)
|
||||
self.assertIsNone(packet.y)
|
||||
self.assertIsNone(packet.p)
|
||||
|
||||
flags = x_delta | y_delta | p_delta
|
||||
data = [flags, 3, 5, 7]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertEqual(packet.dx, 3)
|
||||
self.assertEqual(packet.dy, 5)
|
||||
self.assertEqual(packet.dp, 7)
|
||||
self.assertIsNone(packet.x)
|
||||
self.assertIsNone(packet.y)
|
||||
self.assertIsNone(packet.p)
|
||||
|
||||
flags = x_abs | y_abs | p_abs
|
||||
data = [flags, 1, 2, 3, 4, 5, 6]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertEqual(packet.x, 0x0201)
|
||||
self.assertEqual(packet.y, 0x0403)
|
||||
self.assertEqual(packet.p, 0x0605)
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertIsNone(packet.dp)
|
||||
|
||||
flags = y_abs
|
||||
data = [flags, 2, 3]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertIsNone(packet.x)
|
||||
self.assertEqual(packet.y, 0x0302)
|
||||
self.assertIsNone(packet.p)
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertIsNone(packet.dy)
|
||||
self.assertIsNone(packet.dp)
|
||||
|
||||
flags = x_abs | y_delta | p_delta
|
||||
data = [flags, 2, 3, 4, 5]
|
||||
packet = StrokeDelta(data)
|
||||
self.assertEqual(packet.size, len(data))
|
||||
self.assertEqual(packet.x, 0x0302)
|
||||
self.assertIsNone(packet.y)
|
||||
self.assertIsNone(packet.p)
|
||||
self.assertIsNone(packet.dx)
|
||||
self.assertEqual(packet.dy, 4)
|
||||
self.assertEqual(packet.dp, 5)
|
||||
|
||||
|
||||
class TestStrokes(unittest.TestCase):
|
||||
def test_single_stroke(self):
|
||||
data = '''
|
||||
67 82 69 65 22 73 53 5d 00 00 02 00 00 00 00 00 ff fa c3 1f
|
||||
73 53 5d 2e 01 ff 0a 87 75 80 28 42 00 10 ff ff ff 41 3c 13
|
||||
30 72 03 c8 01 cb 04 e8 ff 01 0f 06 e0 ff 5f 07 e0 ff 91 08
|
||||
c0 78 09 e8 02 01 2f 0a e8 fe 01 ce 0a e0 ff 55 0b e8 01 01
|
||||
dc 0b c8 fe 6f 0c a8 03 ff 75 a8 fe 02 f4 a8 02 ff 06 88 ff
|
||||
ee 88 01 f1 80 fd 88 ff f4 88 01 f4 a8 fe ff f7 a8 01 01 fa
|
||||
88 ff f7 80 f4 a8 01 01 f3 88 ff 01 a8 ff ff fd a0 03 f4 80
|
||||
d3 a0 02 df a0 02 be a8 fe 02 b2 a0 02 c7 e8 ff 02 8e 0a e8
|
||||
fe 01 15 08 e8 01 03 91 04 e8 f3 26 94 01 ff fa 03 21 73 53
|
||||
5d 46 02 ff ff ff d3 6f 5a 38 c0 03 e8 fb 2b 5c 04 a8 fa 2c
|
||||
78 a8 02 ff 4e a8 01 ff 0c a0 fd be 88 02 d6 a8 ff fe f7 a8
|
||||
ff ff d3 a0 01 fd a8 01 ff 15 88 fd 15 a0 ff d9 80 fd 88 fe
|
||||
f4 a8 01 ff f1 a8 ff fe fd 80 09 a8 fe fc 03 88 01 03 a8 ff
|
||||
ff 1e a8 ff ff c6 88 01 1c a8 ff ff f7 a8 02 01 ee 28 01 fe
|
||||
a8 01 ff fd 88 03 f4 a8 ff ff 0b a8 01 fe ff 80 f6 80 0c a0
|
||||
ff f1 a8 ff 01 0f 88 01 02 a8 01 fe 10 a8 fe 01 05 88 01 e9
|
||||
88 fe e2 a0 02 1e a0 02 0c a8 ff ff f4 a8 01 02 fd a8 ff fe
|
||||
ee 80 ee a8 01 ff 03 28 ff fe 80 09 88 01 06 a8 ff fe eb a8
|
||||
02 01 29 88 ff 01 a0 01 02 a8 01 ff fe a8 ff 01 f6 80 0a 88
|
||||
02 03 08 ff 88 01 33 a8 01 ff 09 a0 01 24 a8 01 01 fd a0 ff
|
||||
df a0 02 09 a0 02 0c a8 ff 02 f4 a8 ff 03 03 a8 01 02 f1 88
|
||||
fe 0f a0 01 eb 80 06 a8 ff 01 f1 28 fe 02 88 01 09 a8 ff 01
|
||||
03 a8 ff 01 f4 a8 02 ff 0f a0 01 09 88 02 eb a8 ff ff fa a8
|
||||
01 ff d6 a8 ff ff ee a8 ff fd c4 a0 fe dc a8 01 fd 12 a8 01
|
||||
fd c4 a8 02 fd dc a8 01 fd a6 a0 fc 94 e8 ff fc 8a 06 fc ff
|
||||
ff ff ff ff ff
|
||||
'''
|
||||
b = [d.strip() for d in data.split(' ') if d not in ['', '\n']]
|
||||
b = [int(x, 16) for x in b]
|
||||
|
||||
p = Protocol(ProtocolVersion.INTUOS_PRO, None, None)
|
||||
p.parse_pen_data(b)
|
||||
|
||||
def test_double_stroke(self):
|
||||
data = '''
|
||||
67 82 69 65 28 c7 53 5d 00 00 02 00 00 00 00 00 ff fa c3 26
|
||||
c7 53 5d a8 01 ff 0a 87 75 80 28 42 00 10 ff ff ff f6 29 da
|
||||
1d a4 04 e0 02 0c 06 e0 04 b7 06 e0 04 47 07 e8 01 08 2e 08
|
||||
e0 09 06 09 e0 09 ab 09 e8 01 0a 4d 0a a8 ff 0b 72 a0 0b 06
|
||||
a0 05 e5 a8 ff 05 f7 a8 02 01 15 a8 fe 01 d6 a0 ff e5 a8 01
|
||||
fe 15 a8 ff fe f7 a8 02 fd e5 a8 01 fe eb a8 01 fe ff a0 fd
|
||||
ff a8 ff fd 01 a8 02 fd e6 a8 fd fd 18 a0 fd d9 a0 ff dc a8
|
||||
fd fc 04 a8 01 fe 49 28 ff fc a0 fc ff a0 fa ed a8 01 f9 12
|
||||
a8 fe f9 03 a8 01 f8 fd a8 01 fa d0 a0 fd b5 a8 01 fd d9 a0
|
||||
fd bb e8 ff f9 12 08 e8 ff cc 3b 03 ff fa 03 26 c7 53 5d 6d
|
||||
00 ff ff ff 42 2f fb 1c 65 0a e0 fd f1 0b e8 ff fd b7 0c a8
|
||||
02 03 75 a0 04 09 a0 05 18 a8 03 0a 15 a8 01 0d eb a8 03 0e
|
||||
d0 a8 ff 0f d0 a8 ff 0d 12 a0 0b e2 a8 fd 08 fa 28 ff 07 a8
|
||||
ff 05 f1 20 3a a8 01 02 0c a8 f6 03 f4 28 01 c5 a8 05 fa 0f
|
||||
a8 02 c8 f7 a0 fc fd a0 fe fa a8 02 24 ee a8 ff f6 fe a8 02
|
||||
f6 f3 a8 ff f6 d9 a0 f7 ee a0 f6 d0 a8 ff f9 e2 a0 fb f1 a8
|
||||
ff fa dc e8 01 fd 2a 0d e8 ff f9 57 0a e8 fa e7 9c 04 fc ff
|
||||
ff ff ff ff ff
|
||||
'''
|
||||
b = [d.strip() for d in data.split(' ') if d not in ['', '\n']]
|
||||
b = [int(x, 16) for x in b]
|
||||
|
||||
p = Protocol(ProtocolVersion.INTUOS_PRO, None, None)
|
||||
p.parse_pen_data(b)
|
||||
|
||||
def test_quint_stroke(self):
|
||||
data = '''
|
||||
67 82 69 65 cc ce 53 5d 00 00 05 00 00 00 00 00 ff fa c3 c7
|
||||
ce 53 5d 8d 00 ff 0a 87 75 80 28 42 00 10 ff ff ff 95 29 a9
|
||||
1e 23 06 e0 01 a0 07 e8 ff ff db 08 e8 01 01 e9 09 e0 ff bb
|
||||
0a e0 01 81 0b c0 1a 0c 80 7b a0 02 f1 a8 ff 03 09 a8 ff 03
|
||||
18 a0 04 e2 a8 fd 02 03 a8 02 03 df a8 fe 03 f7 a0 03 0c a8
|
||||
ff 06 e2 a0 03 e8 a8 01 03 f4 a8 01 03 ee a8 01 01 fe a0 03
|
||||
d8 a8 01 03 c7 a0 02 fd a8 01 03 06 a8 ff 01 f7 a8 ff 02 1b
|
||||
08 ff a8 ff 03 1e a0 02 09 a0 03 15 a8 fe 05 fa a8 ff 02 18
|
||||
a0 04 10 a8 ff 01 f0 80 fa a0 ff 06 a0 fe 0f a0 fc f1 a8 01
|
||||
fc 01 28 02 fa a8 01 f8 17 a8 01 f9 e8 28 03 f9 28 01 f9 28
|
||||
ff f9 a0 f9 02 a0 fa ff a0 fb ff a8 01 fb e9 a8 01 fa c3 a8
|
||||
01 f9 91 e0 f8 cc 0a e8 05 c4 76 06 ff fa 03 c8 ce 53 5d 10
|
||||
02 ff ff ff bc 36 40 1e 00 08 e8 f2 26 14 09 e8 02 0d d7 09
|
||||
e8 01 0b 7c 0a a0 0b 69 a0 0a 51 a8 ff 06 f1 a8 ff 07 e8 a8
|
||||
fe 03 e5 a8 f7 35 eb 88 ff fa a8 10 99 f7 20 01 28 f8 2f a8
|
||||
ff fe d6 a8 01 fa eb 28 01 fb 28 02 fa 28 03 fc a8 02 fa eb
|
||||
28 03 fc a8 02 f8 df a8 03 fb f4 a8 01 f9 d6 a8 02 f9 b8 a8
|
||||
02 f7 03 a0 f8 94 e8 02 f7 a3 0a e0 f9 bd 06 e8 0a eb 05 02
|
||||
ff fa 03 c8 ce 53 5d 5b 00 ff ff ff 0a 40 19 1e 5b 05 e8 fe
|
||||
0d b7 06 e8 fd 0e b3 07 e8 ff 07 88 08 e0 07 2d 09 a0 06 54
|
||||
a0 06 75 a8 ff 04 18 a0 05 e8 a8 ff 03 d0 a8 fe 01 fd a8 01
|
||||
02 dc a8 fe 02 ee a0 ff fa 80 f4 a8 ff fe fd a8 01 ff df a8
|
||||
01 fe df a0 fd fa a8 03 ff fd a0 fd 0c a8 01 fd f4 a8 01 fd
|
||||
f1 a0 fc f4 a8 01 fe af a0 fc b8 a8 02 fb c1 e0 fb 64 0b e8
|
||||
01 fa b3 07 e8 03 0b 18 02 ff fa 03 c9 ce 53 5d b2 02 ff ff
|
||||
ff dc 46 82 1d 44 06 e8 fe 1b da 06 e8 ff 08 67 07 e8 ff 05
|
||||
ee 07 e8 01 08 81 08 a8 fe 06 7e a8 03 06 42 a8 fe 07 12 a8
|
||||
01 04 cd a8 ff 04 d0 a8 fe 03 c4 a8 ff 01 d0 88 ff 21 a8 01
|
||||
fe fd a8 01 fd 0c a8 01 fb fd a8 02 fa d9 a8 01 fc fd a8 01
|
||||
fa e5 a0 fb fa a8 01 fb dc a8 01 f9 f1 e8 ff fa be 0a e0 fa
|
||||
41 09 e8 ff fa 3d 07 e8 ff f9 00 05 ff fa 03 c9 ce 53 5d a9
|
||||
00 ff ff ff 0b 1d ae 23 e4 03 e8 21 fc b6 04 e8 20 fb 3a 05
|
||||
c8 06 09 06 e8 01 ff 02 07 e8 02 ff d1 07 88 02 63 a0 fe 06
|
||||
88 03 e5 a8 04 fe d9 a8 04 ff 1e a8 06 fe f4 a8 04 ff e5 a8
|
||||
39 f2 06 88 09 36 a8 0b fc dc a8 c9 0a 03 88 02 f4 a8 03 03
|
||||
27 a8 05 f8 d3 a8 0a 05 12 a8 03 f9 c7 88 07 18 a8 06 07 f2
|
||||
a8 fe f8 f8 88 0b 1e a8 04 01 e0 a8 10 ff fd a8 fd fd 03 a8
|
||||
05 03 0c a8 09 fb f6 88 06 fc a8 01 02 02 a8 ff fa df a8 07
|
||||
03 09 a8 0a ff 09 88 02 df a8 f9 04 1b a8 04 ff f4 a8 01 08
|
||||
21 a8 fe f8 fd a8 fd 07 03 a8 05 ff fd a8 02 ff e8 a8 05 ff
|
||||
18 a8 fd 01 03 28 01 fd a8 02 03 15 a8 f9 04 f1 a8 ff fb fa
|
||||
a0 02 e2 a8 09 ff 15 a8 f9 01 09 88 fc 1b 88 01 e8 a8 fb 01
|
||||
30 28 09 04 a8 f8 f9 06 a8 07 01 f1 a8 fb 02 0c 28 fe fe a8
|
||||
ff 01 24 a0 fe f4 a8 f5 03 f4 a8 01 0a f7 a8 fb f5 d3 a0 ff
|
||||
09 a8 05 03 03 a8 fa 02 1b a8 fc fe ee a8 fd 02 eb a0 fe f6
|
||||
a8 f8 09 22 a8 ff f9 dc 28 fd fe a8 fe 04 f1 a8 03 ff 06 a8
|
||||
f3 03 fd a8 fa 03 f1 a8 f6 ff 03 a8 f9 03 ee a8 fb 02 e5 a8
|
||||
f4 05 dc a8 fa 06 03 a8 f1 fc d6 a8 f4 08 df a8 cd 03 c1 e8
|
||||
fb 01 d1 08 e8 fd 01 58 06 e8 67 0b 8b 02 fc ff ff ff ff ff
|
||||
ff
|
||||
'''
|
||||
b = [d.strip() for d in data.split(' ') if d not in ['', '\n']]
|
||||
b = [int(x, 16) for x in b]
|
||||
|
||||
p = Protocol(ProtocolVersion.INTUOS_PRO, None, None)
|
||||
p.parse_pen_data(b)
|
||||
|
||||
|
||||
# How does this work?
|
||||
# The test generater spits out a simple test function that just calls the
|
||||
# real test function
|
||||
#
|
||||
# Then we search for all yaml files with logs we have and generate a unique
|
||||
# test name (based on the timestamp) for that file. Result: we have
|
||||
# a unittests function for each log file found in the directory.
|
||||
def generator(logfile):
|
||||
def test(self):
|
||||
self._test_file(logfile)
|
||||
return test
|
||||
|
||||
|
||||
def search_for_tests():
|
||||
basedir = Path(xdg.BaseDirectory.xdg_data_home) / 'tuhi'
|
||||
for logfile in basedir.glob('**/raw/log-*.yaml'):
|
||||
with open(logfile) as fd:
|
||||
try:
|
||||
yml = yaml.load(fd, Loader=yaml.Loader)
|
||||
if not yml:
|
||||
continue
|
||||
timestamp = yml['time']
|
||||
test_name = f'test_log_{timestamp}'
|
||||
test = generator(logfile)
|
||||
setattr(TestLogFiles, test_name, test)
|
||||
except Exception as e:
|
||||
logger.error(f'Exception triggered by file {logfile}')
|
||||
raise e
|
||||
|
||||
|
||||
search_for_tests()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
960
tools/kete.py
|
@ -1,960 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from gi.repository import GObject, Gio, GLib
|
||||
import sys
|
||||
import argparse
|
||||
import binascii
|
||||
import cmd
|
||||
import errno
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import readline
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import xdg.BaseDirectory
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tuhi.svg import JsonSvg
|
||||
import tuhi.dbusclient
|
||||
except ModuleNotFoundError:
|
||||
# If PYTHONPATH isn't set up or we never installed Tuhi, the module
|
||||
# isn't available. And since we don't install kete, we can assume that
|
||||
# we're still in the git repo, so messing with the path is "fine".
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa
|
||||
from tuhi.svg import JsonSvg
|
||||
import tuhi.dbusclient
|
||||
|
||||
|
||||
CONFIG_PATH = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi-kete')
|
||||
|
||||
INI_TEMPLATE = '''
|
||||
# configuration file for kete
|
||||
|
||||
# the file follows a standard .ini format:
|
||||
# each device should have its own section like the following
|
||||
# [Bluetooth Address]
|
||||
# in each section, possible keys are:
|
||||
# - Orientation (possible values: Portrait, Landscape,
|
||||
# Reverse-Portrait, Reverse-Landscape
|
||||
# defaults to Landscape)
|
||||
# - HandlePressure (possible values: true, false
|
||||
# defaults to false)
|
||||
|
||||
|
||||
# Example:
|
||||
[11:22:33:44:55:66]
|
||||
Orientation = Reverse-Portrait
|
||||
HandlePressure = true
|
||||
'''
|
||||
|
||||
|
||||
class ColorFormatter(logging.Formatter):
|
||||
BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, LIGHT_GRAY = range(30, 38)
|
||||
DARK_GRAY, LIGHT_RED, LIGHT_GREEN, LIGHT_YELLOW, LIGHT_BLUE, LIGHT_MAGENTA, LIGHT_CYAN, WHITE = range(90, 98)
|
||||
COLORS = {
|
||||
'WARNING': LIGHT_RED,
|
||||
'INFO': LIGHT_GREEN,
|
||||
'DEBUG': LIGHT_GRAY,
|
||||
'CRITICAL': YELLOW,
|
||||
'ERROR': RED,
|
||||
}
|
||||
RESET_SEQ = '\033[0m'
|
||||
COLOR_SEQ = '\033[%dm'
|
||||
BOLD_SEQ = '\033[1m'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
logging.Formatter.__init__(self, *args, **kwargs)
|
||||
|
||||
def format(self, record):
|
||||
levelname = record.levelname
|
||||
color = self.COLOR_SEQ % (self.COLORS[levelname])
|
||||
message = logging.Formatter.format(self, record)
|
||||
message = message.replace('$RESET', self.RESET_SEQ)\
|
||||
.replace('$BOLD', self.BOLD_SEQ)\
|
||||
.replace('$COLOR', color)
|
||||
for k, v in self.COLORS.items():
|
||||
message = message.replace('$' + k, self.COLOR_SEQ % (v + 30))
|
||||
return message + self.RESET_SEQ
|
||||
|
||||
|
||||
log_format = '$COLOR%(levelname)s: %(message)s'
|
||||
logger_handler = logging.StreamHandler()
|
||||
logger_handler.setFormatter(ColorFormatter(log_format))
|
||||
logger = logging.getLogger('tuhi-kete')
|
||||
logger.addHandler(logger_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# remove ':' from the completer delimiters of readline so we can match on
|
||||
# device addresses
|
||||
completer_delims = readline.get_completer_delims()
|
||||
completer_delims = completer_delims.replace(':', '')
|
||||
readline.set_completer_delims(completer_delims)
|
||||
|
||||
|
||||
def b2hex(bs):
|
||||
'''Convert bytes() to a two-letter hex string in the form "1a 2b c3"'''
|
||||
hx = binascii.hexlify(bs).decode('ascii')
|
||||
return ' '.join([''.join(s) for s in zip(hx[::2], hx[1::2])])
|
||||
|
||||
|
||||
class TuhiKeteManager(tuhi.dbusclient.TuhiDBusClientManager):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.connect('unregistered_device', self._on_unregistered_device)
|
||||
|
||||
self.sigs = {}
|
||||
for d in self.devices:
|
||||
self.sigs[d] = []
|
||||
self._connect_device(d)
|
||||
|
||||
def _disconnect_device_signals(self, device):
|
||||
try:
|
||||
for s in self.sigs[device]:
|
||||
device.disconnect(s)
|
||||
self.sigs[device] = []
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _on_unregistered_device(self, manager, device):
|
||||
self._disconnect_device_signals(device)
|
||||
|
||||
def log_press_required(device):
|
||||
logger.info(f'{device}: Press button on device now')
|
||||
device.connect('button-press-required', log_press_required)
|
||||
|
||||
def log_registered(device):
|
||||
logger.info(f'{device}: Registration successful')
|
||||
device.connect('registered', log_registered)
|
||||
device.connect('registered', self._connect_device)
|
||||
|
||||
def _connect_device(self, device):
|
||||
self._disconnect_device_signals(device)
|
||||
|
||||
def log_sync_state(device, pspec):
|
||||
if device.sync_state:
|
||||
logger.debug(f'{device}: Communicating with device')
|
||||
else:
|
||||
logger.debug(f'{device}: Communication complete')
|
||||
device.connect('notify::sync-state', log_sync_state)
|
||||
|
||||
def log_device_error(d, err):
|
||||
if err == -errno.EACCES:
|
||||
logger.error(f'{device}: wrong device, please re-register.')
|
||||
elif err < 0:
|
||||
logger.error(f'{device}: an error occured: {os.strerror(-err)}')
|
||||
device.connect('device-error', log_device_error)
|
||||
|
||||
|
||||
class Worker(GObject.Object):
|
||||
'''Implements a command to be executed.
|
||||
Subclasses need to overwrite run() that will be executed
|
||||
while calling the command.
|
||||
Subclass can also implement the stop() method which
|
||||
will be executed to terminate the command, once the
|
||||
mainloop has finished.'''
|
||||
|
||||
def __init__(self, manager, args=None):
|
||||
GObject.GObject.__init__(self)
|
||||
self.manager = manager
|
||||
self._connected_signals = {}
|
||||
|
||||
def oject_connect(self, obj, signal, callback):
|
||||
if signal in self._connected_signals:
|
||||
# FIXME: this should be an exception
|
||||
logger.error(f'signal {signal} is already set, ignoring')
|
||||
return
|
||||
|
||||
s = obj.connect(signal, callback)
|
||||
self._connected_signals[signal] = (obj, s)
|
||||
|
||||
def manager_connect(self, signal, callback):
|
||||
self.oject_connect(self.manager, signal, callback)
|
||||
|
||||
def cleanup(self):
|
||||
for obj, signal in self._connected_signals.values():
|
||||
obj.disconnect(signal)
|
||||
self._connected_signals = {}
|
||||
|
||||
def run(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class Searcher(Worker):
|
||||
def __init__(self, manager, args):
|
||||
super(Searcher, self).__init__(manager)
|
||||
self.manager_connect('notify::searching', self._on_notify_search)
|
||||
self.manager_connect('unregistered-device', self._on_unregistered_device)
|
||||
|
||||
def run(self):
|
||||
if self.manager.searching:
|
||||
logger.error('Another client is already searching')
|
||||
return
|
||||
|
||||
logger.debug(f'Starting searching')
|
||||
self.manager.start_search()
|
||||
|
||||
def stop(self):
|
||||
if self.manager.searching:
|
||||
logger.debug('Stopping search')
|
||||
self.manager.stop_search()
|
||||
|
||||
self.cleanup()
|
||||
|
||||
def _on_notify_search(self, manager, pspec):
|
||||
if not manager.searching:
|
||||
logger.info('Search cancelled')
|
||||
self.stop()
|
||||
else:
|
||||
logger.info('Search started')
|
||||
|
||||
def _on_unregistered_device(self, manager, device):
|
||||
logger.info(f'Unregistered device: {device}')
|
||||
|
||||
|
||||
class Listener(Worker):
|
||||
def __init__(self, manager, args):
|
||||
super(Listener, self).__init__(manager)
|
||||
|
||||
self.device = None
|
||||
for d in manager.devices:
|
||||
if d.address == args.address:
|
||||
self.device = d
|
||||
break
|
||||
else:
|
||||
logger.error(f'{args.address}: device not found')
|
||||
# FIXME: this should be an exception
|
||||
return
|
||||
|
||||
def device_connect(self, signal, callback):
|
||||
self.oject_connect(self.device, signal, callback)
|
||||
|
||||
def run(self):
|
||||
if self.device is None:
|
||||
return
|
||||
|
||||
if self.device.drawings_available:
|
||||
self._log_drawings_available(self.device)
|
||||
|
||||
if self.device.listening:
|
||||
logger.info(f'{self.device}: device already listening')
|
||||
return
|
||||
|
||||
logger.debug(f'{self.device}: starting listening')
|
||||
self.device_connect('notify::listening', self._on_device_listening)
|
||||
self.device_connect('notify::drawings-available', self._on_drawings_available)
|
||||
self.device.start_listening()
|
||||
|
||||
def stop(self):
|
||||
if self.device.listening:
|
||||
logger.debug(f'{self.device}: stopping listening')
|
||||
self.device.stop_listening()
|
||||
|
||||
self.cleanup()
|
||||
|
||||
def _on_device_listening(self, device, pspec):
|
||||
if self.device.listening:
|
||||
return
|
||||
|
||||
logger.info(f'{device}: Listening stopped')
|
||||
self.stop()
|
||||
|
||||
def _on_drawings_available(self, device, pspec):
|
||||
self._log_drawings_available(device)
|
||||
|
||||
def _log_drawings_available(self, device):
|
||||
s = ', '.join([f'{t}' for t in device.drawings_available])
|
||||
logger.info(f'{device}: drawings available: {s}')
|
||||
|
||||
|
||||
class Fetcher(Worker):
|
||||
def __init__(self, manager, args, config):
|
||||
super(Fetcher, self).__init__(manager)
|
||||
self.device = None
|
||||
self.timestamps = None
|
||||
address = args.address
|
||||
index = args.index
|
||||
|
||||
if address not in config:
|
||||
config[address] = {}
|
||||
|
||||
self.orientation = config[address].get('Orientation', 'Landscape')
|
||||
|
||||
for d in manager.devices:
|
||||
if d.address == address:
|
||||
self.device = d
|
||||
break
|
||||
else:
|
||||
logger.error(f'{address}: device not found')
|
||||
return
|
||||
|
||||
if index != 'all':
|
||||
try:
|
||||
index = int(index)
|
||||
if index not in self.device.drawings_available:
|
||||
raise ValueError()
|
||||
self.timestamps = [index]
|
||||
except ValueError:
|
||||
logger.error(f'Invalid index {index}')
|
||||
return
|
||||
else:
|
||||
self.timestamps = self.device.drawings_available
|
||||
|
||||
def run(self):
|
||||
if self.device is None or self.timestamps is None:
|
||||
return
|
||||
|
||||
for ts in self.timestamps:
|
||||
jsondata = self.device.json(ts)
|
||||
data = json.loads(jsondata)
|
||||
t = time.localtime(data['timestamp'])
|
||||
t = time.strftime('%Y-%m-%d-%H-%M', t)
|
||||
path = f'{data["devicename"]}-{t}.svg'
|
||||
JsonSvg(data, self.orientation, filename=path)
|
||||
logger.info(f'{data["devicename"]}: saved file "{path}"')
|
||||
|
||||
|
||||
class LiveChanger(Worker):
|
||||
def __init__(self, manager, args):
|
||||
super(LiveChanger, self).__init__(manager)
|
||||
|
||||
self.device = None
|
||||
for d in manager.devices:
|
||||
if d.address == args.address:
|
||||
self.device = d
|
||||
break
|
||||
else:
|
||||
logger.error(f'{args.address}: device not found')
|
||||
# FIXME: this should be an exception
|
||||
return
|
||||
|
||||
def run(self):
|
||||
if self.device is None:
|
||||
return
|
||||
|
||||
read_fd, write_fd = os.pipe()
|
||||
|
||||
logger.info(f'{self.device}: starting live mode, please press button on device')
|
||||
self._cb = GLib.io_add_watch(read_fd, GLib.IO_IN, self._on_uhid_data)
|
||||
self.device.start_live(write_fd)
|
||||
|
||||
def _on_uhid_data(self, source, cb_condition):
|
||||
buf = os.read(source, 4380)
|
||||
|
||||
header = '< L'
|
||||
uhid_type = struct.unpack_from(header, buf)[0]
|
||||
|
||||
if uhid_type == 11: # UHID_CREATE2
|
||||
fmt = '< L 128s 64s 64s H H L L L L 4096s'
|
||||
uhid_type, name, phys, uniq, rdesc_size, bus, vid, pid, version, country, rdesc = struct.unpack_from(fmt, buf)
|
||||
name = name.rstrip(b'\x00')
|
||||
rdesc = rdesc[:rdesc_size]
|
||||
logger.info(f'Live mode started for device {name} with rdesc {b2hex(rdesc)}')
|
||||
elif uhid_type == 12: # UHID_INPUT2
|
||||
fmt = '< L H 4096s'
|
||||
uhid_type, data_len, data = struct.unpack_from(fmt, buf)
|
||||
data = data[:data_len]
|
||||
logger.info(f'Live data: {b2hex(data)}')
|
||||
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
logger.debug(f'{self.device}: stopping live mode')
|
||||
try:
|
||||
self.device.stop_live()
|
||||
except GLib.Error as e:
|
||||
if (e.domain != 'g-dbus-error-quark' or
|
||||
e.code != Gio.IOErrorEnum.EXISTS or
|
||||
Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'):
|
||||
raise e
|
||||
GLib.source_remove(self._cb)
|
||||
|
||||
|
||||
class TuhiKeteShellLogHandler(logging.StreamHandler):
|
||||
def __init__(self):
|
||||
super(TuhiKeteShellLogHandler, self).__init__(sys.stdout)
|
||||
self.setFormatter(ColorFormatter(log_format))
|
||||
self._prompt = ''
|
||||
|
||||
def emit(self, record):
|
||||
self.terminator = f'\n{self._prompt}{readline.get_line_buffer()}'
|
||||
super(TuhiKeteShellLogHandler, self).emit(record)
|
||||
|
||||
def set_normal_mode(self):
|
||||
self.acquire()
|
||||
self.setFormatter(ColorFormatter(log_format))
|
||||
self.terminator = '\n'
|
||||
self._prompt = ''
|
||||
self.release()
|
||||
|
||||
def set_prompt_mode(self, prompt):
|
||||
self.acquire()
|
||||
# '\x1b[2K\r' clears the current line and start again from the beginning
|
||||
self.setFormatter(ColorFormatter(f'\x1b[2K\r{log_format}'))
|
||||
self._prompt = prompt
|
||||
self.release()
|
||||
|
||||
|
||||
class TuhiKeteShell(cmd.Cmd):
|
||||
intro = 'Tuhi shell control'
|
||||
prompt = 'tuhi> '
|
||||
|
||||
def __init__(self, completekey='tab', stdin=None, stdout=None):
|
||||
super(TuhiKeteShell, self).__init__(completekey, stdin, stdout)
|
||||
self._manager = None
|
||||
self._workers = []
|
||||
self._log_handler = TuhiKeteShellLogHandler()
|
||||
logger.removeHandler(logger_handler)
|
||||
logger.addHandler(self._log_handler)
|
||||
self._log_handler.set_prompt_mode(self.prompt)
|
||||
|
||||
# patching get_names to hide some functions we do not want in the help
|
||||
self.get_names = self._filtered_get_names
|
||||
|
||||
CONFIG_PATH.mkdir(exist_ok=True)
|
||||
|
||||
self._config_file = Path(CONFIG_PATH, 'settings.ini')
|
||||
self._config = configparser.ConfigParser()
|
||||
if self._config_file.exists():
|
||||
self._config.read(self._config_file)
|
||||
else:
|
||||
# Populate config file with a configuration example
|
||||
with open(self._config_file, 'w') as f:
|
||||
f.write(INI_TEMPLATE)
|
||||
|
||||
self._history_file = Path(CONFIG_PATH, 'histfile')
|
||||
|
||||
try:
|
||||
readline.read_history_file(self._history_file)
|
||||
except FileNotFoundError:
|
||||
readline.write_history_file(self._history_file)
|
||||
|
||||
readline.set_history_length(100)
|
||||
|
||||
Gio.bus_watch_name(Gio.BusType.SESSION,
|
||||
tuhi.dbusclient.TUHI_DBUS_NAME,
|
||||
Gio.BusNameWatcherFlags.NONE,
|
||||
self._on_name_appeared,
|
||||
self._on_name_vanished)
|
||||
|
||||
def __enter__(self):
|
||||
# we can not call GLib.MainLoop() here or it will install a unix signal
|
||||
# handler for SIGINT, and we will not be able to catch
|
||||
# KeyboardInterrupt in cmdloop()
|
||||
self._mainloop = GLib.MainLoop.new(None, False)
|
||||
|
||||
self._glib_thread = threading.Thread(target=self._mainloop.run)
|
||||
self._glib_thread.daemon = True
|
||||
self._glib_thread.start()
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self._mainloop.quit()
|
||||
self._glib_thread.join()
|
||||
|
||||
def _filtered_get_names(self):
|
||||
names = super(TuhiKeteShell, self).get_names()
|
||||
names.remove('do_EOF')
|
||||
return names
|
||||
|
||||
def _on_name_appeared(self, connection, name, client):
|
||||
logger.info('Connected to the Tuhi daemon')
|
||||
self._manager = TuhiKeteManager()
|
||||
|
||||
def _on_name_vanished(self, connection, name):
|
||||
if self._manager is not None:
|
||||
logger.error('Tuhi daemon has quit')
|
||||
else:
|
||||
logger.warning('Tuhi daemon not running')
|
||||
self.terminate_workers()
|
||||
if self._manager is not None:
|
||||
self._manager.terminate()
|
||||
self._manager = None
|
||||
|
||||
def emptyline(self):
|
||||
# make sure we do not re-enter the last typed command
|
||||
pass
|
||||
|
||||
def do_EOF(self, arg):
|
||||
print('\n\r', end='') # to remove the appended weird char
|
||||
return self.do_exit(arg)
|
||||
|
||||
def do_exit(self, args):
|
||||
'''Leave the shell'''
|
||||
self.terminate_workers()
|
||||
return True
|
||||
|
||||
def precmd(self, line):
|
||||
# Restore the logger facility to something sane:
|
||||
self._log_handler.set_normal_mode()
|
||||
if self._manager is None and line not in ['EOF', 'exit', 'help']:
|
||||
print('Not connected to the Tuhi daemon')
|
||||
return ''
|
||||
|
||||
readline.write_history_file(self._history_file)
|
||||
return line
|
||||
|
||||
def postcmd(self, stop, line):
|
||||
# overwrite the logger facility to remove the current prompt and append
|
||||
# a new one
|
||||
self._log_handler.set_prompt_mode(self.prompt)
|
||||
|
||||
# restore any completion display hook we might have set
|
||||
readline.set_completion_display_matches_hook()
|
||||
return stop
|
||||
|
||||
def run(self, init=None):
|
||||
try:
|
||||
self.cmdloop(init)
|
||||
except KeyboardInterrupt:
|
||||
print('^C')
|
||||
self.run('')
|
||||
|
||||
def start_worker(self, worker_class, args=None):
|
||||
worker = worker_class(self._manager, args)
|
||||
worker.run()
|
||||
self._workers.append(worker)
|
||||
|
||||
def terminate_worker(self, worker):
|
||||
worker.stop()
|
||||
self._workers.remove(worker)
|
||||
|
||||
def terminate_workers(self):
|
||||
for worker in self._workers:
|
||||
worker.stop()
|
||||
self._workers = []
|
||||
|
||||
def do_devices(self, arg):
|
||||
'''List known devices. These are devices previously registered with
|
||||
the daemon.'''
|
||||
logger.debug('Listing available devices:')
|
||||
for d in self._manager.devices:
|
||||
print(d)
|
||||
|
||||
def help_listen(self):
|
||||
self.do_listen('-h')
|
||||
|
||||
def complete_listen(self, text, line, begidx, endidx):
|
||||
# mark the end of the line so we can match on the number of fields
|
||||
if line.endswith(' '):
|
||||
line += 'm'
|
||||
fields = line.split()
|
||||
|
||||
completion = []
|
||||
if len(fields) == 2:
|
||||
for device in self._manager.devices:
|
||||
if device.address.startswith(text.upper()):
|
||||
completion.append(device.address)
|
||||
elif len(fields) == 3:
|
||||
for v in ('on', 'off'):
|
||||
if v.startswith(text.lower()):
|
||||
completion.append(v)
|
||||
return completion
|
||||
|
||||
def do_listen(self, args):
|
||||
desc = '''Enable or disable listening on the given device. When
|
||||
listening, all drawings are downloaded from the device as they
|
||||
device allows connections (this usually requires a button press).
|
||||
Drawings are deleted from the device as they are downloaded, they
|
||||
are available with the 'fetch' command.
|
||||
'''
|
||||
parser = argparse.ArgumentParser(prog='listen',
|
||||
description=desc,
|
||||
add_help=False)
|
||||
parser.add_argument('-h', action='help', help=argparse.SUPPRESS)
|
||||
parser.add_argument('address', metavar='12:34:56:AB:CD:EF',
|
||||
type=tuhi.dbusclient.TuhiDBusClientDevice.is_device_address,
|
||||
default=None,
|
||||
help='the address of the device to listen to')
|
||||
parser.add_argument('mode', choices=['on', 'off'], nargs='?',
|
||||
const='on', default='on')
|
||||
try:
|
||||
parsed_args = parser.parse_args(args.split())
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
address = parsed_args.address
|
||||
mode = parsed_args.mode
|
||||
|
||||
for d in self._manager.devices:
|
||||
if d.address == address:
|
||||
if mode == 'on' and d.listening:
|
||||
print(f'Already listening on {address}')
|
||||
return
|
||||
elif mode == 'off' and not d.listening:
|
||||
print(f'Not listening on {address}')
|
||||
return
|
||||
break
|
||||
else:
|
||||
print(f'Device {address} not found')
|
||||
return
|
||||
|
||||
if mode == 'off':
|
||||
for worker in [w for w in self._workers if isinstance(w, Listener)]:
|
||||
if worker.device.address == address:
|
||||
self.terminate_worker(worker)
|
||||
break
|
||||
return
|
||||
|
||||
self.start_worker(Listener, parsed_args)
|
||||
|
||||
def help_fetch(self):
|
||||
self.do_fetch('-h')
|
||||
|
||||
def complete_fetch(self, text, line, begidx, endidx):
|
||||
|
||||
def draw_timestamp(substitution, matches, longest_match_length):
|
||||
print()
|
||||
|
||||
for drawing in matches:
|
||||
# we underline the current matching, because it makes easier to
|
||||
# visually go through the list
|
||||
display_drawing = f'\033[4m{drawing[:len(substitution)]}\033[0m{drawing[len(substitution):]}'
|
||||
|
||||
try:
|
||||
t = time.localtime(int(drawing))
|
||||
t = time.strftime('%Y-%m-%d at %H:%M', t)
|
||||
print(f'{display_drawing}: drawn on the {t}')
|
||||
except ValueError:
|
||||
# 'all' case
|
||||
print(f'{display_drawing}{":":<8} fetch all drawings')
|
||||
|
||||
print(self.prompt, readline.get_line_buffer(), sep='', end='')
|
||||
sys.stdout.flush()
|
||||
|
||||
# mark the end of the line so we can match on the number of fields
|
||||
if line.endswith(' '):
|
||||
line += 'm'
|
||||
fields = line.split()
|
||||
|
||||
completion = []
|
||||
if len(fields) == 2:
|
||||
for device in self._manager.devices:
|
||||
if device.address.startswith(text.upper()):
|
||||
completion.append(device.address)
|
||||
|
||||
elif len(fields) == 3:
|
||||
readline.set_completion_display_matches_hook(draw_timestamp)
|
||||
device = None
|
||||
for d in self._manager.devices:
|
||||
if d.address == fields[1]:
|
||||
device = d
|
||||
break
|
||||
|
||||
if device is None:
|
||||
return
|
||||
|
||||
timestamps = [str(t) for t in d.drawings_available]
|
||||
timestamps.append('all')
|
||||
|
||||
for t in timestamps:
|
||||
if t.startswith(text.lower()):
|
||||
completion.append(t)
|
||||
|
||||
return completion
|
||||
|
||||
def do_fetch(self, args):
|
||||
def is_index_or_all(string):
|
||||
try:
|
||||
n = int(string)
|
||||
except ValueError:
|
||||
if string == 'all':
|
||||
return string
|
||||
raise argparse.ArgumentTypeError(f'"{string}" is neither a timestamp nor "all"')
|
||||
else:
|
||||
return n
|
||||
|
||||
desc = '''
|
||||
Fetches one or all drawings from the given device. These drawings
|
||||
must have been previously downloaded from the device (see the
|
||||
'listen' command) and are saved in $PWD as SVG files.
|
||||
'''
|
||||
parser = argparse.ArgumentParser(prog='fetch',
|
||||
description=desc,
|
||||
add_help=False)
|
||||
parser.add_argument('-h', action='help', help=argparse.SUPPRESS)
|
||||
parser.add_argument('address', metavar='12:34:56:AB:CD:EF',
|
||||
type=tuhi.dbusclient.TuhiDBusClientDevice.is_device_address,
|
||||
default=None,
|
||||
help='the address of the device to fetch drawing from')
|
||||
parser.add_argument('index', metavar='{<index>|all}',
|
||||
type=is_index_or_all,
|
||||
const='all', nargs='?', default='all',
|
||||
help='the index of the drawing to fetch or a literal "all"')
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args.split())
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
# we do not call start_worker() as we don't need to retain the
|
||||
# worker
|
||||
worker = Fetcher(self._manager, parsed_args, self._config)
|
||||
worker.run()
|
||||
|
||||
def help_search(self):
|
||||
self.do_search('-h')
|
||||
|
||||
def complete_search(self, text, line, begidx, endidx):
|
||||
# mark the end of the line so we can match on the number of fields
|
||||
if line.endswith(' '):
|
||||
line += 'm'
|
||||
fields = line.split()
|
||||
|
||||
completion = []
|
||||
if len(fields) == 2:
|
||||
for v in ('on', 'off'):
|
||||
if v.startswith(text.lower()):
|
||||
completion.append(v)
|
||||
|
||||
return completion
|
||||
|
||||
def do_search(self, args):
|
||||
desc = '''
|
||||
Start/Stop listening for devices that can be registered with the
|
||||
daemon. The devices must be in registration mode (blue LED blinking).
|
||||
'''
|
||||
parser = argparse.ArgumentParser(prog='search',
|
||||
description=desc,
|
||||
add_help=False)
|
||||
parser.add_argument('-h', action='help', help=argparse.SUPPRESS)
|
||||
parser.add_argument('mode', choices=['on', 'off'], nargs='?',
|
||||
const='on', default='on')
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args.split())
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
current_searcher = None
|
||||
workers = [w for w in self._workers if isinstance(w, Searcher)]
|
||||
if len(workers) == 1:
|
||||
current_searcher = workers[0]
|
||||
|
||||
if current_searcher is None:
|
||||
if parsed_args.mode == 'on':
|
||||
self.start_worker(Searcher, parsed_args)
|
||||
else:
|
||||
if parsed_args.mode == 'off':
|
||||
self.terminate_worker(current_searcher)
|
||||
else:
|
||||
logger.info('Already searching')
|
||||
|
||||
def help_register(self):
|
||||
self.do_register('-h')
|
||||
|
||||
def complete_register(self, text, line, begidx, endidx):
|
||||
# mark the end of the line so we can match on the number of fields
|
||||
if line.endswith(' '):
|
||||
line += 'm'
|
||||
fields = line.split()
|
||||
|
||||
completion = []
|
||||
if len(fields) == 2:
|
||||
for device in self._manager.unregistered_devices + self._manager.devices:
|
||||
if device.address.startswith(text.upper()):
|
||||
completion.append(device.address)
|
||||
|
||||
return completion
|
||||
|
||||
def do_register(self, args):
|
||||
if not self._manager.searching and '-h' not in args.split():
|
||||
print('please call search first')
|
||||
return
|
||||
|
||||
desc = '''
|
||||
Register the given device. The device must be in registration mode
|
||||
(blue LED blinking).
|
||||
'''
|
||||
parser = argparse.ArgumentParser(prog='register',
|
||||
description=desc,
|
||||
add_help=False)
|
||||
parser.add_argument('-h', action='help', help=argparse.SUPPRESS)
|
||||
parser.add_argument('address', metavar='12:34:56:AB:CD:EF',
|
||||
type=tuhi.dbusclient.TuhiDBusClientDevice.is_device_address,
|
||||
default=None,
|
||||
help='the address of the device to register')
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args.split())
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
address = parsed_args.address
|
||||
|
||||
device = None
|
||||
|
||||
# make sure we do not keep a listener on the device
|
||||
for worker in [w for w in self._workers if isinstance(w, Listener)]:
|
||||
if worker.device.address == address:
|
||||
self.terminate_worker(worker)
|
||||
|
||||
for d in self._manager.devices + self._manager.unregistered_devices:
|
||||
if d.address == address:
|
||||
device = d
|
||||
break
|
||||
else:
|
||||
logger.error(f'{address}: device not found')
|
||||
return
|
||||
|
||||
device.register()
|
||||
|
||||
def help_info(self):
|
||||
self.do_info('-h')
|
||||
|
||||
def complete_info(self, text, line, begidx, endidx):
|
||||
# mark the end of the line so we can match on the number of fields
|
||||
if line.endswith(' '):
|
||||
line += 'm'
|
||||
fields = line.split()
|
||||
|
||||
completion = []
|
||||
if len(fields) == 2:
|
||||
for device in self._manager.devices:
|
||||
if device.address.startswith(text):
|
||||
completion.append(device.address)
|
||||
|
||||
return completion
|
||||
|
||||
def do_info(self, args):
|
||||
desc = '''
|
||||
Show information about the given device. If no device is given, show
|
||||
information about all known devices'''
|
||||
parser = argparse.ArgumentParser(prog='info',
|
||||
description=desc,
|
||||
add_help=False)
|
||||
parser.add_argument('-h', action='help', help=argparse.SUPPRESS)
|
||||
parser.add_argument('address', metavar='12:34:56:AB:CD:EF',
|
||||
type=tuhi.dbusclient.TuhiDBusClientDevice.is_device_address,
|
||||
default=None, nargs='?',
|
||||
help='the address of the device to listen to')
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args.split())
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
for device in self._manager.devices:
|
||||
if parsed_args.address is None or parsed_args.address == device.address:
|
||||
print(device)
|
||||
charge_strs = {
|
||||
0: 'unknown',
|
||||
1: 'charging',
|
||||
2: 'discharging'
|
||||
}
|
||||
try:
|
||||
charge_str = charge_strs[device.battery_state]
|
||||
except KeyError:
|
||||
charge_str = 'invalid'
|
||||
print(f'\tBattery level: {device.battery_percent}%, {charge_str}')
|
||||
print('\tAvailable drawings:')
|
||||
for d in device.drawings_available:
|
||||
t = time.localtime(d)
|
||||
t = time.strftime('%Y-%m-%d at %H:%M', t)
|
||||
print(f'\t\t* {d}: drawn on the {t}')
|
||||
|
||||
def complete_enable_live(self, text, line, begidx, endidx):
|
||||
# mark the end of the line so we can match on the number of fields
|
||||
if line.endswith(' '):
|
||||
line += 'm'
|
||||
fields = line.split()
|
||||
|
||||
completion = []
|
||||
if len(fields) == 2:
|
||||
for device in self._manager.devices:
|
||||
if device.address.startswith(text.upper()):
|
||||
completion.append(device.address)
|
||||
elif len(fields) == 3:
|
||||
for v in ('on', 'off'):
|
||||
if v.startswith(text.lower()):
|
||||
completion.append(v)
|
||||
return completion
|
||||
|
||||
def do_enable_live(self, args):
|
||||
desc = '''Enable or disable live mode on a particular device'''
|
||||
parser = argparse.ArgumentParser(prog='enable_live',
|
||||
description=desc,
|
||||
add_help=False)
|
||||
parser.add_argument('-h', action='help', help=argparse.SUPPRESS)
|
||||
parser.add_argument('address', metavar='12:34:56:AB:CD:EF',
|
||||
type=tuhi.dbusclient.TuhiDBusClientDevice.is_device_address,
|
||||
default=None, nargs='?',
|
||||
help='the address of the device to listen to')
|
||||
parser.add_argument('mode', choices=['on', 'off'], nargs='?',
|
||||
const='on', default='on')
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args.split())
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
address = parsed_args.address
|
||||
mode = parsed_args.mode
|
||||
|
||||
for d in self._manager.devices:
|
||||
if d.address == address:
|
||||
if mode == 'on' and d.live:
|
||||
print(f'Live mode already enabled on {address}')
|
||||
return
|
||||
elif mode == 'off' and not d.live:
|
||||
print(f'Live mode not started on {address}')
|
||||
return
|
||||
break
|
||||
else:
|
||||
print(f'Device {address} not found')
|
||||
return
|
||||
|
||||
if mode == 'off':
|
||||
for worker in [w for w in self._workers if isinstance(w, LiveChanger)]:
|
||||
if worker.device.address == address:
|
||||
self.terminate_worker(worker)
|
||||
break
|
||||
return
|
||||
|
||||
self.start_worker(LiveChanger, parsed_args)
|
||||
|
||||
|
||||
def parse(args):
|
||||
desc = 'Interactive commandline client to the Tuhi DBus daemon'
|
||||
parser = argparse.ArgumentParser(description=desc)
|
||||
parser.add_argument('-v', '--verbose',
|
||||
help='Show some debugging informations',
|
||||
action='store_true',
|
||||
default=False)
|
||||
|
||||
return parser.parse_args(args[1:])
|
||||
|
||||
|
||||
def main(args):
|
||||
args = parse(args)
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
try:
|
||||
with TuhiKeteShell() as shell:
|
||||
shell.run()
|
||||
|
||||
except tuhi.dbusclient.DBusError as e:
|
||||
logger.error(e.message)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv)
|
|
@ -1,168 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
# This Python 2 program allows to translate btsnoop capture files to
|
||||
# raw data coming from the various endpoints.
|
||||
#
|
||||
# You need to retrieve a btsnoop capture file from Android:
|
||||
# * Set up your device you want to snoop with your Android phone
|
||||
# * Install some Android file manager
|
||||
# * Enable developer mode on your Android device
|
||||
# * In Settings - General - Developer Options, enable "Bluetooth HCI snoop
|
||||
# log". This will log all bluetooth traffic to a file
|
||||
# `/Android/data/btsnoop_hci.log` (the location may differ, search for it)
|
||||
# * Use the app to produce some bluetooth data you want to capture
|
||||
# * disable bluetooth snooping
|
||||
# * Copy the `btsnoop_hci.log` file into `Downloads`, connect the Android
|
||||
# device to a computer and download the file. Or mail it to yourself. Or
|
||||
# whatever other way you find to get that file onto your computer.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import sys
|
||||
import binascii
|
||||
|
||||
# https://github.com/joekickass/python-btsnoop
|
||||
import btsnoop.btsnoop.btsnoop as btsnoop
|
||||
import btsnoop.bt.hci_uart as hci_uart
|
||||
import btsnoop.bt.hci_acl as hci_acl
|
||||
import btsnoop.bt.l2cap as l2cap
|
||||
import btsnoop.bt.att as att
|
||||
|
||||
NORDIC_UART_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
|
||||
NORDIC_UART_CHRC_TX_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'
|
||||
NORDIC_UART_CHRC_RX_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
|
||||
|
||||
WACOM_LIVE_SERVICE_UUID = '00001523-1212-efde-1523-785feabcd123'
|
||||
WACOM_CHRC_LIVE_PEN_DATA_UUID = '00001524-1212-efde-1523-785feabcd123'
|
||||
|
||||
WACOM_OFFLINE_SERVICE_UUID = 'ffee0001-bbaa-9988-7766-554433221100'
|
||||
WACOM_OFFLINE_FW_DATA_UUID = 'ffee0002-bbaa-9988-7766-554433221100'
|
||||
WACOM_OFFLINE_CHRC_PEN_DATA_UUID = 'ffee0003-bbaa-9988-7766-554433221100'
|
||||
|
||||
MYSTERIOUS_NOTIFICATION_SERVICE_UUID = '3a340720-c572-11e5-86c5-0002a5d5c51b'
|
||||
MYSTERIOUS_NOTIFICATION_CHRC_UUID = '3a340721-c572-11e5-86c5-0002a5d5c51b'
|
||||
|
||||
# http://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v7.x.x/doc/7.2.0/s110/html/a00071.html#ota_spec_sec
|
||||
NORDIC_DFU_SERVICE_UUID = '00001530-1212-efde-1523-785feabcd123'
|
||||
NORDIC_DFU_CTL_POINT_CHRC_UUID = '00001531-1212-efde-1523-785feabcd123'
|
||||
NORDIC_DFU_PACKET_CHRC_UUID = '00001532-1212-efde-1523-785feabcd123'
|
||||
NORDIC_DFU_UNKNONWN_CHRC_UUID = '00001534-1212-efde-1523-785feabcd123'
|
||||
|
||||
desc_uuids = {
|
||||
NORDIC_UART_SERVICE_UUID: 'NORDIC_UART_SERVICE_UUID',
|
||||
NORDIC_UART_CHRC_TX_UUID: 'Nordic UART TX -->',
|
||||
NORDIC_UART_CHRC_RX_UUID: 'Nordic UART RX <--',
|
||||
|
||||
NORDIC_DFU_SERVICE_UUID: 'NORDIC_DFU_SERVICE_UUID',
|
||||
NORDIC_DFU_CTL_POINT_CHRC_UUID: 'Nordic DFU Ctl Point',
|
||||
NORDIC_DFU_PACKET_CHRC_UUID: 'Nordic DFU packet',
|
||||
NORDIC_DFU_UNKNONWN_CHRC_UUID: 'Nordic DFU Unknown',
|
||||
|
||||
WACOM_LIVE_SERVICE_UUID: 'WACOM_LIVE_SERVICE_UUID',
|
||||
WACOM_CHRC_LIVE_PEN_DATA_UUID: 'Wacom Live <----',
|
||||
|
||||
WACOM_OFFLINE_SERVICE_UUID: 'WACOM_OFFLINE_SERVICE_UUID',
|
||||
WACOM_OFFLINE_FW_DATA_UUID: 'Sending FW Data --->',
|
||||
WACOM_OFFLINE_CHRC_PEN_DATA_UUID: 'Wacom RX <----',
|
||||
|
||||
MYSTERIOUS_NOTIFICATION_SERVICE_UUID: 'MYSTERIOUS_NOTIFICATION_SERVICE_UUID',
|
||||
MYSTERIOUS_NOTIFICATION_CHRC_UUID: 'Mysterious Notification',
|
||||
}
|
||||
|
||||
handles = {}
|
||||
|
||||
|
||||
def att_data_to_uuid(data):
|
||||
# reverse the string
|
||||
data = data[::-1]
|
||||
uuid = binascii.hexlify(data[:4]) + '-' + \
|
||||
binascii.hexlify(data[4:6]) + '-' + \
|
||||
binascii.hexlify(data[6:8]) + '-' + \
|
||||
binascii.hexlify(data[8:10]) + '-' + \
|
||||
binascii.hexlify(data[10:])
|
||||
return uuid
|
||||
|
||||
|
||||
def get_rows(records):
|
||||
|
||||
rows = []
|
||||
for record in records:
|
||||
|
||||
seq_nbr = record[0]
|
||||
# time = record[3].strftime("%b-%d %H:%M:%S.%f")
|
||||
|
||||
hci_pkt_type, hci_pkt_data = hci_uart.parse(record[4])
|
||||
# hci = hci_uart.type_to_str(hci_pkt_type)
|
||||
|
||||
if hci_pkt_type != hci_uart.ACL_DATA:
|
||||
continue
|
||||
|
||||
hci_data = hci_acl.parse(hci_pkt_data)
|
||||
l2cap_length, l2cap_cid, l2cap_data = l2cap.parse(hci_data[2], hci_data[4])
|
||||
|
||||
if l2cap_cid != l2cap.L2CAP_CID_ATT:
|
||||
continue
|
||||
|
||||
att_opcode, att_data = att.parse(l2cap_data)
|
||||
# cmd_evt_l2cap = att.opcode_to_str(att_opcode)
|
||||
data = att_data
|
||||
|
||||
if att_opcode == 0x11:
|
||||
length = ord(data[0])
|
||||
if length == 20:
|
||||
start = binascii.hexlify(data[1:3])
|
||||
end = binascii.hexlify(data[3:5])
|
||||
print('{:>6} service handle from {} to {}: {} '.format(seq_nbr, start, end, att_data_to_uuid(data[5:])))
|
||||
continue
|
||||
elif att_opcode == 0x09:
|
||||
length = ord(data[0])
|
||||
if length == 21:
|
||||
value_handle = binascii.hexlify(data[4:6])
|
||||
uuid = att_data_to_uuid(data[6:])
|
||||
desc_uuid = uuid
|
||||
try:
|
||||
desc_uuid = desc_uuids[uuid]
|
||||
except KeyError:
|
||||
pass
|
||||
print('{:>6} chrc at handle {}: {}'.format(seq_nbr, value_handle, uuid))
|
||||
handles[value_handle] = (uuid, desc_uuid)
|
||||
continue
|
||||
|
||||
if att_opcode not in [0x52, 0x1b]:
|
||||
continue
|
||||
|
||||
data = binascii.hexlify(data)
|
||||
|
||||
handle = data[:4]
|
||||
if handle not in handles:
|
||||
continue
|
||||
|
||||
rows.append(['{:>6}'.format(seq_nbr), handles[handle][1], data[4:]])
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def main(filename):
|
||||
records = btsnoop.parse(filename)
|
||||
rows = get_rows(records)
|
||||
|
||||
for r in rows:
|
||||
print(' '.join(r))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 2:
|
||||
main(sys.argv[1])
|
||||
else:
|
||||
sys.exit(-1)
|
|
@ -1,169 +0,0 @@
|
|||
#!/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# 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 2 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/>.
|
||||
#
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import json
|
||||
import logging
|
||||
|
||||
# This tool isn't installed, so we can assume that the tuhi module is always
|
||||
# in the parent directory
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa
|
||||
from tuhi.util import flatten
|
||||
from tuhi.drawing import Drawing
|
||||
from tuhi.protocol import StrokeFile
|
||||
from tuhi.svg import JsonSvg
|
||||
from tuhi.wacom import WacomProtocolSpark, WacomProtocolIntuosPro, WacomProtocolSlate
|
||||
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(name)s: %(message)s',
|
||||
level=logging.INFO,
|
||||
datefmt='%H:%M:%S')
|
||||
logger = logging.getLogger('tuhi') # set the pseudo-root logger to take advantage of the other loggers
|
||||
|
||||
|
||||
def parse_file(filename, file_format, tablet_model, orientation):
|
||||
width = tablet_model.width
|
||||
height = tablet_model.height
|
||||
pressure = tablet_model.pressure
|
||||
point_size = tablet_model.point_size
|
||||
orientation = orientation or tablet_model.orientation
|
||||
|
||||
stem = Path(filename).stem
|
||||
with open(filename) as fd:
|
||||
yml = yaml.load(fd, Loader=yaml.Loader)
|
||||
if not yml:
|
||||
print(f'{filename}: empty file.')
|
||||
return
|
||||
|
||||
# all recv lists that have source PEN
|
||||
pendata = [d['recv'] for d in yml['data'] if 'recv' in d and 'source' in d and d['source'] == 'PEN']
|
||||
data = list(flatten(pendata))
|
||||
if not data:
|
||||
print(f'{filename}: no pen data.')
|
||||
return
|
||||
|
||||
f = StrokeFile(data)
|
||||
# Spark doesn't have timestamps in the strokes, so use the file
|
||||
# timestamp itself
|
||||
timestamp = f.timestamp or yml['time']
|
||||
# gotta convert to Drawings, then to json string, then to json, then
|
||||
# to svg. ffs.
|
||||
svgname = f'{stem}.svg'
|
||||
jsonname = f'{stem}.json'
|
||||
d = Drawing(svgname, (width * point_size, height * point_size), timestamp)
|
||||
|
||||
def normalize(p):
|
||||
NORMALIZED_RANGE = 0x10000
|
||||
return NORMALIZED_RANGE * p / pressure
|
||||
|
||||
for s in f.strokes:
|
||||
stroke = d.new_stroke()
|
||||
for p in s.points:
|
||||
stroke.new_abs((p.x * point_size, p.y * point_size), normalize(p.p))
|
||||
stroke.seal()
|
||||
d.seal()
|
||||
if file_format == 'json':
|
||||
with open(jsonname, 'w') as fd:
|
||||
fd.write(d.to_json())
|
||||
return
|
||||
|
||||
from io import StringIO
|
||||
js = json.load(StringIO(d.to_json()))
|
||||
JsonSvg(js, orientation, d.name)
|
||||
|
||||
|
||||
def fetch_files():
|
||||
import xdg.BaseDirectory
|
||||
basedir = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi')
|
||||
|
||||
return [f for f in basedir.rglob('raw/*.yaml')]
|
||||
|
||||
|
||||
def main(args=sys.argv):
|
||||
long_description = '''
|
||||
This tool is primarily a debugging tool but can be used to recover
|
||||
"lost" files. Use this tool if Tuhi failed to convert a drawing
|
||||
after downloading it from the device. Obviously after fixing the bug
|
||||
that failed to convert it.
|
||||
|
||||
Input data is a raw log file. These are usually stored in
|
||||
\t$XDG_DATA_HOME/tuhi/<bluetooth address>/raw/
|
||||
|
||||
Pass the log file to this tool and it will convert it to a JSON file or
|
||||
an SVG file. Alternatively, use --all to convert all
|
||||
all log files containing pen data in the above directory.
|
||||
|
||||
Files are placed in $CWD and use file names containing the file time
|
||||
for easier identification.
|
||||
|
||||
Copying the JSON files into the $XDG_DATA_HOME/tuhi/ will make them
|
||||
appear in the GUI.
|
||||
'''.replace(' ', '')
|
||||
|
||||
parser = argparse.ArgumentParser(description='Converter tool from raw Tuhi log files to SVG and Tuhi JSON files.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=long_description)
|
||||
parser.add_argument('filename', help='The YAML file to load', nargs='?')
|
||||
parser.add_argument('--verbose',
|
||||
help='Show some debugging informations',
|
||||
action='store_true',
|
||||
default=False)
|
||||
parser.add_argument('--all',
|
||||
help='Convert all files in $XDG_DATA_DIR/tuhi/',
|
||||
action='store_true',
|
||||
default=False)
|
||||
parser.add_argument('--orientation',
|
||||
help='The orientation of the tablet. Default: the tablet model\'s default',
|
||||
default=None,
|
||||
choices=['landscape', 'portrait', 'reverse-landscape', 'reverse-portrait'])
|
||||
parser.add_argument('--tablet-model',
|
||||
help='Use defaults from the given tablet model',
|
||||
default='intuos-pro',
|
||||
choices=['intuos-pro', 'slate', 'spark'])
|
||||
parser.add_argument('--format',
|
||||
help='The format to generate. Default: svg',
|
||||
default='svg',
|
||||
choices=['svg', 'json'])
|
||||
|
||||
ns = parser.parse_args(args[1:])
|
||||
if ns.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
if not ns.all:
|
||||
if ns.filename is None:
|
||||
print('filename is required, or use --all', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
files = [ns.filename]
|
||||
else:
|
||||
files = fetch_files()
|
||||
|
||||
model_map = {
|
||||
'intuos-pro': WacomProtocolIntuosPro,
|
||||
'slate': WacomProtocolSlate,
|
||||
'spark': WacomProtocolSpark,
|
||||
}
|
||||
for f in files:
|
||||
parse_file(f, ns.format, model_map[ns.tablet_model], ns.orientation)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,32 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import subprocess
|
||||
from multiprocessing import Process
|
||||
|
||||
|
||||
def start_tuhi():
|
||||
subprocess.run('tuhi')
|
||||
|
||||
|
||||
def start_tuhigui():
|
||||
subprocess.run('tuhi-gui')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
tuhi = Process(target=start_tuhi)
|
||||
tuhi.daemon = True
|
||||
tuhi.start()
|
||||
tuhigui = Process(target=start_tuhigui)
|
||||
tuhigui.start()
|
||||
tuhigui.join()
|
|
@ -1,76 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import sys
|
||||
import multiprocessing
|
||||
|
||||
|
||||
def maybe_start_tuhi(queue):
|
||||
should_start = queue.get()
|
||||
|
||||
if not should_start:
|
||||
return
|
||||
|
||||
import tuhi.base
|
||||
tuhi.base.main(['tuhi'])
|
||||
|
||||
|
||||
def main(args=sys.argv):
|
||||
|
||||
queue = multiprocessing.Queue()
|
||||
|
||||
tuhi_process = multiprocessing.Process(target=maybe_start_tuhi, args=(queue,))
|
||||
tuhi_process.daemon = True
|
||||
tuhi_process.start()
|
||||
|
||||
# import after spawning the process, or the 2 processes will fight for GLib
|
||||
import kete
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
# connect to the session
|
||||
try:
|
||||
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise kete.DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
# attempt to connect to tuhi
|
||||
try:
|
||||
proxy = Gio.DBusProxy.new_sync(connection,
|
||||
Gio.DBusProxyFlags.NONE, None,
|
||||
kete.TUHI_DBUS_NAME,
|
||||
kete.ROOT_PATH,
|
||||
kete.ORG_FREEDESKTOP_TUHI1_MANAGER,
|
||||
None)
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise kete.DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
started = proxy.get_name_owner() is not None
|
||||
|
||||
if not started:
|
||||
print(f'No-one is handling {kete.TUHI_DBUS_NAME}, attempting to start a daemon')
|
||||
|
||||
queue.put(not started)
|
||||
|
||||
kete.main(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv)
|
|
@ -1,235 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import sys
|
||||
import multiprocessing
|
||||
from multiprocessing import reduction
|
||||
|
||||
try:
|
||||
import tuhi.dbusclient
|
||||
except ModuleNotFoundError:
|
||||
# If PYTHONPATH isn't set up or we never installed Tuhi, the module
|
||||
# isn't available. And since we don't install tuhi-live, we can assume that
|
||||
# we're still in the git repo, so messing with the path is "fine".
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa
|
||||
import tuhi.dbusclient
|
||||
|
||||
|
||||
manager = None
|
||||
logger = None
|
||||
|
||||
|
||||
def open_uhid_process(queue_in, conn_out):
|
||||
while True:
|
||||
try:
|
||||
pid = queue_in.get()
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
else:
|
||||
fd = os.open('/dev/uhid', os.O_RDWR)
|
||||
reduction.send_handle(conn_out, fd, pid)
|
||||
|
||||
|
||||
def maybe_start_tuhi(queue):
|
||||
try:
|
||||
should_start, args = queue.get()
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
if not should_start:
|
||||
return
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
import tuhi.base
|
||||
import signal
|
||||
|
||||
# we don't want to kill Tuhi on ctrl+c because we won't be able to reset
|
||||
# live mode. Instead we rely on tuhi-live to take us down when it exits
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
args = ['tuhi-live'] + args # argparse in tuhi.base.main skips argv[0]
|
||||
|
||||
tuhi.base.main(args)
|
||||
|
||||
|
||||
def start_tuhi_server(args):
|
||||
queue = multiprocessing.Queue()
|
||||
|
||||
tuhi_process = multiprocessing.Process(target=maybe_start_tuhi, args=(queue,))
|
||||
tuhi_process.daemon = True
|
||||
tuhi_process.start()
|
||||
|
||||
sys.path.append(os.path.join(os.getcwd(), 'tools'))
|
||||
|
||||
# import after spawning the process, or the 2 processes will fight for GLib
|
||||
import kete
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
global logger
|
||||
logger = logging.getLogger('tuhi-live')
|
||||
logger.addHandler(kete.logger_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
logger.debug('connecting to the bus')
|
||||
|
||||
# connect to the session
|
||||
try:
|
||||
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise tuhi.dbusclient.DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
logger.debug('looking for tuhi on the bus')
|
||||
# attempt to connect to tuhi
|
||||
try:
|
||||
proxy = Gio.DBusProxy.new_sync(connection,
|
||||
Gio.DBusProxyFlags.NONE, None,
|
||||
tuhi.dbusclient.TUHI_DBUS_NAME,
|
||||
tuhi.dbusclient.ROOT_PATH,
|
||||
tuhi.dbusclient.ORG_FREEDESKTOP_TUHI1_MANAGER,
|
||||
None)
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise tuhi.dbusclient.DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
started = proxy.get_name_owner() is not None
|
||||
|
||||
if not started:
|
||||
print(f'No-one is handling {tuhi.dbusclient.TUHI_DBUS_NAME}, attempting to start a daemon')
|
||||
|
||||
queue.put((not started, args))
|
||||
|
||||
|
||||
def run_live(request_fd_queue, conn_fd):
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
def on_name_appeared(connection, name, client):
|
||||
global manager
|
||||
logger.info('Connected to the Tuhi daemon')
|
||||
manager = tuhi.dbusclient.TuhiDBusClientManager()
|
||||
|
||||
for device in manager.devices:
|
||||
if device.live:
|
||||
logger.info(f'{device} is already live, stopping first')
|
||||
device.stop_live()
|
||||
logger.info(f'starting live on {device}, please press button on the device')
|
||||
request_fd_queue.put(os.getpid())
|
||||
fd = reduction.recv_handle(conn_fd)
|
||||
device.start_live(fd)
|
||||
|
||||
Gio.bus_watch_name(Gio.BusType.SESSION,
|
||||
tuhi.dbusclient.TUHI_DBUS_NAME,
|
||||
Gio.BusNameWatcherFlags.NONE,
|
||||
on_name_appeared,
|
||||
None)
|
||||
|
||||
mainloop = GLib.MainLoop()
|
||||
|
||||
def on_disconnect(dev, pspec):
|
||||
mainloop.quit()
|
||||
|
||||
wait_for_disconnect = False
|
||||
|
||||
try:
|
||||
mainloop.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for device in manager.devices:
|
||||
if device.live and device.connected:
|
||||
logger.info(f'stopping live on {device}')
|
||||
device.connect('notify::connected', on_disconnect)
|
||||
device.stop_live()
|
||||
wait_for_disconnect = True
|
||||
|
||||
# we re-run the mainloop to terminate the connections
|
||||
if wait_for_disconnect:
|
||||
try:
|
||||
mainloop.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def drop_privileges():
|
||||
sys.stderr.write('dropping privileges\n')
|
||||
|
||||
os.setgroups([])
|
||||
gid = int(os.getenv('SUDO_GID'))
|
||||
uid = int(os.getenv('SUDO_UID'))
|
||||
pwname = os.getenv('SUDO_USER')
|
||||
os.setresgid(gid, gid, gid)
|
||||
os.initgroups(pwname, gid)
|
||||
os.setresuid(uid, uid, uid)
|
||||
|
||||
pw = pwd.getpwuid(uid)
|
||||
|
||||
# we completely clear the environment and start a new and controlled one
|
||||
os.environ.clear()
|
||||
os.environ['XDG_RUNTIME_DIR'] = f'/run/user/{uid}'
|
||||
os.environ['HOME'] = pw.pw_dir
|
||||
|
||||
|
||||
def parse(args):
|
||||
parser = argparse.ArgumentParser(description='Tool to start live mode')
|
||||
parser.add_argument('--flatpak-compatibility-mode',
|
||||
help='Use the flatpak xdg directories',
|
||||
action='store_true',
|
||||
default=False)
|
||||
|
||||
ns, remaining_args = parser.parse_known_args(args[1:])
|
||||
return ns, remaining_args
|
||||
|
||||
|
||||
def main(args=sys.argv):
|
||||
if not os.geteuid() == 0:
|
||||
sys.exit('Script must be run as root')
|
||||
|
||||
our_args, remaining_args = parse(args)
|
||||
request_fd_queue = multiprocessing.Queue()
|
||||
conn_in, conn_out = multiprocessing.Pipe()
|
||||
|
||||
fd_process = multiprocessing.Process(target=open_uhid_process, args=(request_fd_queue, conn_out))
|
||||
fd_process.daemon = True
|
||||
fd_process.start()
|
||||
|
||||
drop_privileges()
|
||||
|
||||
if our_args.flatpak_compatibility_mode:
|
||||
from pathlib import Path
|
||||
|
||||
# tuhi-live is usually started through sudo, so let's get to the
|
||||
# user's home directory here.
|
||||
userhome = Path(os.path.expanduser('~' + os.getlogin()))
|
||||
basedir = userhome / '.var' / 'app' / 'org.freedesktop.Tuhi'
|
||||
print(f'Using flatpak xdg dirs in {basedir}')
|
||||
os.environ['XDG_DATA_HOME'] = os.fspath(basedir / 'data')
|
||||
os.environ['XDG_CONFIG_HOME'] = os.fspath(basedir / 'config')
|
||||
os.environ['XDG_CACHE_HOME'] = os.fspath(basedir / 'cache')
|
||||
|
||||
start_tuhi_server(remaining_args)
|
||||
run_live(request_fd_queue, conn_in)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv)
|
32
tuhi-gui.in
|
@ -1,32 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import gi
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
# 3.30 is the first one with Gtk.Template
|
||||
gi.check_version('3.30') # NOQA
|
||||
except ValueError as e:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
gi.require_version('Gio', '2.0') # NOQA
|
||||
from gi.repository import Gio
|
||||
|
||||
|
||||
@devel@ # NOQA
|
||||
resource = Gio.resource_load(os.fspath(Path('@pkgdatadir@', 'tuhi.gresource')))
|
||||
Gio.Resource._register(resource)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import gettext
|
||||
import locale
|
||||
|
||||
locale.bindtextdomain('tuhi', '@localedir@')
|
||||
gettext.bindtextdomain('tuhi', '@localedir@')
|
||||
|
||||
from tuhi.gui.application import main
|
||||
main(sys.argv)
|
|
@ -1,18 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import tuhi.base
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
tuhi.base.main(sys.argv + ['--verbose'])
|
49
tuhi.in
|
@ -1,49 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
|
||||
tuhi_server = Path('@libexecdir@', 'tuhi-server')
|
||||
tuhi_gui = Path('@libexecdir@', 'tuhi-gui')
|
||||
|
||||
|
||||
@devel@ # NOQA
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.version_info < (3, 6):
|
||||
sys.exit('Python 3.6 or later required')
|
||||
|
||||
parser = argparse.ArgumentParser(description='Tuhi')
|
||||
parser.add_argument('--flatpak-compatibility-mode',
|
||||
help='Use the flatpak xdg directories',
|
||||
action='store_true',
|
||||
default=False)
|
||||
ns, remainder = parser.parse_known_args()
|
||||
if ns.flatpak_compatibility_mode:
|
||||
import os
|
||||
|
||||
basedir = Path.home() / '.var' / 'app' / 'org.freedesktop.Tuhi'
|
||||
print(f'Using flatpak xdg dirs in {basedir}')
|
||||
os.environ['XDG_DATA_HOME'] = os.fspath(basedir / 'data')
|
||||
os.environ['XDG_CONFIG_HOME'] = os.fspath(basedir / 'config')
|
||||
os.environ['XDG_CACHE_HOME'] = os.fspath(basedir / 'cache')
|
||||
|
||||
tuhi = subprocess.Popen([tuhi_server] + remainder)
|
||||
try:
|
||||
subprocess.run([tuhi_gui] + remainder)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
tuhi.terminate()
|
477
tuhi/base.py
|
@ -1,477 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import argparse
|
||||
import enum
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import xdg.BaseDirectory
|
||||
from pathlib import Path
|
||||
try:
|
||||
from gi.repository import GObject, GLib
|
||||
except Exception as e:
|
||||
print(f'************ Importing gi.repository failed **********')
|
||||
print(f'* This is an issue with the gi module, not with tuhi *')
|
||||
print(f'******************************************************')
|
||||
print(f'The full exception is below:')
|
||||
print(f'')
|
||||
raise e
|
||||
|
||||
from tuhi.dbusserver import TuhiDBusServer
|
||||
from tuhi.ble import BlueZDeviceManager
|
||||
from tuhi.wacom import WacomDevice, DeviceMode
|
||||
from tuhi.config import TuhiConfig
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi')
|
||||
|
||||
logger = logging.getLogger('tuhi')
|
||||
|
||||
WACOM_COMPANY_IDS = [0x4755, 0x4157]
|
||||
|
||||
|
||||
class TuhiDevice(GObject.Object):
|
||||
'''
|
||||
Glue object to combine the backend bluez DBus object (that talks to the
|
||||
real device) with the frontend DBusServer object that exports the device
|
||||
over Tuhi's DBus interface
|
||||
'''
|
||||
|
||||
class BatteryState(enum.Enum):
|
||||
UNKNOWN = 0
|
||||
CHARGING = 1
|
||||
DISCHARGING = 2
|
||||
|
||||
__gsignals__ = {
|
||||
# Signal sent when an error occurs on the device itself.
|
||||
# Argument is a Wacom*Exception
|
||||
'device-error':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
}
|
||||
|
||||
BATTERY_UPDATE_MIN_INTERVAL = 300
|
||||
|
||||
def __init__(self, bluez_device, config, uuid=None, mode=DeviceMode.LISTEN):
|
||||
GObject.Object.__init__(self)
|
||||
self.config = config
|
||||
self._wacom_device = None
|
||||
# We need either uuid or registered as false
|
||||
assert uuid is not None or mode == DeviceMode.REGISTER
|
||||
self._mode = mode
|
||||
self._battery_state = TuhiDevice.BatteryState.UNKNOWN
|
||||
self._battery_percent = 0
|
||||
self._last_battery_update_time = 0
|
||||
self._battery_timer_source = None
|
||||
self._signals = {'connected': None,
|
||||
'disconnected': None}
|
||||
|
||||
self._bluez_device = bluez_device
|
||||
|
||||
self._tuhi_dbus_device = None
|
||||
|
||||
@GObject.Property
|
||||
def dimensions(self):
|
||||
if self._wacom_device is None:
|
||||
return 0, 0
|
||||
return self._wacom_device.dimensions
|
||||
|
||||
@GObject.Property
|
||||
def mode(self):
|
||||
return self._mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, mode):
|
||||
if self._mode != mode:
|
||||
self._mode = mode
|
||||
self.notify('registered')
|
||||
|
||||
@GObject.Property
|
||||
def registered(self):
|
||||
return self.mode == DeviceMode.LISTEN
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return self._bluez_device.name
|
||||
|
||||
@GObject.Property
|
||||
def address(self):
|
||||
return self._bluez_device.address
|
||||
|
||||
@GObject.Property
|
||||
def bluez_device(self):
|
||||
return self._bluez_device
|
||||
|
||||
@GObject.Property
|
||||
def dbus_device(self):
|
||||
return self._tuhi_dbus_device
|
||||
|
||||
@dbus_device.setter
|
||||
def dbus_device(self, device):
|
||||
assert self._tuhi_dbus_device is None
|
||||
self._tuhi_dbus_device = device
|
||||
self._tuhi_dbus_device.connect('register-requested', self._on_register_requested)
|
||||
self._tuhi_dbus_device.connect('notify::listening', self._on_listening_updated)
|
||||
self._tuhi_dbus_device.connect('notify::live', self._on_live_updated)
|
||||
|
||||
drawings = self.config.load_drawings(self.address)
|
||||
if drawings:
|
||||
logger.debug(f'{self.address}: loaded {len(drawings)} drawings from disk')
|
||||
for d in drawings:
|
||||
self._tuhi_dbus_device.add_drawing(d)
|
||||
|
||||
@GObject.Property
|
||||
def listening(self):
|
||||
return self._tuhi_dbus_device.listening
|
||||
|
||||
@GObject.Property
|
||||
def live(self):
|
||||
return self._tuhi_dbus_device.live
|
||||
|
||||
@GObject.Property
|
||||
def battery_percent(self):
|
||||
return self._battery_percent
|
||||
|
||||
@battery_percent.setter
|
||||
def battery_percent(self, value):
|
||||
self._battery_percent = value
|
||||
|
||||
@GObject.Property
|
||||
def battery_state(self):
|
||||
return self._battery_state
|
||||
|
||||
@battery_state.setter
|
||||
def battery_state(self, value):
|
||||
self._battery_state = value
|
||||
|
||||
@GObject.Property
|
||||
def sync_state(self):
|
||||
return self._sync_state
|
||||
|
||||
def _connect_device(self, mode):
|
||||
if self._signals['connected'] is None:
|
||||
self._signals['connected'] = self._bluez_device.connect('connected', self._on_bluez_device_connected, mode)
|
||||
if self._signals['disconnected'] is None:
|
||||
self._signals['disconnected'] = self._bluez_device.connect('disconnected', self._on_bluez_device_disconnected)
|
||||
self._bluez_device.connect_device()
|
||||
|
||||
def register(self):
|
||||
self._connect_device(DeviceMode.REGISTER)
|
||||
|
||||
def listen(self):
|
||||
self._connect_device(DeviceMode.LISTEN)
|
||||
|
||||
def _on_bluez_device_connected(self, bluez_device, mode):
|
||||
logger.debug(f'{bluez_device.address}: connected for {mode}')
|
||||
if self._wacom_device is None:
|
||||
self._wacom_device = WacomDevice(bluez_device, self.config)
|
||||
self._wacom_device.connect('drawing', self._on_drawing_received)
|
||||
self._wacom_device.connect('done', self._on_fetching_finished, bluez_device)
|
||||
self._wacom_device.connect('button-press-required', self._on_button_press_required)
|
||||
self._wacom_device.connect('notify::uuid', self._on_uuid_updated, bluez_device)
|
||||
self._wacom_device.connect('battery-status', self._on_battery_status, bluez_device)
|
||||
self._wacom_device.connect('notify::sync-state', self._on_sync_state)
|
||||
self._wacom_device.connect('notify::dimensions', self._on_dimensions)
|
||||
|
||||
if mode == DeviceMode.REGISTER:
|
||||
self._wacom_device.start_register()
|
||||
elif mode == DeviceMode.LIVE:
|
||||
self._wacom_device.start_live(self._tuhi_dbus_device.uhid_fd)
|
||||
else:
|
||||
self._wacom_device.start_listen()
|
||||
|
||||
try:
|
||||
bluez_device.disconnect(self._signals['connected'])
|
||||
self._signals['connected'] = None
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _on_dimensions(self, device, pspec):
|
||||
self.notify('dimensions')
|
||||
|
||||
def _on_sync_state(self, device, pspec):
|
||||
self._sync_state = device.sync_state
|
||||
self.notify('sync-state')
|
||||
|
||||
def _on_bluez_device_disconnected(self, bluez_device):
|
||||
logger.debug(f'{bluez_device.address}: disconnected')
|
||||
try:
|
||||
bluez_device.disconnect(self._signals['disconnected'])
|
||||
self._signals['disconnected'] = None
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _on_register_requested(self, dbus_device):
|
||||
# FIXME: this needs to throw an exception/return the value
|
||||
if self.mode == DeviceMode.LISTEN:
|
||||
return
|
||||
|
||||
self.register()
|
||||
|
||||
def _on_drawing_received(self, device, drawing):
|
||||
logger.debug('Drawing received')
|
||||
self._tuhi_dbus_device.add_drawing(drawing)
|
||||
self.config.store_drawing(self.address, drawing)
|
||||
|
||||
def _on_fetching_finished(self, device, exception, bluez_device):
|
||||
if self.live:
|
||||
return
|
||||
bluez_device.disconnect_device()
|
||||
if exception is not None:
|
||||
logger.info(exception)
|
||||
self.emit('device-error', exception)
|
||||
|
||||
def _on_button_press_required(self, device):
|
||||
self._tuhi_dbus_device.notify_button_press_required()
|
||||
|
||||
def _on_uuid_updated(self, wacom_device, pspec, bluez_device):
|
||||
self.config.new_device(bluez_device.address, wacom_device.uuid, wacom_device.protocol)
|
||||
# FIXME: we have registered and that *should* set us to listen. But
|
||||
# the ManufacturerData doesn't update until (some time into) the
|
||||
# next connection request.
|
||||
self.mode = DeviceMode.LISTEN
|
||||
|
||||
def _on_listening_updated(self, dbus_device, pspec):
|
||||
# Callback when a DBus client calls Start/Stop listening
|
||||
self.notify('listening')
|
||||
|
||||
def _on_live_updated(self, dbus_device, pspec):
|
||||
if self.live:
|
||||
self._connect_device(DeviceMode.LIVE)
|
||||
else:
|
||||
if self._wacom_device is not None:
|
||||
self._wacom_device.stop_live()
|
||||
|
||||
def _on_battery_status(self, wacom_device, percent, is_charging, bluez_device):
|
||||
if is_charging:
|
||||
self.battery_state = TuhiDevice.BatteryState.CHARGING
|
||||
else:
|
||||
self.battery_state = TuhiDevice.BatteryState.DISCHARGING
|
||||
self.battery_percent = percent
|
||||
|
||||
# If we don't get battery updates for a while, switch the state
|
||||
# to unknown
|
||||
if self._battery_timer_source is not None:
|
||||
GObject.source_remove(self._battery_timer_source)
|
||||
self._battery_timer_source = \
|
||||
GObject.timeout_add_seconds(self.BATTERY_UPDATE_MIN_INTERVAL,
|
||||
self._on_battery_timeout)
|
||||
self._last_battery_update_time = time.time()
|
||||
|
||||
def _on_battery_timeout(self):
|
||||
if self._last_battery_update_time < time.time() - self.BATTERY_UPDATE_MIN_INTERVAL:
|
||||
self.battery_state = TuhiDevice.BatteryState.UNKNOWN
|
||||
self._battery_timer_source = None # gets auto-destroyed
|
||||
return False
|
||||
|
||||
|
||||
class Tuhi(GObject.Object):
|
||||
'''
|
||||
The Tuhi object is the main entry point and glue object between the
|
||||
backend and the DBus server.
|
||||
'''
|
||||
__gsignals__ = {
|
||||
'device-added':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
'device-connected':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
'terminate':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, config_dir=None):
|
||||
GObject.Object.__init__(self)
|
||||
self.server = TuhiDBusServer()
|
||||
self.server.connect('bus-name-acquired', self._on_tuhi_bus_name_acquired)
|
||||
self.server.connect('bus-name-lost', self._on_tuhi_bus_name_lost)
|
||||
self.server.connect('search-start-requested', self._on_start_search_requested)
|
||||
self.server.connect('search-stop-requested', self._on_stop_search_requested)
|
||||
self.bluez = BlueZDeviceManager()
|
||||
self.bluez.connect('discovery-started', self._on_bluez_discovery_started)
|
||||
self.bluez.connect('discovery-stopped', self._on_bluez_discovery_stopped)
|
||||
|
||||
self.config = TuhiConfig()
|
||||
|
||||
self.devices = {}
|
||||
|
||||
self._search_stop_handler = None
|
||||
|
||||
def _on_tuhi_bus_name_acquired(self, dbus_server):
|
||||
self.bluez.connect_to_bluez()
|
||||
for dev in self.bluez.devices:
|
||||
self._add_device(self.bluez, dev)
|
||||
|
||||
self.bluez.connect('device-added',
|
||||
lambda mgr, dev: self._add_device(mgr, dev, True))
|
||||
self.bluez.connect('device-updated',
|
||||
lambda mgr, dev: self._add_device(mgr, dev, True))
|
||||
|
||||
def _on_tuhi_bus_name_lost(self, dbus_server):
|
||||
self.emit('terminate')
|
||||
|
||||
def _on_start_search_requested(self, dbus_server, stop_handler):
|
||||
self._search_stop_handler = stop_handler
|
||||
self.bluez.start_discovery()
|
||||
|
||||
def _on_stop_search_requested(self, dbus_server):
|
||||
# If you request to stop, you get a successful stop and we ignore
|
||||
# anything the server does underneath
|
||||
self._search_stop_handler(0)
|
||||
self._search_stop_handler = None
|
||||
self.bluez.stop_discovery()
|
||||
self._search_device_handler = None
|
||||
|
||||
unregistered = [addr for (addr, d) in self.devices.items() if not d.registered]
|
||||
for addr in unregistered:
|
||||
del self.devices[addr]
|
||||
|
||||
def _on_bluez_discovery_started(self, manager):
|
||||
# Something else may turn discovery mode on, we don't care about
|
||||
# it then
|
||||
if not self._search_stop_handler:
|
||||
return
|
||||
|
||||
def _on_bluez_discovery_stopped(self, manager):
|
||||
if self._search_stop_handler is not None:
|
||||
self._search_stop_handler(0)
|
||||
|
||||
# restart discovery if some users are already in the listening mode
|
||||
self._on_listening_updated(None, None)
|
||||
|
||||
def _add_device(self, manager, bluez_device, from_live_update=False):
|
||||
'''
|
||||
Process a new BlueZ device that may be one of our devices.
|
||||
|
||||
This function is called once during intial setup to enumerate the
|
||||
BlueZ devices and for every BlueZ device property change. Including
|
||||
RSSI which will give you a value every second or so.
|
||||
|
||||
.. :param from_live_update: True if this function was called from a BlueZ
|
||||
device property update. False when called during the initial setup
|
||||
stage.
|
||||
'''
|
||||
|
||||
# We have a reverse-engineered protocol. Let's not talk to anyone
|
||||
# who doesn't look like we know them to avoid potentially bricking a
|
||||
# device. If the vendor id is None it may still be one of our
|
||||
# devices, provided it's been registered previously.
|
||||
if bluez_device.vendor_id is not None and bluez_device.vendor_id not in WACOM_COMPANY_IDS:
|
||||
return
|
||||
|
||||
# check if the device is already known to us
|
||||
try:
|
||||
config = self.config.devices[bluez_device.address]
|
||||
uuid = config['uuid']
|
||||
except KeyError:
|
||||
if bluez_device.vendor_id is None:
|
||||
return
|
||||
uuid = None
|
||||
|
||||
# if we got here from a currently live BlueZ device,
|
||||
# ManufacturerData is reliable. Else, consider the device not in
|
||||
# register mode
|
||||
#
|
||||
# When the device is in register mode (blue light blinking), the
|
||||
# manufacturer is merely 4 bytes. This will reset to 7 bytes even
|
||||
# when the device simply times out and does not register fully.
|
||||
if from_live_update and len(bluez_device.manufacturer_data or []) == 4:
|
||||
mode = DeviceMode.REGISTER
|
||||
else:
|
||||
mode = DeviceMode.LISTEN
|
||||
if uuid is None:
|
||||
logger.info(f'{bluez_device.address}: device without config, must be registered first')
|
||||
return
|
||||
logger.debug(f'{bluez_device.address}: UUID {uuid} protocol: {config["Protocol"]}')
|
||||
|
||||
# create the device if unknown from us
|
||||
if bluez_device.address not in self.devices:
|
||||
d = TuhiDevice(bluez_device, self.config, uuid, mode)
|
||||
d.dbus_device = self.server.create_device(d)
|
||||
d.connect('notify::listening', self._on_listening_updated)
|
||||
self.devices[bluez_device.address] = d
|
||||
|
||||
d = self.devices[bluez_device.address]
|
||||
|
||||
if mode == DeviceMode.REGISTER:
|
||||
d.mode = mode
|
||||
logger.debug(f'{bluez_device.objpath}: call Register() on device')
|
||||
elif d.listening:
|
||||
d.listen()
|
||||
|
||||
def _on_listening_updated(self, tuhi_dbus_device, pspec):
|
||||
listen = self._search_stop_handler is not None
|
||||
for dev in self.devices.values():
|
||||
if dev.listening:
|
||||
listen = True
|
||||
break
|
||||
|
||||
if listen:
|
||||
self.bluez.start_discovery()
|
||||
else:
|
||||
self.bluez.stop_discovery()
|
||||
|
||||
|
||||
def setup_logging(config_dir):
|
||||
session_log_file = Path(config_dir, 'session-logs', f'tuhi-{time.strftime("%y-%m-%d-%H:%M:%S")}.log')
|
||||
session_log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
formatter = logging.Formatter(fmt='%(asctime)s %(levelname)s: %(name)s: %(message)s',
|
||||
datefmt='%H:%M:%S')
|
||||
|
||||
fh = logging.FileHandler(session_log_file)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(formatter)
|
||||
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG)
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
logger.addHandler(fh)
|
||||
logger.info(f'Session log: {session_log_file}')
|
||||
|
||||
|
||||
def main(args=sys.argv):
|
||||
if sys.version_info < (3, 6):
|
||||
sys.exit('Python 3.6 or later required')
|
||||
|
||||
desc = 'Daemon to extract the pen stroke data from Wacom SmartPad devices'
|
||||
parser = argparse.ArgumentParser(description=desc)
|
||||
parser.add_argument('-v', '--verbose',
|
||||
help='Show some debugging informations',
|
||||
action='store_true',
|
||||
default=False)
|
||||
parser.add_argument('--config-dir',
|
||||
help='Base directory for configuration',
|
||||
type=str,
|
||||
default=DEFAULT_CONFIG_PATH)
|
||||
parser.add_argument('--peek',
|
||||
help='Download first drawing only but do not remove it from the device',
|
||||
action='store_true',
|
||||
default=False)
|
||||
|
||||
ns = parser.parse_args(args[1:])
|
||||
TuhiConfig.set_base_path(ns.config_dir)
|
||||
TuhiConfig().peek_at_drawing = ns.peek
|
||||
setup_logging(ns.config_dir)
|
||||
|
||||
if ns.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
try:
|
||||
mainloop = GLib.MainLoop()
|
||||
tuhi = Tuhi(config_dir=ns.config_dir)
|
||||
tuhi.connect('terminate', lambda tuhi: mainloop.quit())
|
||||
mainloop.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
464
tuhi/ble.py
|
@ -1,464 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from gi.repository import GObject, Gio, GLib
|
||||
|
||||
logger = logging.getLogger('tuhi.ble')
|
||||
|
||||
ORG_BLUEZ_GATTCHARACTERISTIC1 = 'org.bluez.GattCharacteristic1'
|
||||
ORG_BLUEZ_GATTSERVICE1 = 'org.bluez.GattService1'
|
||||
ORG_BLUEZ_DEVICE1 = 'org.bluez.Device1'
|
||||
ORG_BLUEZ_ADAPTER1 = 'org.bluez.Adapter1'
|
||||
|
||||
|
||||
class BlueZCharacteristic(GObject.Object):
|
||||
'''
|
||||
Abstraction for a org.bluez.GattCharacteristic1 object.
|
||||
|
||||
Use start_notify() to receive notifications about the characteristics.
|
||||
Hook up a property with connect_property() first.
|
||||
|
||||
'''
|
||||
def __init__(self, obj):
|
||||
'''
|
||||
:param obj: the org.bluez.GattCharacteristic1 DBus proxy object
|
||||
'''
|
||||
self.obj = obj
|
||||
|
||||
assert(self.interface is not None)
|
||||
assert(self.uuid is not None)
|
||||
|
||||
self._property_callbacks = {}
|
||||
self.interface.connect('g-properties-changed',
|
||||
self._on_properties_changed)
|
||||
|
||||
@GObject.Property
|
||||
def interface(self):
|
||||
return self.obj.get_interface(ORG_BLUEZ_GATTCHARACTERISTIC1)
|
||||
|
||||
@GObject.Property
|
||||
def objpath(self):
|
||||
return self.obj.get_object_path()
|
||||
|
||||
@GObject.Property
|
||||
def uuid(self):
|
||||
return self.interface.get_cached_property('UUID').unpack()
|
||||
|
||||
def connect_property(self, propname, callback):
|
||||
'''
|
||||
Connect the property with the given name to the callback function
|
||||
provide. When the property chages, callback is invoked as:
|
||||
|
||||
callback(propname, value)
|
||||
|
||||
The common way is connect_property('Value', do_something) to get
|
||||
notified about Value changes on this characteristic.
|
||||
'''
|
||||
self._property_callbacks[propname] = callback
|
||||
|
||||
def start_notify(self):
|
||||
self.interface.StartNotify()
|
||||
|
||||
def write_value(self, data):
|
||||
return self.interface.WriteValue('(aya{sv})', data, {})
|
||||
|
||||
def _on_properties_changed(self, obj, properties, invalidated_properties):
|
||||
properties = properties.unpack()
|
||||
for name, value in properties.items():
|
||||
try:
|
||||
self._property_callbacks[name](name, value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return f'Characteristic {self.uuid}:{self.objpath}'
|
||||
|
||||
|
||||
class BlueZDevice(GObject.Object):
|
||||
'''
|
||||
Abstraction for a org.bluez.Device1 object
|
||||
|
||||
The device initializes itself based on the given object manager and
|
||||
object, specifically: it resolves its gatt characteristics.
|
||||
|
||||
To connect to the real device, call connect_to_device(). The 'connected'
|
||||
and 'disconnected' signals are emitted when the connection is
|
||||
established.
|
||||
|
||||
The device's characteristics are in self.characteristics[uuid]
|
||||
'''
|
||||
__gsignals__ = {
|
||||
'connected':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
'disconnected':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
'updated':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, om, obj):
|
||||
'''
|
||||
:param om: The ObjectManager for name org.bluez path /
|
||||
:param obj: The org.bluez.Device1 DBus proxy object
|
||||
'''
|
||||
GObject.Object.__init__(self)
|
||||
self.obj = obj
|
||||
self.om = om
|
||||
self.logger = logger.getChild(self.address)
|
||||
|
||||
assert(self.interface is not None)
|
||||
|
||||
self.logger.debug(f'Device {self.objpath} - {self.name}')
|
||||
|
||||
self.characteristics = {}
|
||||
self._resolve_gatt_characteristics()
|
||||
self.interface.connect('g-properties-changed', self._on_properties_changed)
|
||||
if self.connected:
|
||||
self.emit('connected')
|
||||
|
||||
@GObject.Property
|
||||
def objpath(self):
|
||||
return self.obj.get_object_path()
|
||||
|
||||
@GObject.Property
|
||||
def interface(self):
|
||||
return self.obj.get_interface(ORG_BLUEZ_DEVICE1)
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
try:
|
||||
return self.interface.get_cached_property('Name').unpack()
|
||||
except AttributeError:
|
||||
return 'UNKNOWN'
|
||||
|
||||
@GObject.Property
|
||||
def address(self):
|
||||
return self.interface.get_cached_property('Address').unpack()
|
||||
|
||||
@GObject.Property
|
||||
def uuids(self):
|
||||
return self.interface.get_cached_property('UUIDs').unpack()
|
||||
|
||||
@GObject.Property
|
||||
def vendor_id(self):
|
||||
md = self.interface.get_cached_property('ManufacturerData')
|
||||
if md is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return next(iter(dict(md)))
|
||||
except StopIteration:
|
||||
# dict is empty
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@GObject.Property
|
||||
def connected(self):
|
||||
return (self.interface.get_cached_property('Connected').unpack() and
|
||||
self.interface.get_cached_property('ServicesResolved').unpack())
|
||||
|
||||
@GObject.Property
|
||||
def manufacturer_data(self):
|
||||
md = self.interface.get_cached_property('ManufacturerData')
|
||||
if md is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return next(iter(dict(md).values()))
|
||||
except StopIteration:
|
||||
# dict is empty
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_gatt_characteristics(self):
|
||||
'''
|
||||
Resolve the GattCharacteristics.
|
||||
'''
|
||||
objs = self.om.get_objects(interface=ORG_BLUEZ_GATTCHARACTERISTIC1,
|
||||
base_path=self.objpath)
|
||||
for obj in objs:
|
||||
i = obj.get_interface(ORG_BLUEZ_GATTCHARACTERISTIC1)
|
||||
uuid = i.get_cached_property('UUID').unpack()
|
||||
|
||||
if uuid in self.characteristics:
|
||||
continue
|
||||
|
||||
self.characteristics[uuid] = BlueZCharacteristic(obj)
|
||||
self.logger.debug(f'GattCharacteristic: {uuid}')
|
||||
|
||||
def connect_device(self):
|
||||
'''
|
||||
Connect to the bluetooth device via bluez. This function is
|
||||
asynchronous and returns immediately.
|
||||
'''
|
||||
i = self.obj.get_interface(ORG_BLUEZ_DEVICE1)
|
||||
if self.connected:
|
||||
self.logger.info(f'Device is already connected')
|
||||
self.emit('connected')
|
||||
return
|
||||
|
||||
self.logger.debug(f'Connecting')
|
||||
i.Connect(result_handler=self._on_connect_result)
|
||||
|
||||
def _on_connect_result(self, obj, result, user_data):
|
||||
if (isinstance(result, GLib.Error) and
|
||||
result.domain == 'g-io-error-quark' and
|
||||
result.code == Gio.IOErrorEnum.DBUS_ERROR and
|
||||
Gio.dbus_error_get_remote_error(result) == 'org.bluez.Error.Failed' and
|
||||
'Operation already in progress' in result.message):
|
||||
self.logger.debug(f'Already connecting')
|
||||
elif isinstance(result, Exception):
|
||||
self.logger.error(f'Connection failed: {result}')
|
||||
|
||||
def disconnect_device(self):
|
||||
'''
|
||||
Disconnect the bluetooth device via bluez. This function is
|
||||
asynchronous and returns immediately.
|
||||
'''
|
||||
i = self.obj.get_interface(ORG_BLUEZ_DEVICE1)
|
||||
if not i.get_cached_property('Connected').get_boolean():
|
||||
self.logger.info(f'Device is already disconnected')
|
||||
self.emit('disconnected')
|
||||
return
|
||||
|
||||
self.logger.debug(f'Disconnecting')
|
||||
i.Disconnect(result_handler=self._on_disconnect_result)
|
||||
|
||||
def _on_disconnect_result(self, obj, result, user_data):
|
||||
if isinstance(result, Exception):
|
||||
self.logger.error(f'Disconnection failed: {result}')
|
||||
|
||||
def _on_properties_changed(self, obj, properties, invalidated_properties):
|
||||
properties = properties.unpack()
|
||||
|
||||
if 'Connected' in properties:
|
||||
if properties['Connected']:
|
||||
self.logger.debug('Connection established')
|
||||
else:
|
||||
self.logger.debug('Disconnected')
|
||||
self.emit('disconnected')
|
||||
if 'ServicesResolved' in properties:
|
||||
if properties['ServicesResolved']:
|
||||
self._resolve_gatt_characteristics()
|
||||
self.emit('connected')
|
||||
if 'RSSI' in properties:
|
||||
self.emit('updated')
|
||||
if 'ManufacturerData' in properties:
|
||||
self.notify('manufacturer-data')
|
||||
|
||||
def connect_gatt_value(self, uuid, callback):
|
||||
'''
|
||||
Connects Value property changes of the given GATT Characteristics
|
||||
UUID to the callback.
|
||||
'''
|
||||
try:
|
||||
chrc = self.characteristics[uuid]
|
||||
chrc.connect_property('Value', callback)
|
||||
chrc.start_notify()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return f'Device {self.name}:{self.objpath}'
|
||||
|
||||
|
||||
class BlueZObjectManager:
|
||||
'''
|
||||
Namespace to encapsulate our modification to the object manager.
|
||||
'''
|
||||
@classmethod
|
||||
def instance(cls):
|
||||
proxy = Gio.DBusObjectManagerClient.new_for_bus_sync(
|
||||
Gio.BusType.SYSTEM,
|
||||
Gio.DBusObjectManagerClientFlags.NONE,
|
||||
'org.bluez',
|
||||
'/',
|
||||
None,
|
||||
None,
|
||||
None)
|
||||
|
||||
# Replace the object managers get_objects() with our pimped one.
|
||||
proxy.get_objects_unsorted = proxy.get_objects
|
||||
proxy.get_objects = partial(cls.get_objects, proxy)
|
||||
|
||||
return proxy
|
||||
|
||||
def get_objects(self, interface=None, base_path=None):
|
||||
'''
|
||||
Get objects sorted by their object path.
|
||||
|
||||
Optional arguments can be used to filter the returned object list.
|
||||
|
||||
:param interface: filter objects by interface, default is None
|
||||
:param base_path: filter objects by object path, default is None
|
||||
(the objects path has to start with `base_path`)
|
||||
'''
|
||||
def base_path_filter(obj):
|
||||
return obj.get_object_path().startswith(base_path)
|
||||
|
||||
def interface_filter(obj):
|
||||
return obj.get_interface(interface) is not None
|
||||
|
||||
objs = self.get_objects_unsorted()
|
||||
|
||||
if base_path is not None:
|
||||
objs = filter(base_path_filter, objs)
|
||||
|
||||
if interface is not None:
|
||||
objs = filter(interface_filter, objs)
|
||||
|
||||
return sorted(objs, key=lambda obj: obj.get_object_path())
|
||||
|
||||
|
||||
class BlueZDeviceManager(GObject.Object):
|
||||
'''
|
||||
Manager object that connects to org.bluez's root object and handles the
|
||||
devices.
|
||||
'''
|
||||
__gsignals__ = {
|
||||
'device-added':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
'device-updated':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
'discovery-started':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
'discovery-stopped':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
GObject.Object.__init__(self, **kwargs)
|
||||
self.devices = []
|
||||
self._discovery = False
|
||||
|
||||
def connect_to_bluez(self):
|
||||
'''
|
||||
Connect to bluez's DBus interface. Once called, devices will be
|
||||
resolved as they come in. The device-added signal is emitted for
|
||||
each device.
|
||||
'''
|
||||
self._om = BlueZObjectManager.instance()
|
||||
self._om.connect('object-added', self._on_om_object_added)
|
||||
self._om.connect('object-removed', self._on_om_object_removed)
|
||||
|
||||
for obj in self._om.get_objects():
|
||||
self._process_object(obj)
|
||||
|
||||
def _discovery_timeout_expired(self):
|
||||
self.stop_discovery()
|
||||
return False
|
||||
|
||||
def start_discovery(self, timeout=0):
|
||||
'''
|
||||
Start discovery mode, terminating after the specified timeout (in
|
||||
seconds). If timeout is 0, no timeout is imposed and the discovery
|
||||
mode stays on.
|
||||
|
||||
This emits the discovery-started signal
|
||||
'''
|
||||
self.emit('discovery-started')
|
||||
if self._discovery:
|
||||
return
|
||||
|
||||
self._discovery = True
|
||||
|
||||
for obj in self._om.get_objects(interface=ORG_BLUEZ_ADAPTER1):
|
||||
i = obj.get_interface(ORG_BLUEZ_ADAPTER1)
|
||||
|
||||
# remove the duplicate data filter so we get notifications as they come in
|
||||
i.SetDiscoveryFilter('(a{sv})', {'DuplicateData': GLib.Variant.new_boolean(False)})
|
||||
|
||||
objpath = obj.get_object_path()
|
||||
try:
|
||||
i.StartDiscovery()
|
||||
logger.debug(f'{objpath}: Discovery started (timeout {timeout})')
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR and
|
||||
Gio.dbus_error_get_remote_error(e) == 'org.bluez.Error.InProgress'):
|
||||
logger.debug(f'{objpath}: Already listening')
|
||||
|
||||
if timeout > 0:
|
||||
GObject.timeout_add_seconds(timeout, self._discovery_timeout_expired)
|
||||
|
||||
# FIXME: Any errors up to here should trigger discovery-stopped
|
||||
# signal with the status code
|
||||
|
||||
def stop_discovery(self):
|
||||
'''
|
||||
Stop an ongoing discovery mode. Any errors are logged but ignored.
|
||||
|
||||
This emits the discovery-stopped signal
|
||||
'''
|
||||
if not self._discovery:
|
||||
return
|
||||
|
||||
self._discovery = False
|
||||
|
||||
for obj in self._om.get_objects(interface=ORG_BLUEZ_ADAPTER1):
|
||||
i = obj.get_interface(ORG_BLUEZ_ADAPTER1)
|
||||
objpath = obj.get_object_path()
|
||||
try:
|
||||
i.StopDiscovery()
|
||||
logger.debug(f'{objpath}: Discovery stopped')
|
||||
except GLib.Error as e:
|
||||
logger.debug(f'{objpath}: Failed to stop discovery ({e})')
|
||||
|
||||
# reset the discovery filters
|
||||
i.SetDiscoveryFilter('(a{sv})', {})
|
||||
|
||||
self.emit('discovery-stopped')
|
||||
|
||||
def _on_device_updated(self, device):
|
||||
'''Callback for Device's properties-changed'''
|
||||
# logger.debug(f'Object updated: {device.name}')
|
||||
|
||||
self.emit('device-updated', device)
|
||||
|
||||
def _on_om_object_added(self, om, obj):
|
||||
'''Callback for ObjectManager's object-added'''
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug(f'Object added: {objpath}')
|
||||
self._process_object(obj, event=True)
|
||||
|
||||
def _on_om_object_removed(self, om, obj):
|
||||
'''Callback for ObjectManager's object-removed'''
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug(f'Object removed: {objpath}')
|
||||
|
||||
def _process_object(self, obj, event=True):
|
||||
'''Process a single DBusProxyObject'''
|
||||
|
||||
if obj.get_interface(ORG_BLUEZ_ADAPTER1) is not None:
|
||||
self._process_adapter(obj)
|
||||
elif obj.get_interface(ORG_BLUEZ_DEVICE1) is not None:
|
||||
self._process_device(obj)
|
||||
elif obj.get_interface(ORG_BLUEZ_GATTCHARACTERISTIC1) is not None:
|
||||
self._process_characteristic(obj)
|
||||
|
||||
def _process_adapter(self, obj):
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug(f'Adapter: {objpath}')
|
||||
|
||||
def _process_device(self, obj):
|
||||
dev = BlueZDevice(self._om, obj)
|
||||
self.devices.append(dev)
|
||||
dev.connect('updated', self._on_device_updated)
|
||||
self.emit('device-added', dev)
|
||||
|
||||
def _process_characteristic(self, obj):
|
||||
objpath = obj.get_object_path()
|
||||
logger.debug(f'Characteristic {objpath}')
|
138
tuhi/config.py
|
@ -1,138 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from gi.repository import GObject
|
||||
|
||||
import configparser
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from .drawing import Drawing
|
||||
from .protocol import ProtocolVersion
|
||||
|
||||
logger = logging.getLogger('tuhi.config')
|
||||
|
||||
|
||||
def is_btaddr(addr):
|
||||
return re.match('^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$', addr) is not None
|
||||
|
||||
|
||||
class TuhiConfig(GObject.Object):
|
||||
_instance = None
|
||||
_base_path = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(TuhiConfig, cls).__new__(cls)
|
||||
self = cls._instance
|
||||
self.__init__() # for GObject to initialize
|
||||
logger.debug(f'Using config directory: {self._base_path}')
|
||||
Path(self._base_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._devices = {}
|
||||
self._scan_config_dir()
|
||||
self.peek_at_drawing = False
|
||||
return cls._instance
|
||||
|
||||
@property
|
||||
def log_dir(self):
|
||||
'''
|
||||
The pathlib.Path to the directory to store log files in.
|
||||
'''
|
||||
return Path(self._base_path)
|
||||
|
||||
@GObject.Property
|
||||
def devices(self):
|
||||
'''
|
||||
Returns a dictionary with the bluetooth address as key
|
||||
'''
|
||||
return self._devices
|
||||
|
||||
def _scan_config_dir(self):
|
||||
dirs = [d for d in Path(self._base_path).iterdir() if d.is_dir() and is_btaddr(d.name)]
|
||||
for directory in dirs:
|
||||
settings = Path(directory, 'settings.ini')
|
||||
if not settings.is_file():
|
||||
continue
|
||||
|
||||
logger.debug(f'{directory}: configuration found')
|
||||
config = configparser.ConfigParser()
|
||||
config.read(settings)
|
||||
|
||||
btaddr = directory.name
|
||||
assert config['Device']['Address'] == btaddr
|
||||
if 'Protocol' not in config['Device']:
|
||||
config['Device']['Protocol'] = ProtocolVersion.ANY.name.lower()
|
||||
self._devices[btaddr] = config['Device']
|
||||
|
||||
def new_device(self, address, uuid, protocol):
|
||||
assert is_btaddr(address)
|
||||
assert len(uuid) == 12
|
||||
assert protocol != ProtocolVersion.ANY
|
||||
|
||||
logger.debug(f'{address}: adding new config, UUID {uuid}')
|
||||
path = Path(self._base_path, address)
|
||||
path.mkdir(exist_ok=True)
|
||||
|
||||
# The ConfigParser default is to write out options as lowercase, but
|
||||
# the ini standard is Capitalized. But it's convenient to have
|
||||
# write-out nice but read-in flexible. So have two different config
|
||||
# parsers for writing and then for handling the reads later
|
||||
path = Path(path, 'settings.ini')
|
||||
config = configparser.ConfigParser()
|
||||
config.optionxform = str
|
||||
config.read(path)
|
||||
|
||||
config['Device'] = {
|
||||
'Address': address,
|
||||
'UUID': uuid,
|
||||
'Protocol': protocol.name.lower(),
|
||||
}
|
||||
|
||||
with open(path, 'w') as configfile:
|
||||
config.write(configfile)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
self._devices[address] = config['Device']
|
||||
|
||||
def store_drawing(self, address, drawing):
|
||||
assert is_btaddr(address)
|
||||
assert drawing is not None
|
||||
|
||||
if address not in self.devices:
|
||||
logger.error(f'{address}: cannot store drawings for unknown device')
|
||||
return
|
||||
|
||||
logger.debug(f'{address}: adding new drawing, timestamp {drawing.timestamp}')
|
||||
path = Path(self._base_path, address, f'{drawing.timestamp}.json')
|
||||
|
||||
with open(path, 'w') as f:
|
||||
f.write(drawing.to_json())
|
||||
|
||||
def load_drawings(self, address):
|
||||
assert is_btaddr(address)
|
||||
|
||||
if address not in self.devices:
|
||||
return []
|
||||
|
||||
configdir = Path(self._base_path, address)
|
||||
return [Drawing.from_json(f) for f in configdir.glob('*.json')]
|
||||
|
||||
@classmethod
|
||||
def set_base_path(cls, path):
|
||||
if cls._instance is not None:
|
||||
logger.error('Trying to set config base path but we already have the singleton object')
|
||||
return
|
||||
|
||||
cls._base_path = Path(path)
|
|
@ -1,412 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from gi.repository import GObject, Gio, GLib
|
||||
import argparse
|
||||
import errno
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger('tuhi.dbusclient')
|
||||
|
||||
TUHI_DBUS_NAME = 'org.freedesktop.tuhi1'
|
||||
ORG_FREEDESKTOP_TUHI1_MANAGER = 'org.freedesktop.tuhi1.Manager'
|
||||
ORG_FREEDESKTOP_TUHI1_DEVICE = 'org.freedesktop.tuhi1.Device'
|
||||
ROOT_PATH = '/org/freedesktop/tuhi1'
|
||||
|
||||
ORG_BLUEZ_DEVICE1 = 'org.bluez.Device1'
|
||||
|
||||
|
||||
class DBusError(Exception):
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
|
||||
class _DBusObject(GObject.Object):
|
||||
_connection = None
|
||||
|
||||
def __init__(self, name, interface, objpath):
|
||||
super().__init__()
|
||||
|
||||
# this is not handled asynchronously because if we fail to
|
||||
# get the session bus, we have other issues
|
||||
if _DBusObject._connection is None:
|
||||
self._connect_to_session()
|
||||
|
||||
self.interface = interface
|
||||
self.objpath = objpath
|
||||
self._online = False
|
||||
self._name = name
|
||||
try:
|
||||
self._connect()
|
||||
except DBusError:
|
||||
self._reconnect_timer = GObject.timeout_add_seconds(2, self._on_reconnect_timer)
|
||||
|
||||
def _connect(self):
|
||||
try:
|
||||
self.proxy = Gio.DBusProxy.new_sync(self._connection,
|
||||
Gio.DBusProxyFlags.NONE, None,
|
||||
self._name, self.objpath,
|
||||
self.interface, None)
|
||||
if self.proxy.get_name_owner() is None:
|
||||
raise DBusError(f'No-one is handling {self._name}, is the daemon running?')
|
||||
|
||||
self._online = True
|
||||
self.notify('online')
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
self.proxy.connect('g-properties-changed', self._on_properties_changed)
|
||||
self.proxy.connect('g-signal', self._on_signal_received)
|
||||
|
||||
def _on_reconnect_timer(self):
|
||||
try:
|
||||
logger.debug('reconnecting')
|
||||
self._connect()
|
||||
return False
|
||||
except DBusError:
|
||||
return True
|
||||
|
||||
@GObject.Property
|
||||
def online(self):
|
||||
return self._online
|
||||
|
||||
def _connect_to_session(self):
|
||||
try:
|
||||
_DBusObject._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def _on_properties_changed(self, proxy, changed_props, invalidated_props):
|
||||
# Implement this in derived classes to respond to property changes
|
||||
pass
|
||||
|
||||
def _on_signal_received(self, proxy, sender, signal, parameters):
|
||||
# Implement this in derived classes to respond to signals
|
||||
pass
|
||||
|
||||
def property(self, name):
|
||||
p = self.proxy.get_cached_property(name)
|
||||
if p is not None:
|
||||
return p.unpack()
|
||||
return p
|
||||
|
||||
def terminate(self):
|
||||
del(self.proxy)
|
||||
|
||||
|
||||
class _DBusSystemObject(_DBusObject):
|
||||
'''
|
||||
Same as the _DBusObject, but connects to the system bus instead
|
||||
'''
|
||||
def __init__(self, name, interface, objpath):
|
||||
self._connect_to_system()
|
||||
super().__init__(name, interface, objpath)
|
||||
|
||||
def _connect_to_system(self):
|
||||
try:
|
||||
self._connection = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||
except GLib.Error as e:
|
||||
if (e.domain == 'g-io-error-quark' and
|
||||
e.code == Gio.IOErrorEnum.DBUS_ERROR):
|
||||
raise DBusError(e.message)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
class BlueZDevice(_DBusSystemObject):
|
||||
def __init__(self, objpath):
|
||||
super().__init__('org.bluez', ORG_BLUEZ_DEVICE1, objpath)
|
||||
self.proxy.connect('g-properties-changed', self._on_properties_changed)
|
||||
|
||||
@GObject.Property
|
||||
def connected(self):
|
||||
return self.proxy.get_cached_property('Connected').unpack()
|
||||
|
||||
def _on_properties_changed(self, obj, properties, invalidated_properties):
|
||||
properties = properties.unpack()
|
||||
|
||||
if 'Connected' in properties:
|
||||
self.notify('connected')
|
||||
|
||||
|
||||
class TuhiDBusClientDevice(_DBusObject):
|
||||
__gsignals__ = {
|
||||
'button-press-required':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
'registered':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
'device-error':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (int,)),
|
||||
}
|
||||
|
||||
def __init__(self, manager, objpath):
|
||||
super().__init__(TUHI_DBUS_NAME, ORG_FREEDESKTOP_TUHI1_DEVICE, objpath)
|
||||
self.manager = manager
|
||||
self.is_registering = False
|
||||
self._bluez_device = BlueZDevice(self.property('BlueZDevice'))
|
||||
self._bluez_device.connect('notify::connected', self._on_connected)
|
||||
self._sync_state = 0
|
||||
|
||||
@classmethod
|
||||
def is_device_address(cls, string):
|
||||
if re.match(r'[0-9a-f]{2}(:[0-9a-f]{2}){5}$', string.lower()):
|
||||
return string
|
||||
raise argparse.ArgumentTypeError(f'"{string}" is not a valid device address')
|
||||
|
||||
@GObject.Property
|
||||
def address(self):
|
||||
return self._bluez_device.property('Address')
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return self._bluez_device.property('Name')
|
||||
|
||||
@GObject.Property
|
||||
def dimensions(self):
|
||||
return self.property('Dimensions')
|
||||
|
||||
@GObject.Property
|
||||
def listening(self):
|
||||
return self.property('Listening')
|
||||
|
||||
@GObject.Property
|
||||
def drawings_available(self):
|
||||
return self.property('DrawingsAvailable')
|
||||
|
||||
@GObject.Property
|
||||
def battery_percent(self):
|
||||
return self.property('BatteryPercent')
|
||||
|
||||
@GObject.Property
|
||||
def battery_state(self):
|
||||
return self.property('BatteryState')
|
||||
|
||||
@GObject.Property
|
||||
def connected(self):
|
||||
return self._bluez_device.connected
|
||||
|
||||
@GObject.Property
|
||||
def sync_state(self):
|
||||
return self._sync_state
|
||||
|
||||
@GObject.Property
|
||||
def live(self):
|
||||
return self.property('Live')
|
||||
|
||||
def _on_connected(self, bluez_device, pspec):
|
||||
self.notify('connected')
|
||||
|
||||
def register(self):
|
||||
logger.debug(f'{self}: Register')
|
||||
# FIXME: Register() doesn't return anything useful yet, so we wait until
|
||||
# the device is in the Manager's Devices property
|
||||
self.s1 = self.manager.connect('notify::devices', self._on_mgr_devices_updated)
|
||||
self.is_registering = True
|
||||
self.proxy.Register()
|
||||
|
||||
def start_listening(self):
|
||||
self.proxy.StartListening()
|
||||
|
||||
def stop_listening(self):
|
||||
try:
|
||||
self.proxy.StopListening()
|
||||
except GLib.Error as e:
|
||||
if (e.domain != 'g-dbus-error-quark' or
|
||||
e.code != Gio.IOErrorEnum.EXISTS or
|
||||
Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'):
|
||||
raise e
|
||||
|
||||
def json(self, timestamp):
|
||||
SUPPORTED_FILE_FORMAT = 1
|
||||
return self.proxy.GetJSONData('(ut)', SUPPORTED_FILE_FORMAT, timestamp)
|
||||
|
||||
def _on_signal_received(self, proxy, sender, signal, parameters):
|
||||
if signal == 'ButtonPressRequired':
|
||||
logger.info(f'{self}: Press button on device now')
|
||||
self.emit('button-press-required')
|
||||
elif signal == 'ListeningStopped':
|
||||
err = parameters[0]
|
||||
if err == -errno.EACCES:
|
||||
logger.error(f'{self}: wrong device, please re-register.')
|
||||
elif err < 0:
|
||||
logger.error(f'{self}: an error occured: {os.strerror(-err)}')
|
||||
self.emit('device-error', err)
|
||||
self.notify('listening')
|
||||
elif signal == 'SyncState':
|
||||
self._sync_state = parameters[0]
|
||||
self.notify('sync-state')
|
||||
|
||||
def _on_properties_changed(self, proxy, changed_props, invalidated_props):
|
||||
if changed_props is None:
|
||||
return
|
||||
|
||||
changed_props = changed_props.unpack()
|
||||
|
||||
if 'DrawingsAvailable' in changed_props:
|
||||
self.notify('drawings-available')
|
||||
elif 'Listening' in changed_props:
|
||||
self.notify('listening')
|
||||
elif 'BatteryPercent' in changed_props:
|
||||
self.notify('battery-percent')
|
||||
elif 'BatteryState' in changed_props:
|
||||
self.notify('battery-state')
|
||||
elif 'Live' in changed_props:
|
||||
self.notify('live')
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.address} - {self.name}'
|
||||
|
||||
def _on_mgr_devices_updated(self, manager, pspec):
|
||||
if not self.is_registering:
|
||||
return
|
||||
|
||||
for d in manager.devices:
|
||||
if d.address == self.address:
|
||||
self.is_registering = False
|
||||
self.manager.disconnect(self.s1)
|
||||
del(self.s1)
|
||||
logger.info(f'{self}: Registration successful')
|
||||
self.emit('registered')
|
||||
|
||||
def start_live(self, fd):
|
||||
fd_list = Gio.UnixFDList.new()
|
||||
fd_list.append(fd)
|
||||
|
||||
res, fds = self.proxy.call_with_unix_fd_list_sync('org.freedesktop.tuhi1.Device.StartLive',
|
||||
GLib.Variant('(h)', (fd,)),
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
fd_list,
|
||||
None)
|
||||
|
||||
def stop_live(self):
|
||||
self.proxy.StopLive()
|
||||
|
||||
def terminate(self):
|
||||
try:
|
||||
self.manager.disconnect(self.s1)
|
||||
except AttributeError:
|
||||
pass
|
||||
self._bluez_device.terminate()
|
||||
super().terminate()
|
||||
|
||||
|
||||
class TuhiDBusClientManager(_DBusObject):
|
||||
__gsignals__ = {
|
||||
'unregistered-device':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(TUHI_DBUS_NAME, ORG_FREEDESKTOP_TUHI1_MANAGER, ROOT_PATH)
|
||||
|
||||
self._devices = {}
|
||||
self._unregistered_devices = {}
|
||||
logger.info('starting up')
|
||||
|
||||
if not self.online:
|
||||
self.connect('notify::online', self._init)
|
||||
else:
|
||||
self._init()
|
||||
|
||||
def _init(self, *args, **kwargs):
|
||||
logger.info('manager is online')
|
||||
for objpath in self.property('Devices'):
|
||||
device = TuhiDBusClientDevice(self, objpath)
|
||||
self._devices[device.address] = device
|
||||
|
||||
@GObject.Property
|
||||
def devices(self):
|
||||
return [v for k, v in self._devices.items()]
|
||||
|
||||
@GObject.Property
|
||||
def unregistered_devices(self):
|
||||
return [v for k, v in self._unregistered_devices.items()]
|
||||
|
||||
@GObject.Property
|
||||
def searching(self):
|
||||
return self.proxy.get_cached_property('Searching')
|
||||
|
||||
def start_search(self):
|
||||
self._unregistered_devices = {}
|
||||
self.proxy.StartSearch()
|
||||
|
||||
def stop_search(self):
|
||||
try:
|
||||
self.proxy.StopSearch()
|
||||
except GLib.Error as e:
|
||||
if (e.domain != 'g-dbus-error-quark' or
|
||||
e.code != Gio.IOErrorEnum.EXISTS or
|
||||
Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'):
|
||||
raise e
|
||||
self._unregistered_devices = {}
|
||||
|
||||
def terminate(self):
|
||||
for dev in self._devices.values():
|
||||
dev.terminate()
|
||||
self._devices = {}
|
||||
self._unregistered_devices = {}
|
||||
super().terminate()
|
||||
|
||||
def _on_properties_changed(self, proxy, changed_props, invalidated_props):
|
||||
if changed_props is None:
|
||||
return
|
||||
|
||||
changed_props = changed_props.unpack()
|
||||
|
||||
if 'Devices' in changed_props:
|
||||
objpaths = changed_props['Devices']
|
||||
for objpath in objpaths:
|
||||
try:
|
||||
d = self._unregistered_devices[objpath]
|
||||
self._devices[d.address] = d
|
||||
del self._unregistered_devices[objpath]
|
||||
except KeyError:
|
||||
# if we called Register() on an existing device it's not
|
||||
# in unregistered devices
|
||||
pass
|
||||
self.notify('devices')
|
||||
if 'Searching' in changed_props:
|
||||
self.notify('searching')
|
||||
|
||||
def _handle_unregistered_device(self, objpath):
|
||||
for addr, dev in self._devices.items():
|
||||
if dev.objpath == objpath:
|
||||
self.emit('unregistered-device', dev)
|
||||
return
|
||||
|
||||
device = TuhiDBusClientDevice(self, objpath)
|
||||
self._unregistered_devices[objpath] = device
|
||||
|
||||
logger.debug(f'New unregistered device: {device}')
|
||||
self.emit('unregistered-device', device)
|
||||
|
||||
def _on_signal_received(self, proxy, sender, signal, parameters):
|
||||
if signal == 'SearchStopped':
|
||||
self.notify('searching')
|
||||
elif signal == 'UnregisteredDevice':
|
||||
objpath = parameters[0]
|
||||
self._handle_unregistered_device(objpath)
|
||||
|
||||
def __getitem__(self, btaddr):
|
||||
return self._devices[btaddr]
|
|
@ -1,660 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
import logging
|
||||
import errno
|
||||
|
||||
from gi.repository import GObject, Gio, GLib
|
||||
from .drawing import Drawing
|
||||
|
||||
logger = logging.getLogger('tuhi.dbus')
|
||||
|
||||
INTROSPECTION_XML = '''
|
||||
<node>
|
||||
<interface name='org.freedesktop.tuhi1.Manager'>
|
||||
<property type='ao' name='Devices' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
|
||||
<property type='ao' name='Searching' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
|
||||
<property type='au' name='JSONDataVersions' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='const'/>
|
||||
</property>
|
||||
|
||||
<method name='StartSearch'>
|
||||
<annotation name='org.freedesktop.DBus.Method.NoReply' value='true'/>
|
||||
</method>
|
||||
|
||||
<method name='StopSearch'>
|
||||
<annotation name='org.freedesktop.DBus.Method.NoReply' value='true'/>
|
||||
</method>
|
||||
|
||||
<signal name='SearchStopped'>
|
||||
<arg name='status' type='i' />
|
||||
</signal>
|
||||
|
||||
<signal name='UnregisteredDevice'>
|
||||
<arg name='info' type='o' />
|
||||
</signal>
|
||||
</interface>
|
||||
|
||||
<interface name='org.freedesktop.tuhi1.Device'>
|
||||
<property type='o' name='BlueZDevice' access='read'/>
|
||||
<property type='uu' name='Dimensions' access='read'/>
|
||||
<property type='b' name='Listening' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
<property type='b' name='Live' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
<property type='u' name='BatteryPercent' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
<property type='b' name='BatteryState' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
<property type='at' name='DrawingsAvailable' access='read'>
|
||||
<annotation name='org.freedesktop.DBus.Property.EmitsChangedSignal' value='true'/>
|
||||
</property>
|
||||
|
||||
<method name='Register'>
|
||||
<arg name='result' type='i' direction='out'/>
|
||||
</method>
|
||||
|
||||
<method name='StartListening'>
|
||||
<annotation name='org.freedesktop.DBus.Method.NoReply' value='true'/>
|
||||
</method>
|
||||
|
||||
<method name='StopListening'>
|
||||
<annotation name='org.freedesktop.DBus.Method.NoReply' value='true'/>
|
||||
</method>
|
||||
|
||||
<method name='StartLive'>
|
||||
<arg name='uhid_fd' type='h' />
|
||||
<arg name='result' type='i' direction='out'/>
|
||||
</method>
|
||||
|
||||
<method name='StopLive'>
|
||||
<annotation name='org.freedesktop.DBus.Method.NoReply' value='true'/>
|
||||
</method>
|
||||
|
||||
<method name='GetJSONData'>
|
||||
<arg name='file_version' type='u' direction='in'/>
|
||||
<arg name='timestamp' type='t' direction='in'/>
|
||||
<arg name='json' type='s' direction='out'/>
|
||||
</method>
|
||||
|
||||
<signal name='ButtonPressRequired' />
|
||||
|
||||
<signal name='ListeningStopped'>
|
||||
<arg name='status' type='i' />
|
||||
</signal>
|
||||
|
||||
<signal name='LiveStopped'>
|
||||
<arg name='status' type='i' />
|
||||
</signal>
|
||||
|
||||
<signal name='SyncState'>
|
||||
<arg name='status' type='i' />
|
||||
</signal>
|
||||
</interface>
|
||||
</node>
|
||||
'''
|
||||
BASE_PATH = '/org/freedesktop/tuhi1'
|
||||
BUS_NAME = 'org.freedesktop.tuhi1'
|
||||
INTF_MANAGER = 'org.freedesktop.tuhi1.Manager'
|
||||
INTF_DEVICE = 'org.freedesktop.tuhi1.Device'
|
||||
|
||||
|
||||
class _TuhiDBus(GObject.Object):
|
||||
def __init__(self, connection, objpath, interface):
|
||||
GObject.Object.__init__(self)
|
||||
self.connection = connection
|
||||
self.objpath = objpath
|
||||
self.interface = interface
|
||||
|
||||
def properties_changed(self, props, dest=None):
|
||||
'''
|
||||
Send a PropertiesChanged signal to the given destination (if any).
|
||||
The props argument is a { name: value } dictionary of the
|
||||
property values, the values are GVariant.bool, etc.
|
||||
'''
|
||||
builder = GLib.VariantBuilder(GLib.VariantType('a{sv}'))
|
||||
for name, value in props.items():
|
||||
de = GLib.Variant.new_dict_entry(GLib.Variant.new_string(name),
|
||||
GLib.Variant.new_variant(value))
|
||||
builder.add_value(de)
|
||||
properties = builder.end()
|
||||
inval_props = GLib.VariantBuilder(GLib.VariantType('as'))
|
||||
inval_props = inval_props.end()
|
||||
self.connection.emit_signal(dest, self.objpath,
|
||||
'org.freedesktop.DBus.Properties',
|
||||
'PropertiesChanged',
|
||||
GLib.Variant.new_tuple(
|
||||
GLib.Variant.new_string(self.interface),
|
||||
properties,
|
||||
inval_props))
|
||||
|
||||
def signal(self, name, arg=None, dest=None):
|
||||
if arg is not None:
|
||||
arg = GLib.Variant.new_tuple(arg)
|
||||
self.connection.emit_signal(dest, self.objpath, self.interface, name, arg)
|
||||
|
||||
|
||||
class TuhiDBusDevice(_TuhiDBus):
|
||||
'''
|
||||
Class representing a DBus object for a Tuhi device. This class only
|
||||
handles the DBus bits, communication with the device is done elsewhere.
|
||||
'''
|
||||
__gsignals__ = {
|
||||
'register-requested':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self, device, connection):
|
||||
objpath = device.address.replace(':', '_')
|
||||
objpath = f'{BASE_PATH}/{objpath}'
|
||||
_TuhiDBus.__init__(self, connection, objpath, INTF_DEVICE)
|
||||
|
||||
self.bluez_device_objpath = device.bluez_device.objpath
|
||||
self.name = device.name
|
||||
self.width, self.height = device.dimensions
|
||||
self.drawings = {}
|
||||
self.registered = device.registered
|
||||
self._listening = False
|
||||
self._listening_client = None
|
||||
self._live = False
|
||||
self._uhid_fd = None
|
||||
self._live_client = None
|
||||
self._dbusid = self._register_object(connection)
|
||||
self._battery_percent = 0
|
||||
self._battery_state = device.battery_state
|
||||
device.connect('notify::registered', self._on_device_registered)
|
||||
device.connect('notify::battery-percent', self._on_battery_percent)
|
||||
device.connect('notify::battery-state', self._on_battery_state)
|
||||
device.connect('device-error', self._on_device_error)
|
||||
device.connect('notify::sync-state', self._on_sync_state)
|
||||
device.connect('notify::dimensions', self._on_dimensions)
|
||||
|
||||
@GObject.Property
|
||||
def listening(self):
|
||||
return self._listening
|
||||
|
||||
@listening.setter
|
||||
def listening(self, value):
|
||||
if self._listening == value:
|
||||
return
|
||||
|
||||
self._listening = value
|
||||
self.properties_changed({'Listening': GLib.Variant.new_boolean(value)})
|
||||
|
||||
@GObject.Property
|
||||
def live(self):
|
||||
return self._live
|
||||
|
||||
@live.setter
|
||||
def live(self, value):
|
||||
if self._live == value:
|
||||
return
|
||||
|
||||
self._live = value
|
||||
self.properties_changed({'Live': GLib.Variant.new_boolean(value)})
|
||||
|
||||
@GObject.Property
|
||||
def uhid_fd(self):
|
||||
return self._uhid_fd
|
||||
|
||||
@GObject.Property
|
||||
def registered(self):
|
||||
return self._registered
|
||||
|
||||
@registered.setter
|
||||
def registered(self, registered):
|
||||
self._registered = registered
|
||||
|
||||
@GObject.Property
|
||||
def battery_percent(self):
|
||||
return self._battery_percent
|
||||
|
||||
@battery_percent.setter
|
||||
def battery_percent(self, value):
|
||||
if self._battery_percent == value:
|
||||
return
|
||||
|
||||
self._battery_percent = value
|
||||
self.properties_changed({'BatteryPercent': GLib.Variant.new_uint32(value)})
|
||||
|
||||
@GObject.Property
|
||||
def battery_state(self):
|
||||
return self._battery_state
|
||||
|
||||
@battery_state.setter
|
||||
def battery_state(self, value):
|
||||
if self._battery_state == value:
|
||||
return
|
||||
|
||||
self._battery_state = value
|
||||
self.properties_changed({'BatteryState': GLib.Variant.new_uint32(value.value)})
|
||||
|
||||
def remove(self):
|
||||
self.connection.unregister_object(self._dbusid)
|
||||
self._dbusid = None
|
||||
|
||||
def _register_object(self, connection):
|
||||
introspection = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION_XML)
|
||||
intf = introspection.lookup_interface(self.interface)
|
||||
return connection.register_object(self.objpath,
|
||||
intf,
|
||||
self._method_cb,
|
||||
self._property_read_cb,
|
||||
self._property_write_cb)
|
||||
|
||||
def _method_cb(self, connection, sender, objpath, interface, methodname, args, invocation):
|
||||
if interface != self.interface:
|
||||
return None
|
||||
|
||||
if methodname == 'Register':
|
||||
# FIXME: we should cache the method invocation here, wait for a
|
||||
# successful result from Tuhi and then return the value
|
||||
self._register()
|
||||
result = GLib.Variant.new_int32(0)
|
||||
invocation.return_value(GLib.Variant.new_tuple(result))
|
||||
elif methodname == 'StartListening':
|
||||
self._start_listening(connection, sender)
|
||||
invocation.return_value()
|
||||
elif methodname == 'StopListening':
|
||||
self._stop_listening(connection, sender)
|
||||
invocation.return_value()
|
||||
elif methodname == 'StartLive':
|
||||
self._start_live(connection, sender, args, invocation)
|
||||
elif methodname == 'StopLive':
|
||||
self._stop_live(connection, sender)
|
||||
invocation.return_value()
|
||||
elif methodname == 'GetJSONData':
|
||||
json = GLib.Variant.new_string(self._json_data(args))
|
||||
invocation.return_value(GLib.Variant.new_tuple(json))
|
||||
|
||||
def _property_read_cb(self, connection, sender, objpath, interface, propname):
|
||||
if interface != INTF_DEVICE:
|
||||
return None
|
||||
|
||||
if propname == 'BlueZDevice':
|
||||
return GLib.Variant.new_object_path(self.bluez_device_objpath)
|
||||
elif propname == 'Dimensions':
|
||||
w = GLib.Variant.new_uint32(self.width)
|
||||
h = GLib.Variant.new_uint32(self.height)
|
||||
return GLib.Variant.new_tuple(w, h)
|
||||
elif propname == 'DrawingsAvailable':
|
||||
ts = GLib.Variant.new_array(GLib.VariantType('t'),
|
||||
[GLib.Variant.new_uint64(t)
|
||||
for t in self.drawings.keys()])
|
||||
return ts
|
||||
elif propname == 'Listening':
|
||||
return GLib.Variant.new_boolean(self.listening)
|
||||
elif propname == 'Live':
|
||||
return GLib.Variant.new_boolean(self.live)
|
||||
elif propname == 'BatteryPercent':
|
||||
return GLib.Variant.new_uint32(self.battery_percent)
|
||||
elif propname == 'BatteryState':
|
||||
return GLib.Variant.new_uint32(self.battery_state.value)
|
||||
|
||||
return None
|
||||
|
||||
def _property_write_cb(self):
|
||||
pass
|
||||
|
||||
def _register(self):
|
||||
self.emit('register-requested')
|
||||
|
||||
def _on_device_registered(self, device, pspec):
|
||||
if self.registered == device.registered:
|
||||
return
|
||||
self.registered = device.registered
|
||||
|
||||
def _on_battery_percent(self, device, pspec):
|
||||
self.battery_percent = device.battery_percent
|
||||
|
||||
def _on_battery_state(self, device, pspec):
|
||||
self.battery_state = device.battery_state
|
||||
|
||||
def _on_device_error(self, device, exception):
|
||||
logger.info('An error occured while synching the device')
|
||||
if self.listening:
|
||||
self._stop_listening(self.connection, self._listening_client[0],
|
||||
-exception.errno)
|
||||
|
||||
def _on_dimensions(self, device, pspec):
|
||||
self.width, self.height = device.dimensions
|
||||
w = GLib.Variant.new_uint32(self.width)
|
||||
h = GLib.Variant.new_uint32(self.height)
|
||||
self.properties_changed({'Dimensions': GLib.Variant.new_tuple(w, h)})
|
||||
|
||||
def _on_sync_state(self, device, pspec):
|
||||
if self._listening_client is None:
|
||||
return
|
||||
|
||||
dest = self._listening_client[0]
|
||||
status = GLib.Variant.new_int32(device.sync_state)
|
||||
self.signal('SyncState', status, dest=dest)
|
||||
|
||||
def _start_listening(self, connection, sender):
|
||||
if self.listening:
|
||||
logger.debug(f'{self} - already listening')
|
||||
|
||||
# silently ignore it for the current client but send EBUSY to
|
||||
# other clients
|
||||
if sender != self._listening_client[0]:
|
||||
status = GLib.Variant.new_int32(-errno.EBUSY)
|
||||
self.signal('ListeningStopped', status, dest=sender)
|
||||
return
|
||||
|
||||
s = connection.signal_subscribe(sender='org.freedesktop.DBus',
|
||||
interface_name='org.freedesktop.DBus',
|
||||
member='NameOwnerChanged',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
arg0=None,
|
||||
flags=Gio.DBusSignalFlags.NONE,
|
||||
callback=self._on_name_owner_changed_signal_cb,
|
||||
user_data=sender)
|
||||
self._listening_client = (sender, s)
|
||||
logger.debug(f'Listening started on {self.name} for {sender}')
|
||||
|
||||
self.listening = True
|
||||
self.notify('listening')
|
||||
|
||||
def _on_name_owner_changed_signal_cb(self, connection, sender, object_path,
|
||||
interface_name, node,
|
||||
out_user_data, user_data):
|
||||
name, old_owner, new_owner = out_user_data
|
||||
if name != user_data:
|
||||
return
|
||||
|
||||
self._stop_listening(connection, user_data)
|
||||
self._stop_live(connection, user_data)
|
||||
|
||||
def _stop_listening(self, connection, sender, errno=0):
|
||||
if not self.listening or sender != self._listening_client[0]:
|
||||
return
|
||||
|
||||
connection.signal_unsubscribe(self._listening_client[1])
|
||||
self._listening_client = None
|
||||
logger.debug(f'Listening stopped on {self.name} for {sender}')
|
||||
|
||||
self.notify('listening')
|
||||
|
||||
status = GLib.Variant.new_int32(errno)
|
||||
self.signal('ListeningStopped', status, dest=sender)
|
||||
self.listening = False
|
||||
self.notify('listening')
|
||||
|
||||
def _start_live(self, connection, sender, args, invocation):
|
||||
if self.live:
|
||||
logger.debug(f'{self} - already in live mode')
|
||||
|
||||
# silently ignore it for the current client but send EBUSY to
|
||||
# other clients
|
||||
if sender != self._listening_client[0]:
|
||||
status = GLib.Variant.new_int32(-errno.EBUSY)
|
||||
self.signal('LiveStopped', status, dest=sender)
|
||||
return
|
||||
|
||||
s = connection.signal_subscribe(sender='org.freedesktop.DBus',
|
||||
interface_name='org.freedesktop.DBus',
|
||||
member='NameOwnerChanged',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
arg0=None,
|
||||
flags=Gio.DBusSignalFlags.NONE,
|
||||
callback=self._on_name_owner_changed_signal_cb,
|
||||
user_data=sender)
|
||||
self._live_client = (sender, s)
|
||||
logger.debug(f'Live mode started on {self.name} for {sender}')
|
||||
|
||||
message = invocation.get_message()
|
||||
fds_list = message.get_unix_fd_list()
|
||||
|
||||
if fds_list is None or fds_list.get_length() != 1:
|
||||
logger.error(f'uhid fds not provided')
|
||||
result = GLib.Variant.new_int32(-errno.EINVAL)
|
||||
invocation.return_value(GLib.Variant.new_tuple(result))
|
||||
return
|
||||
|
||||
fds_list = fds_list.steal_fds()
|
||||
|
||||
self._uhid_fd = fds_list[0]
|
||||
|
||||
self.live = True
|
||||
|
||||
result = GLib.Variant.new_int32(0)
|
||||
invocation.return_value(GLib.Variant.new_tuple(result))
|
||||
|
||||
def _stop_live(self, connection, sender, errno=0):
|
||||
if not self.live or sender != self._live_client[0]:
|
||||
return
|
||||
|
||||
connection.signal_unsubscribe(self._live_client[1])
|
||||
self._live_client = None
|
||||
logger.debug(f'Live mode stopped on {self.name} for {sender}')
|
||||
|
||||
status = GLib.Variant.new_int32(errno)
|
||||
self.signal('LiveStopped', status, dest=sender)
|
||||
self.live = False
|
||||
|
||||
def _json_data(self, args):
|
||||
file_format = args[0]
|
||||
if file_format != Drawing.JSON_FILE_FORMAT_VERSION:
|
||||
logger.info(f'Unsupported file format requested: {file_format}')
|
||||
return ''
|
||||
|
||||
index = args[1]
|
||||
try:
|
||||
drawing = self.drawings[index]
|
||||
except KeyError:
|
||||
return ''
|
||||
else:
|
||||
return drawing.to_json()
|
||||
|
||||
def add_drawing(self, drawing):
|
||||
self.drawings[drawing.timestamp] = drawing
|
||||
ts = GLib.Variant.new_array(GLib.VariantType('t'),
|
||||
[GLib.Variant.new_uint64(t)
|
||||
for t in self.drawings.keys()])
|
||||
self.properties_changed({'DrawingsAvailable': ts})
|
||||
|
||||
def notify_button_press_required(self):
|
||||
logger.debug('Sending ButtonPressRequired signal')
|
||||
self.signal('ButtonPressRequired')
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.objpath} - {self.name}'
|
||||
|
||||
|
||||
class TuhiDBusServer(_TuhiDBus):
|
||||
'''
|
||||
Class for the DBus server.
|
||||
'''
|
||||
__gsignals__ = {
|
||||
'bus-name-acquired':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
'bus-name-lost':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
|
||||
# Signal arguments:
|
||||
# search_stop_handler(status)
|
||||
# to be called when the search process has terminated, with
|
||||
# an integer status code (0 == success, negative errno)
|
||||
'search-start-requested':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
'search-stop-requested':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
_TuhiDBus.__init__(self, None, BASE_PATH, INTF_MANAGER)
|
||||
self._devices = []
|
||||
self._unregistered_devices = {}
|
||||
self._dbus = Gio.bus_own_name(Gio.BusType.SESSION,
|
||||
BUS_NAME,
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
self._bus_aquired,
|
||||
self._bus_name_aquired,
|
||||
self._bus_name_lost)
|
||||
self._is_searching = False
|
||||
self._searching_client = None
|
||||
|
||||
@GObject.Property
|
||||
def is_searching(self):
|
||||
return self._is_searching
|
||||
|
||||
@is_searching.setter
|
||||
def is_searching(self, value):
|
||||
if self._is_searching == value:
|
||||
return
|
||||
|
||||
self._is_searching = value
|
||||
self.properties_changed({'Searching': GLib.Variant.new_boolean(value)})
|
||||
|
||||
def _bus_aquired(self, connection, name):
|
||||
introspection = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION_XML)
|
||||
intf = introspection.lookup_interface(self.interface)
|
||||
self.connection = connection
|
||||
Gio.DBusConnection.register_object(connection,
|
||||
self.objpath,
|
||||
intf,
|
||||
self._method_cb,
|
||||
self._property_read_cb,
|
||||
self._property_write_cb)
|
||||
|
||||
def _bus_name_aquired(self, connection, name):
|
||||
logger.debug('Bus name aquired')
|
||||
self.emit('bus-name-acquired')
|
||||
|
||||
def _bus_name_lost(self, connection, name):
|
||||
logger.error('Bus not available, is there another Tuhi process running?')
|
||||
self.emit('bus-name-lost')
|
||||
|
||||
def _method_cb(self, connection, sender, objpath, interface, methodname, args, invocation):
|
||||
if interface != self.interface:
|
||||
return None
|
||||
|
||||
if methodname == 'StartSearch':
|
||||
self._start_search(connection, sender)
|
||||
invocation.return_value()
|
||||
elif methodname == 'StopSearch':
|
||||
self._stop_search(connection, sender)
|
||||
invocation.return_value()
|
||||
|
||||
def _property_read_cb(self, connection, sender, objpath, interface, propname):
|
||||
if interface != self.interface:
|
||||
return None
|
||||
|
||||
if propname == 'Devices':
|
||||
return GLib.Variant.new_objv([d.objpath for d in self._devices if d.registered])
|
||||
elif propname == 'Searching':
|
||||
return GLib.Variant.new_boolean(self.is_searching)
|
||||
elif propname == 'JSONDataVersions':
|
||||
return GLib.Variant.new_array(GLib.VariantType('u'),
|
||||
[GLib.Variant.new_uint32(Drawing.JSON_FILE_FORMAT_VERSION)])
|
||||
|
||||
return None
|
||||
|
||||
def _property_write_cb(self):
|
||||
pass
|
||||
|
||||
def _start_search(self, connection, sender):
|
||||
if self.is_searching:
|
||||
logger.debug('Already searching')
|
||||
|
||||
# silently ignore it for the current client but send EBUSY to
|
||||
# other clients
|
||||
if sender != self._searching_client[0]:
|
||||
status = GLib.Variant.new_int32(-errno.EBUSY)
|
||||
self.signal('SearchStopped', status)
|
||||
return
|
||||
|
||||
self.is_searching = True
|
||||
|
||||
s = connection.signal_subscribe(sender='org.freedesktop.DBus',
|
||||
interface_name='org.freedesktop.DBus',
|
||||
member='NameOwnerChanged',
|
||||
object_path='/org/freedesktop/DBus',
|
||||
arg0=None,
|
||||
flags=Gio.DBusSignalFlags.NONE,
|
||||
callback=self._on_name_owner_changed_signal_cb,
|
||||
user_data=sender)
|
||||
self._searching_client = (sender, s)
|
||||
|
||||
self.emit('search-start-requested', self._on_search_stop)
|
||||
for d in self._devices:
|
||||
if not d.registered:
|
||||
self._emit_unregistered_signal(d)
|
||||
|
||||
def _on_name_owner_changed_signal_cb(self, connection, sender, object_path,
|
||||
interface_name, node,
|
||||
out_user_data, user_data):
|
||||
name, old_owner, new_owner = out_user_data
|
||||
if name != user_data:
|
||||
return
|
||||
|
||||
self._stop_search(connection, user_data)
|
||||
|
||||
def _stop_search(self, connection, sender):
|
||||
if not self.is_searching or sender != self._searching_client[0]:
|
||||
return
|
||||
|
||||
connection.signal_unsubscribe(self._searching_client[1])
|
||||
self.is_searching = False
|
||||
self.emit('search-stop-requested')
|
||||
|
||||
def _on_search_stop(self, status):
|
||||
'''
|
||||
Called by whoever handles the search-start-requested signal
|
||||
'''
|
||||
logger.debug('Search has stopped')
|
||||
self.is_searching = False
|
||||
status = GLib.Variant.new_int32(status)
|
||||
self.signal('SearchStopped', status, dest=self._searching_client[0])
|
||||
self._searching_client = None
|
||||
|
||||
for dev in self._devices:
|
||||
if dev.registered:
|
||||
continue
|
||||
|
||||
dev.remove()
|
||||
self._devices = [d for d in self._devices if d.registered]
|
||||
|
||||
def cleanup(self):
|
||||
Gio.bus_unown_name(self._dbus)
|
||||
|
||||
def create_device(self, device):
|
||||
dev = TuhiDBusDevice(device, self.connection)
|
||||
dev.connect('notify::registered', self._on_device_registered)
|
||||
self._devices.append(dev)
|
||||
if not device.registered:
|
||||
self._emit_unregistered_signal(dev)
|
||||
return dev
|
||||
|
||||
def _on_device_registered(self, device, param):
|
||||
objpaths = GLib.Variant.new_array(GLib.VariantType('o'),
|
||||
[GLib.Variant.new_object_path(d.objpath)
|
||||
for d in self._devices if d.registered])
|
||||
self.properties_changed({'Devices': objpaths})
|
||||
|
||||
if not device.registered and self._is_searching:
|
||||
self._emit_unregistered_signal(device)
|
||||
|
||||
def _emit_unregistered_signal(self, device):
|
||||
arg = GLib.Variant.new_object_path(device.objpath)
|
||||
self.signal('UnregisteredDevice', arg, dest=self._searching_client[0])
|
169
tuhi/drawing.py
|
@ -1,169 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from gi.repository import GObject
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('tuhi.drawing')
|
||||
|
||||
|
||||
class Point(GObject.Object):
|
||||
def __init__(self, stroke):
|
||||
GObject.Object.__init__(self)
|
||||
self.stroke = stroke
|
||||
self.position = None
|
||||
self.pressure = None
|
||||
|
||||
def to_dict(self):
|
||||
d = {}
|
||||
for key in ['position', 'pressure']:
|
||||
val = getattr(self, key, None)
|
||||
if val is not None:
|
||||
d[key] = val
|
||||
return d
|
||||
|
||||
|
||||
class Stroke(GObject.Object):
|
||||
def __init__(self, drawing):
|
||||
GObject.Object.__init__(self)
|
||||
self.drawing = drawing
|
||||
self.points = []
|
||||
self._position = (0, 0)
|
||||
self._pressure = 0
|
||||
self._is_sealed = False
|
||||
|
||||
@GObject.Property
|
||||
def sealed(self):
|
||||
return self._is_sealed
|
||||
|
||||
def seal(self):
|
||||
self._is_sealed = True
|
||||
|
||||
def new_rel(self, position=None, pressure=None):
|
||||
assert not self._is_sealed
|
||||
|
||||
p = Point(self)
|
||||
if position is not None:
|
||||
x, y = self._position
|
||||
self._position = (x + position[0], y + position[1])
|
||||
p.position = self._position
|
||||
if pressure is not None:
|
||||
self._pressure += pressure
|
||||
p.pressure = self._pressure
|
||||
|
||||
self.points.append(p)
|
||||
|
||||
def new_abs(self, position=None, pressure=None):
|
||||
assert not self._is_sealed
|
||||
|
||||
p = Point(self)
|
||||
if position is not None:
|
||||
self._position = position
|
||||
p.position = position
|
||||
if pressure is not None:
|
||||
self._pressure = pressure
|
||||
p.pressure = pressure
|
||||
|
||||
self.points.append(p)
|
||||
|
||||
def to_dict(self):
|
||||
d = {}
|
||||
d['points'] = [p.to_dict() for p in self.points]
|
||||
return d
|
||||
|
||||
|
||||
class Drawing(GObject.Object):
|
||||
'''
|
||||
Abstracts a drawing. The drawing is composed Strokes, each of which has
|
||||
Points.
|
||||
'''
|
||||
JSON_FILE_FORMAT_VERSION = 1
|
||||
|
||||
def __init__(self, name, dimensions, timestamp):
|
||||
GObject.Object.__init__(self)
|
||||
self.name = name
|
||||
self.dimensions = dimensions
|
||||
self.timestamp = timestamp # unix seconds
|
||||
self.strokes = []
|
||||
self._current_stroke = -1
|
||||
self.session_id = 'unset'
|
||||
|
||||
def seal(self):
|
||||
# Drop empty strokes
|
||||
for s in self.strokes:
|
||||
s.seal()
|
||||
self.strokes = [s for s in self.strokes if s.points]
|
||||
|
||||
# The way we're building drawings, we don't need to change the current
|
||||
# stroke at runtime, so this is read-ony
|
||||
@GObject.Property
|
||||
def current_stroke(self):
|
||||
if self._current_stroke < 0:
|
||||
return None
|
||||
|
||||
s = self.strokes[self._current_stroke]
|
||||
return s if not s.sealed else None
|
||||
|
||||
def new_stroke(self):
|
||||
'''
|
||||
Create a new stroke and make it the current stroke
|
||||
'''
|
||||
if self.current_stroke is not None:
|
||||
self.current_stroke.seal()
|
||||
|
||||
s = Stroke(self)
|
||||
self.strokes.append(s)
|
||||
self._current_stroke += 1
|
||||
return s
|
||||
|
||||
def to_json(self):
|
||||
json_data = {
|
||||
'version': self.JSON_FILE_FORMAT_VERSION,
|
||||
'devicename': self.name,
|
||||
'sessionid': self.session_id,
|
||||
'dimensions': list(self.dimensions),
|
||||
'timestamp': self.timestamp,
|
||||
'strokes': [s.to_dict() for s in self.strokes]
|
||||
}
|
||||
return json.dumps(json_data, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path):
|
||||
d = None
|
||||
with open(path, 'r') as fp:
|
||||
json_data = json.load(fp)
|
||||
|
||||
try:
|
||||
if json_data['version'] != cls.JSON_FILE_FORMAT_VERSION:
|
||||
logger.error(f'{path}: Invalid file format version')
|
||||
return d
|
||||
|
||||
name = json_data['devicename']
|
||||
dimensions = tuple(json_data['dimensions'])
|
||||
timestamp = json_data['timestamp']
|
||||
d = Drawing(name, dimensions, timestamp)
|
||||
|
||||
for s in json_data['strokes']:
|
||||
stroke = d.new_stroke()
|
||||
for p in s['points']:
|
||||
position = p.get('position', None)
|
||||
pressure = p.get('pressure', None)
|
||||
stroke.new_abs(position, pressure)
|
||||
except KeyError:
|
||||
logger.error(f'{path}: failed to parse json file')
|
||||
|
||||
return d
|
||||
|
||||
def __repr__(self):
|
||||
return f'Drawing from {self.name} at {self.timestamp}, {len(self.strokes)} strokes'
|
|
@ -1,50 +0,0 @@
|
|||
TuhiGui
|
||||
=======
|
||||
|
||||
Tuhi is a GUI to the Tuhi DBus daemon that connects to and fetches the data
|
||||
from the Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). The data
|
||||
is converted to SVG and users can save it on disk.
|
||||
|
||||
For more info about Tuhi see: https://github.com/tuhiproject/tuhi
|
||||
|
||||
|
||||
Building TuhiGUI
|
||||
----------------
|
||||
|
||||
```
|
||||
$> git clone http://github.com/tuhiproject/tuhigui
|
||||
$> cd tuhigui
|
||||
$> meson builddir
|
||||
$> ninja -C builddir
|
||||
$> ./builddir/tuhigui.devel
|
||||
```
|
||||
|
||||
TuhiGui requires Python v3.6 or above.
|
||||
|
||||
Install TuhiGUI
|
||||
---------------
|
||||
|
||||
```
|
||||
$> git clone http://github.com/tuhiproject/tuhigui
|
||||
$> cd tuhigui
|
||||
$> meson builddir
|
||||
$> ninja -C builddir install
|
||||
$> tuhigui
|
||||
```
|
||||
|
||||
TuhiGui requires Python v3.6 or above.
|
||||
|
||||
Flatpak
|
||||
-------
|
||||
|
||||
```
|
||||
$> git clone http://github.com/tuhiproject/tuhigui
|
||||
$> cd tuhigui
|
||||
$> flatpak-builder flatpak_builddir org.freedesktop.TuhiGui.json --install --user --force-clean
|
||||
$> flatpak run org.freedesktop.TuhiGui
|
||||
```
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
TuhiGui is licensed under the GPLv2 or later.
|
|
@ -1,12 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
|
@ -1,164 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from .window import MainWindow
|
||||
from .config import Config
|
||||
from pathlib import Path
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import xdg.BaseDirectory
|
||||
import gi
|
||||
gi.require_version("Gio", "2.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gio, GLib, Gtk, Gdk # NOQA
|
||||
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(name)s: %(message)s',
|
||||
level=logging.INFO,
|
||||
datefmt='%H:%M:%S')
|
||||
logger = logging.getLogger('tuhi.gui')
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi')
|
||||
|
||||
|
||||
class Application(Gtk.Application):
|
||||
def __init__(self):
|
||||
super().__init__(application_id='org.freedesktop.Tuhi',
|
||||
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
||||
GLib.set_application_name('Tuhi')
|
||||
self.add_main_option('config-dir', 0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
'path to configuration directory',
|
||||
'/path/to/config-dir')
|
||||
self.add_main_option('verbose', 0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
'enable verbose output')
|
||||
# unused, just here to have option compatibility with the tuhi
|
||||
# server but we could add some GUI feedback here
|
||||
self.add_main_option('peek', 0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
'download first drawing only but do not remove it from the device')
|
||||
|
||||
self.set_accels_for_action('app.quit', ['<Ctrl>Q'])
|
||||
|
||||
self._tuhi = None
|
||||
|
||||
def do_startup(self):
|
||||
Gtk.Application.do_startup(self)
|
||||
self._build_app_menu()
|
||||
|
||||
def do_activate(self):
|
||||
window = MainWindow(application=self)
|
||||
window.present()
|
||||
|
||||
def do_command_line(self, command_line):
|
||||
options = command_line.get_options_dict()
|
||||
# convert GVariantDict -> GVariant -> dict
|
||||
options = options.end().unpack()
|
||||
|
||||
try:
|
||||
Config.set_base_path(options['config-dir'])
|
||||
except KeyError:
|
||||
Config.set_base_path(DEFAULT_CONFIG_PATH)
|
||||
|
||||
if 'verbose' in options:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
self.activate()
|
||||
return 0
|
||||
|
||||
def _build_app_menu(self):
|
||||
actions = [('about', self._about),
|
||||
('quit', self._quit),
|
||||
('help', self._help)]
|
||||
for (name, callback) in actions:
|
||||
action = Gio.SimpleAction.new(name, None)
|
||||
action.connect('activate', callback)
|
||||
self.add_action(action)
|
||||
|
||||
def _about(self, action, param):
|
||||
builder = Gtk.Builder().new_from_resource('/org/freedesktop/Tuhi/AboutDialog.ui')
|
||||
about = builder.get_object('about_dialog')
|
||||
about.set_transient_for(self.get_active_window())
|
||||
about.connect('response', lambda about, param: about.destroy())
|
||||
about.show()
|
||||
|
||||
def _quit(self, action, param):
|
||||
windows = self.get_windows()
|
||||
for window in windows:
|
||||
window.destroy()
|
||||
|
||||
def _help(self, action, param):
|
||||
import time
|
||||
Gtk.show_uri(None, 'https://github.com/tuhiproject/tuhi/wiki', time.time())
|
||||
|
||||
|
||||
def install_excepthook():
|
||||
old_hook = sys.excepthook
|
||||
|
||||
def new_hook(etype, evalue, etb):
|
||||
old_hook(etype, evalue, etb)
|
||||
while Gtk.main_level():
|
||||
Gtk.main_quit()
|
||||
sys.exit()
|
||||
sys.excepthook = new_hook
|
||||
|
||||
|
||||
def gtk_style():
|
||||
css = b"""
|
||||
flowboxchild:selected {
|
||||
background-color: white;
|
||||
}
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
.bg-paper {
|
||||
border-radius: 5px;
|
||||
background-color: #ebe9e8;
|
||||
}
|
||||
.drawing {
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
}
|
||||
"""
|
||||
|
||||
screen = Gdk.Screen.get_default()
|
||||
if screen is None:
|
||||
print('Error: Unable to connect to screen. Make sure DISPLAY or WAYLAND_DISPLAY are set', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
style_provider = Gtk.CssProvider()
|
||||
style_provider.load_from_data(css)
|
||||
Gtk.StyleContext.add_provider_for_screen(screen,
|
||||
style_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if sys.version_info < (3, 6):
|
||||
sys.exit('Python 3.6 or later required')
|
||||
|
||||
import gettext
|
||||
import locale
|
||||
import signal
|
||||
|
||||
install_excepthook()
|
||||
gtk_style()
|
||||
|
||||
locale.textdomain('tuhi')
|
||||
gettext.textdomain('tuhi')
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
exit_status = Application().run(argv)
|
||||
sys.exit(exit_status)
|
|
@ -1,134 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
|
||||
from gi.repository import GObject
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger('tuhi.gui.config')
|
||||
|
||||
|
||||
class Config(GObject.Object):
|
||||
_instance = None
|
||||
_base_path = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(Config, cls).__new__(cls)
|
||||
|
||||
self = cls._instance
|
||||
self.__init__() # for GObject to initialize
|
||||
self.path = Path(self._base_path, 'tuhigui.ini')
|
||||
self.base_path = self._base_path
|
||||
self.config = configparser.ConfigParser()
|
||||
# Don't lowercase options
|
||||
self.config.optionxform = str
|
||||
self._drawings = []
|
||||
self._load()
|
||||
self._load_cached_drawings()
|
||||
return cls._instance
|
||||
|
||||
def _load(self):
|
||||
if not self.path.exists():
|
||||
return
|
||||
|
||||
logger.debug(f'configuration found')
|
||||
self.config.read(self.path)
|
||||
|
||||
def _load_cached_drawings(self):
|
||||
if not self.base_path.exists():
|
||||
return
|
||||
|
||||
for filename in self.base_path.glob('*.json'):
|
||||
with open(filename) as fd:
|
||||
self._drawings.append(json.load(fd))
|
||||
self.notify('drawings')
|
||||
|
||||
def _write(self):
|
||||
self.path.resolve().parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.path, 'w') as fd:
|
||||
self.config.write(fd)
|
||||
|
||||
def _add_key(self, section, key, value):
|
||||
if section not in self.config:
|
||||
self.config[section] = {}
|
||||
self.config[section][key] = value
|
||||
self._write()
|
||||
|
||||
@GObject.Property
|
||||
def orientation(self):
|
||||
try:
|
||||
return self.config['Device']['Orientation']
|
||||
except KeyError:
|
||||
return 'landscape'
|
||||
|
||||
@orientation.setter
|
||||
def orientation(self, orientation):
|
||||
assert(orientation in ['landscape', 'portrait'])
|
||||
self._add_key('Device', 'Orientation', orientation)
|
||||
|
||||
@GObject.Property
|
||||
def drawings(self):
|
||||
return self._drawings
|
||||
|
||||
def add_drawing(self, timestamp, json_string):
|
||||
'''Add a drawing JSON with the given timestamp to the backend
|
||||
storage. This will update self.drawings.'''
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
path = Path(self.base_path, f'{timestamp}.json')
|
||||
if path.exists():
|
||||
return
|
||||
|
||||
# Tuhi may still cache files we've 'deleted' locally. These need to
|
||||
# be ignored because they're still technically deleted.
|
||||
deleted = Path(self.base_path, f'{timestamp}.json.deleted')
|
||||
if deleted.exists():
|
||||
return
|
||||
|
||||
with open(path, 'w') as fd:
|
||||
fd.write(json_string)
|
||||
|
||||
self._drawings.append(json.loads(json_string))
|
||||
self.notify('drawings')
|
||||
|
||||
def delete_drawing(self, timestamp):
|
||||
# We don't delete json files immediately, we just rename them
|
||||
# so we can resurrect them in the future if need be.
|
||||
path = Path(self.base_path, f'{timestamp}.json')
|
||||
target = Path(self.base_path, f'{timestamp}.json.deleted')
|
||||
path.rename(target)
|
||||
|
||||
self._drawings = [d for d in self._drawings if d['timestamp'] != timestamp]
|
||||
self.notify('drawings')
|
||||
|
||||
def undelete_drawing(self, timestamp):
|
||||
path = Path(self.base_path, f'{timestamp}.json')
|
||||
target = Path(self.base_path, f'{timestamp}.json.deleted')
|
||||
target.rename(path)
|
||||
|
||||
with open(path) as fd:
|
||||
self._drawings.append(json.load(fd))
|
||||
self.notify('drawings')
|
||||
|
||||
@classmethod
|
||||
def set_base_path(cls, path):
|
||||
if cls._instance is not None:
|
||||
logger.error('Trying to set config base path but we already have the singleton object')
|
||||
return
|
||||
|
||||
cls._base_path = Path(path)
|
|
@ -1,162 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from gettext import gettext as _
|
||||
|
||||
import xdg.BaseDirectory
|
||||
import os
|
||||
from pathlib import Path
|
||||
from .config import Config
|
||||
from tuhi.svg import JsonSvg
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GObject, Gtk, GdkPixbuf, Gdk # NOQA
|
||||
|
||||
|
||||
DATA_PATH = Path(xdg.BaseDirectory.xdg_cache_home, 'tuhi', 'svg')
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/Drawing.ui')
|
||||
class Drawing(Gtk.EventBox):
|
||||
__gtype_name__ = "Drawing"
|
||||
|
||||
box_toolbar = Gtk.Template.Child()
|
||||
image_svg = Gtk.Template.Child()
|
||||
btn_rotate_left = Gtk.Template.Child()
|
||||
btn_rotate_right = Gtk.Template.Child()
|
||||
|
||||
def __init__(self, json_data, zoom, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.orientation = Config().orientation
|
||||
Config().connect('notify::orientation', self._on_orientation_changed)
|
||||
DATA_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.json_data = json_data
|
||||
self._zoom = zoom
|
||||
self.process_svg() # sets self.svg
|
||||
self.redraw()
|
||||
|
||||
self.timestamp = self.svg.timestamp
|
||||
self.box_toolbar.set_opacity(0)
|
||||
|
||||
def _on_orientation_changed(self, config, pspec):
|
||||
self.orientation = config.orientation
|
||||
self.process_svg()
|
||||
self.redraw()
|
||||
|
||||
def process_svg(self):
|
||||
path = os.fspath(Path(DATA_PATH, f'{self.json_data["timestamp"]}.svg'))
|
||||
self.svg = JsonSvg(self.json_data, self.orientation, path)
|
||||
width, height = -1, -1
|
||||
if 'portrait' in self.orientation:
|
||||
height = 1000
|
||||
else:
|
||||
width = 1000
|
||||
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename=self.svg.filename,
|
||||
width=width,
|
||||
height=height,
|
||||
preserve_aspect_ratio=True)
|
||||
|
||||
def redraw(self):
|
||||
ratio = self.pixbuf.get_height() / self.pixbuf.get_width()
|
||||
base = 250 + self.zoom * 50
|
||||
if 'portrait' in self.orientation:
|
||||
width = base / ratio
|
||||
height = base
|
||||
else:
|
||||
width = base
|
||||
height = base * ratio
|
||||
pb = self.pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||
self.image_svg.set_from_pixbuf(pb)
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return "drawing"
|
||||
|
||||
@GObject.Property
|
||||
def zoom(self):
|
||||
return self._zoom
|
||||
|
||||
@zoom.setter
|
||||
def zoom(self, zoom):
|
||||
if zoom == self._zoom:
|
||||
return
|
||||
self._zoom = zoom
|
||||
self.redraw()
|
||||
|
||||
@Gtk.Template.Callback('_on_download_button_clicked')
|
||||
def _on_download_button_clicked(self, button):
|
||||
dialog = Gtk.FileChooserDialog(_('Please choose a file'),
|
||||
None,
|
||||
Gtk.FileChooserAction.SAVE,
|
||||
(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_SAVE, Gtk.ResponseType.OK))
|
||||
|
||||
dialog.set_do_overwrite_confirmation(True)
|
||||
# Translators: the default filename to save to
|
||||
dialog.set_current_name(_('untitled.svg'))
|
||||
|
||||
filter_any = Gtk.FileFilter()
|
||||
# Translators: filter name to show all/any files
|
||||
filter_any.set_name(_('Any files'))
|
||||
filter_any.add_pattern('*')
|
||||
filter_svg = Gtk.FileFilter()
|
||||
# Translators: filter to show svg files only
|
||||
filter_svg.set_name(_('SVG files'))
|
||||
filter_svg.add_pattern('*.svg')
|
||||
dialog.add_filter(filter_svg)
|
||||
dialog.add_filter(filter_any)
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
import shutil
|
||||
|
||||
# regenerate the SVG based on the current rotation.
|
||||
# where we used the orientation buttons, we haven't updated the
|
||||
# file itself.
|
||||
self.process_svg()
|
||||
|
||||
file = dialog.get_filename()
|
||||
shutil.copyfile(self.svg.filename, file)
|
||||
# FIXME: error handling
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
@Gtk.Template.Callback('_on_delete_button_clicked')
|
||||
def _on_delete_button_clicked(self, button):
|
||||
Config().delete_drawing(self.timestamp)
|
||||
|
||||
@Gtk.Template.Callback('_on_rotate_button_clicked')
|
||||
def _on_rotate_button_clicked(self, button):
|
||||
if button == self.btn_rotate_left:
|
||||
self.pixbuf = self.pixbuf.rotate_simple(GdkPixbuf.PixbufRotation.COUNTERCLOCKWISE)
|
||||
advance = 1
|
||||
else:
|
||||
self.pixbuf = self.pixbuf.rotate_simple(GdkPixbuf.PixbufRotation.CLOCKWISE)
|
||||
advance = 3
|
||||
|
||||
orientations = ['portrait', 'landscape', 'reverse-portrait', 'reverse-landscape'] * 3
|
||||
o = orientations[orientations.index(self.orientation) + advance]
|
||||
self.orientation = o
|
||||
self.redraw()
|
||||
|
||||
@Gtk.Template.Callback('_on_enter')
|
||||
def _on_enter(self, *args):
|
||||
self.box_toolbar.set_opacity(100)
|
||||
|
||||
@Gtk.Template.Callback('_on_leave')
|
||||
def _on_leave(self, drawing, event):
|
||||
if event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return
|
||||
self.box_toolbar.set_opacity(0)
|
|
@ -1,198 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from .drawing import Drawing
|
||||
from .config import Config
|
||||
|
||||
import time
|
||||
import logging
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GObject, Gtk # NOQA
|
||||
|
||||
logger = logging.getLogger('tuhi.gui.drawingperspective')
|
||||
|
||||
|
||||
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/Flowbox.ui")
|
||||
class Flowbox(Gtk.Box):
|
||||
__gtype_name__ = "Flowbox"
|
||||
|
||||
label_date = Gtk.Template.Child()
|
||||
flowbox_drawings = Gtk.Template.Child()
|
||||
|
||||
def __init__(self, timestruct, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.time = timestruct
|
||||
self.label_date.set_text(time.strftime('%B %Y', self.time))
|
||||
|
||||
def insert(self, drawing):
|
||||
# We don't know which order we get drawings from the device, so
|
||||
# let's do a sorted insert here
|
||||
index = 0
|
||||
child = self.flowbox_drawings.get_child_at_index(index)
|
||||
while child is not None:
|
||||
if child.get_child().timestamp < drawing.timestamp:
|
||||
break
|
||||
index += 1
|
||||
child = self.flowbox_drawings.get_child_at_index(index)
|
||||
|
||||
self.flowbox_drawings.insert(drawing, index)
|
||||
|
||||
def delete(self, drawing):
|
||||
def delete_matching_child(child, drawing):
|
||||
if child.get_child() == drawing:
|
||||
self.flowbox_drawings.remove(child)
|
||||
self.flowbox_drawings.foreach(delete_matching_child, drawing)
|
||||
|
||||
@GObject.Property
|
||||
def is_empty(self):
|
||||
return not self.flowbox_drawings.get_children()
|
||||
|
||||
|
||||
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/DrawingPerspective.ui")
|
||||
class DrawingPerspective(Gtk.Stack):
|
||||
__gtype_name__ = "DrawingPerspective"
|
||||
|
||||
viewport = Gtk.Template.Child()
|
||||
overlay_undo = Gtk.Template.Child()
|
||||
notification_delete_undo = Gtk.Template.Child()
|
||||
notification_delete_close = Gtk.Template.Child()
|
||||
box_all_drawings = Gtk.Template.Child()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.known_drawings = {} # type {timestamp: Drawing()}
|
||||
self.flowboxes = {}
|
||||
self._zoom = 0
|
||||
self._want_listen = True
|
||||
|
||||
def _cache_drawings(self, device, pspec):
|
||||
# The config backend filters duplicates anyway, so don't care here
|
||||
for ts in self.device.drawings_available:
|
||||
json_string = self.device.json(ts)
|
||||
Config().add_drawing(ts, json_string)
|
||||
|
||||
def _update_drawings(self, config, pspec):
|
||||
def _hash(drawing):
|
||||
return time.strftime('%Y%m', time.gmtime(drawing.timestamp))
|
||||
|
||||
for js in sorted(config.drawings, reverse=True, key=lambda j: j['timestamp']):
|
||||
ts = js['timestamp']
|
||||
if ts in self.known_drawings:
|
||||
continue
|
||||
|
||||
drawing = Drawing(js, self._zoom)
|
||||
self.known_drawings[ts] = drawing
|
||||
|
||||
# Now pick the right monthly flowbox to insert into
|
||||
key = _hash(drawing)
|
||||
try:
|
||||
fb = self.flowboxes[key]
|
||||
except KeyError:
|
||||
fb = Flowbox(time.gmtime(drawing.timestamp))
|
||||
self.flowboxes[key] = fb
|
||||
self.box_all_drawings.add(fb)
|
||||
finally:
|
||||
fb.insert(drawing)
|
||||
|
||||
# Remove deleted drawings
|
||||
deleted = [ts for ts in self.known_drawings if ts not in [js['timestamp'] for js in config.drawings]]
|
||||
for ts in deleted:
|
||||
drawing = self.known_drawings[ts]
|
||||
fb = self.flowboxes[_hash(drawing)]
|
||||
fb.delete(drawing)
|
||||
if fb.is_empty:
|
||||
del self.flowboxes[_hash(drawing)]
|
||||
self.box_all_drawings.remove(fb)
|
||||
del self.known_drawings[ts]
|
||||
self.notification_delete_undo.deleted_drawing = drawing.timestamp
|
||||
self.overlay_undo.set_reveal_child(True)
|
||||
|
||||
@GObject.Property
|
||||
def device(self):
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, device):
|
||||
self._device = device
|
||||
|
||||
self._signals = []
|
||||
sig = device.connect('notify::connected', self._on_connected)
|
||||
self._signals.append(sig)
|
||||
sig = device.connect('notify::listening', self._on_listening_stopped)
|
||||
self._signals.append(sig)
|
||||
sig = device.connect('device-error', self._on_device_error)
|
||||
self._signals.append(sig)
|
||||
|
||||
# This is a bit convoluted. We need to cache all drawings
|
||||
# because Tuhi doesn't have guaranteed storage. So any json that
|
||||
# comes in from Tuhi, we pass to our config backend to save
|
||||
# somewhere.
|
||||
# The config backend adds the json file and emits a notify for the
|
||||
# json itself (once cached) that we then actually use for SVG
|
||||
# generation.
|
||||
device.connect('notify::drawings-available', self._cache_drawings)
|
||||
Config().connect('notify::drawings', self._update_drawings)
|
||||
|
||||
self._update_drawings(Config(), None)
|
||||
|
||||
# We always want to sync on startup
|
||||
logger.debug(f'{device.name} - starting to listen')
|
||||
device.start_listening()
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return "drawing_perspective"
|
||||
|
||||
@GObject.Property
|
||||
def zoom(self):
|
||||
return self._zoom
|
||||
|
||||
@zoom.setter
|
||||
def zoom(self, zoom):
|
||||
if zoom == self._zoom:
|
||||
return
|
||||
|
||||
self._zoom = zoom
|
||||
for ts, drawing in self.known_drawings.items():
|
||||
drawing.zoom = zoom
|
||||
|
||||
def _on_connected(self, device, pspec):
|
||||
# Turns out we don't really care about whether the device is
|
||||
# connected or not, it has little effect on how we work here
|
||||
pass
|
||||
|
||||
def _on_listening_stopped(self, device, pspec):
|
||||
if not device.listening and self._want_listen:
|
||||
logger.debug(f'{device.name} - listening stopped, restarting')
|
||||
# We never want to stop listening
|
||||
device.start_listening()
|
||||
|
||||
def _on_device_error(self, device, error):
|
||||
import errno
|
||||
if error == -errno.EACCES:
|
||||
# No point to keep getting notified
|
||||
for sig in self._signals:
|
||||
device.disconnect(sig)
|
||||
self._signals = []
|
||||
self._want_listen = False
|
||||
|
||||
@Gtk.Template.Callback('_on_undo_close_clicked')
|
||||
def _on_undo_close_clicked(self, button):
|
||||
self.overlay_undo.set_reveal_child(False)
|
||||
|
||||
@Gtk.Template.Callback('_on_undo_clicked')
|
||||
def _on_undo_clicked(self, button):
|
||||
Config().undelete_drawing(button.deleted_drawing)
|
||||
self.overlay_undo.set_reveal_child(False)
|
|
@ -1,239 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from .drawingperspective import DrawingPerspective
|
||||
from .config import Config
|
||||
from tuhi.dbusclient import TuhiDBusClientManager
|
||||
|
||||
from gettext import gettext as _
|
||||
import logging
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gio, GLib, GObject # NOQA
|
||||
|
||||
logger = logging.getLogger('tuhi.gui.window')
|
||||
|
||||
|
||||
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/ErrorPerspective.ui")
|
||||
class ErrorPerspective(Gtk.Box):
|
||||
'''
|
||||
The page loaded when we cannot connect to the Tuhi DBus server.
|
||||
'''
|
||||
__gtype_name__ = "ErrorPerspective"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return "error_perspective"
|
||||
|
||||
|
||||
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/SetupPerspective.ui")
|
||||
class SetupDialog(Gtk.Dialog):
|
||||
'''
|
||||
The setup dialog when we don't yet have a registered device with Tuhi.
|
||||
'''
|
||||
__gtype_name__ = "SetupDialog"
|
||||
__gsignals__ = {
|
||||
'new-device':
|
||||
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
|
||||
}
|
||||
|
||||
stack = Gtk.Template.Child()
|
||||
label_devicename_p1 = Gtk.Template.Child()
|
||||
btn_quit = Gtk.Template.Child()
|
||||
|
||||
def __init__(self, tuhi, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._tuhi = tuhi
|
||||
self._sig = tuhi.connect('unregistered-device', self._on_unregistered_device)
|
||||
tuhi.start_search()
|
||||
self.device = None
|
||||
|
||||
def _on_unregistered_device(self, tuhi, device):
|
||||
tuhi.disconnect(self._sig)
|
||||
|
||||
self.label_devicename_p1.set_text(_(f'Connecting to {device.name}'))
|
||||
self.stack.set_visible_child_name('page1')
|
||||
self._sig = device.connect('button-press-required', self._on_button_press_required)
|
||||
device.register()
|
||||
|
||||
def _on_button_press_required(self, device):
|
||||
device.disconnect(self._sig)
|
||||
|
||||
self.stack.set_visible_child_name('page2')
|
||||
self._sig = device.connect('registered', self._on_registered)
|
||||
|
||||
def _on_registered(self, device):
|
||||
device.disconnect(self._sig)
|
||||
self.device = device
|
||||
self.response(Gtk.ResponseType.OK)
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return "setup_dialog"
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/MainWindow.ui')
|
||||
class MainWindow(Gtk.ApplicationWindow):
|
||||
__gtype_name__ = 'MainWindow'
|
||||
|
||||
stack_perspectives = Gtk.Template.Child()
|
||||
headerbar = Gtk.Template.Child()
|
||||
menubutton1 = Gtk.Template.Child()
|
||||
spinner_sync = Gtk.Template.Child()
|
||||
image_battery = Gtk.Template.Child()
|
||||
image_missing_tablet = Gtk.Template.Child()
|
||||
overlay_reauth = Gtk.Template.Child()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.maximize()
|
||||
self._tuhi = TuhiDBusClientManager()
|
||||
|
||||
action = Gio.SimpleAction.new_stateful('orientation', GLib.VariantType('s'),
|
||||
GLib.Variant('s', 'landscape'))
|
||||
action.connect('activate', self._on_orientation_changed)
|
||||
action.set_state(GLib.Variant.new_string(Config().orientation))
|
||||
self.add_action(action)
|
||||
|
||||
builder = Gtk.Builder.new_from_resource('/org/freedesktop/Tuhi/ui/AppMenu.ui')
|
||||
menu = builder.get_object("primary-menu")
|
||||
self.menubutton1.set_menu_model(menu)
|
||||
|
||||
ep = ErrorPerspective()
|
||||
self._add_perspective(ep)
|
||||
self.stack_perspectives.set_visible_child_name(ep.name)
|
||||
|
||||
self._signals = []
|
||||
|
||||
# the dbus bindings need more async...
|
||||
if not self._tuhi.online:
|
||||
self._tuhi.connect('notify::online', self._on_dbus_online)
|
||||
else:
|
||||
self._on_dbus_online()
|
||||
|
||||
def _register_device(self):
|
||||
dialog = SetupDialog(self._tuhi)
|
||||
dialog.set_transient_for(self)
|
||||
dialog.connect('response', self._on_setup_dialog_closed)
|
||||
dialog.show()
|
||||
|
||||
def _on_dbus_online(self, *args, **kwargs):
|
||||
logger.debug('dbus is online')
|
||||
|
||||
dp = DrawingPerspective()
|
||||
self._add_perspective(dp)
|
||||
self.headerbar.set_title(f'Tuhi')
|
||||
self.stack_perspectives.set_visible_child_name(dp.name)
|
||||
|
||||
if not self._tuhi.devices:
|
||||
self._register_device()
|
||||
else:
|
||||
device = self._tuhi.devices[0]
|
||||
self._init_device(device)
|
||||
dp.device = device
|
||||
self.headerbar.set_title(f'Tuhi - {dp.device.name}')
|
||||
|
||||
def _init_device(self, device):
|
||||
sig = device.connect('notify::sync-state', self._on_sync_state)
|
||||
self._signals.append(sig)
|
||||
sig = device.connect('notify::battery-percent', self._on_battery_changed)
|
||||
self._signals.append(sig)
|
||||
sig = device.connect('notify::battery-state', self._on_battery_changed)
|
||||
self._signals.append(sig)
|
||||
sig = device.connect('device-error', self._on_device_error)
|
||||
self._signals.append(sig)
|
||||
self._on_battery_changed(device, None)
|
||||
|
||||
def _on_battery_changed(self, device, pspec):
|
||||
if device.battery_percent > 80:
|
||||
fill = 'full'
|
||||
elif device.battery_percent > 40:
|
||||
fill = 'good'
|
||||
elif device.battery_percent > 10:
|
||||
fill = 'low'
|
||||
else:
|
||||
fill = 'caution'
|
||||
|
||||
if device.battery_state == 1:
|
||||
state = '-charging'
|
||||
elif device.battery_state == 0: # unknown
|
||||
fill = 'missing'
|
||||
state = ''
|
||||
else:
|
||||
state = ''
|
||||
batt_icon_name = f'battery-{fill}{state}-symbolic'
|
||||
_, isize = self.image_battery.get_icon_name()
|
||||
self.image_battery.set_from_icon_name(batt_icon_name, isize)
|
||||
self.image_battery.set_tooltip_text(f'{device.battery_percent}%')
|
||||
|
||||
def _on_sync_state(self, device, pspec):
|
||||
self.image_missing_tablet.set_visible(False)
|
||||
if device.sync_state:
|
||||
self.spinner_sync.start()
|
||||
else:
|
||||
self.spinner_sync.stop()
|
||||
|
||||
def _on_setup_dialog_closed(self, dialog, response):
|
||||
self.overlay_reauth.set_reveal_child(False)
|
||||
device = dialog.device
|
||||
dialog.destroy()
|
||||
|
||||
if response != Gtk.ResponseType.OK or device is None:
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
logger.debug('device was registered')
|
||||
self.headerbar.set_title(f'Tuhi - {device.name}')
|
||||
|
||||
dp = self._get_child('drawing_perspective')
|
||||
dp.device = device
|
||||
self._init_device(device)
|
||||
self.stack_perspectives.set_visible_child_name(dp.name)
|
||||
|
||||
def _on_device_error(self, device, err):
|
||||
import errno
|
||||
logger.info(f'Device error: {err}')
|
||||
if err == -errno.EACCES:
|
||||
self.overlay_reauth.set_reveal_child(True)
|
||||
# No point to keep getting notified, it won't be able to
|
||||
# register.
|
||||
for sig in self._signals:
|
||||
device.disconnect(sig)
|
||||
self._signals = []
|
||||
|
||||
def _add_perspective(self, perspective):
|
||||
self.stack_perspectives.add_named(perspective, perspective.name)
|
||||
|
||||
def _get_child(self, name):
|
||||
return self.stack_perspectives.get_child_by_name(name)
|
||||
|
||||
def _on_reconnect_tuhi(self, tuhi):
|
||||
self._tuhi = tuhi
|
||||
|
||||
def _on_orientation_changed(self, action, label):
|
||||
action.set_state(label)
|
||||
Config().orientation = label.get_string() # this is a GVariant
|
||||
|
||||
@Gtk.Template.Callback('_on_zoom_changed')
|
||||
def _on_zoom_changed(self, adjustment):
|
||||
dp = self._get_child('drawing_perspective')
|
||||
dp.zoom = int(adjustment.get_value())
|
||||
|
||||
@Gtk.Template.Callback('_on_reauth_clicked')
|
||||
def _on_reauth_clicked(self, button):
|
||||
self._register_device()
|
1933
tuhi/protocol.py
82
tuhi/svg.py
|
@ -1,82 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
from gi.repository import GObject
|
||||
|
||||
import svgwrite
|
||||
from svgwrite import mm
|
||||
|
||||
|
||||
class JsonSvg(GObject.Object):
|
||||
def __init__(self, json, orientation, filename, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.json = json
|
||||
self.timestamp = json['timestamp']
|
||||
self.filename = filename
|
||||
self.orientation = orientation.lower()
|
||||
self._convert()
|
||||
|
||||
def _convert(self):
|
||||
js = self.json
|
||||
dimensions = js['dimensions']
|
||||
if dimensions == [0, 0]:
|
||||
width, height = 100, 100
|
||||
else:
|
||||
# Original dimensions are too big for SVG Standard
|
||||
# so we normalize them
|
||||
width, height = dimensions[0] / 1000, dimensions[1] / 1000
|
||||
|
||||
if self.orientation in ['portrait', 'reverse-portrait']:
|
||||
size = (height * mm, width * mm)
|
||||
else:
|
||||
size = (width * mm, height * mm)
|
||||
svg = svgwrite.Drawing(filename=self.filename, size=size)
|
||||
g = svgwrite.container.Group(id='layer0')
|
||||
for stroke_num, s in enumerate(js['strokes']):
|
||||
|
||||
points_with_sk_width = []
|
||||
|
||||
for p in s['points']:
|
||||
|
||||
x, y = p['position']
|
||||
# Normalize coordinates too
|
||||
x, y = x / 1000, y / 1000
|
||||
|
||||
if self.orientation == 'reverse-portrait':
|
||||
x, y = y, width - x
|
||||
elif self.orientation == 'portrait':
|
||||
x, y = height - y, x
|
||||
elif self.orientation == 'reverse-landscape':
|
||||
x, y = width - x, height - y
|
||||
|
||||
# Pressure normalized range is [0, 0xffff]
|
||||
delta = (p['pressure'] - 0x8000) / 0x8000
|
||||
stroke_width = 0.4 + 0.20 * delta
|
||||
points_with_sk_width.append((x, y, stroke_width))
|
||||
|
||||
lines = svgwrite.container.Group(id=f'strokes_{stroke_num}', stroke='black')
|
||||
for i, (x, y, stroke_width) in enumerate(points_with_sk_width):
|
||||
if i != 0:
|
||||
xp, yp, stroke_width_p = points_with_sk_width[i - 1]
|
||||
lines.add(
|
||||
svg.line(
|
||||
start=(xp * mm, yp * mm),
|
||||
end=(x * mm, y * mm),
|
||||
stroke_width=stroke_width,
|
||||
style='fill:none'
|
||||
)
|
||||
)
|
||||
g.add(lines)
|
||||
|
||||
svg.add(g)
|
||||
svg.save()
|
214
tuhi/uhid.py
|
@ -1,214 +0,0 @@
|
|||
#!/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# 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 2 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/>.
|
||||
#
|
||||
|
||||
from gi.repository import GObject
|
||||
import os
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
|
||||
class UHIDUncompleteException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UHIDDevice(GObject.Object):
|
||||
__UHID_LEGACY_CREATE = 0
|
||||
UHID_DESTROY = 1
|
||||
UHID_START = 2
|
||||
UHID_STOP = 3
|
||||
UHID_OPEN = 4
|
||||
UHID_CLOSE = 5
|
||||
UHID_OUTPUT = 6
|
||||
__UHID_LEGACY_OUTPUT_EV = 7
|
||||
__UHID_LEGACY_INPUT = 8
|
||||
UHID_GET_REPORT = 9
|
||||
UHID_GET_REPORT_REPLY = 10
|
||||
UHID_CREATE2 = 11
|
||||
UHID_INPUT2 = 12
|
||||
UHID_SET_REPORT = 13
|
||||
UHID_SET_REPORT_REPLY = 14
|
||||
|
||||
UHID_FEATURE_REPORT = 0
|
||||
UHID_OUTPUT_REPORT = 1
|
||||
UHID_INPUT_REPORT = 2
|
||||
|
||||
def __init__(self, fd=None):
|
||||
GObject.Object.__init__(self)
|
||||
self._name = None
|
||||
self._phys = ''
|
||||
self._rdesc = None
|
||||
self.parsed_rdesc = None
|
||||
self._info = None
|
||||
if fd is None:
|
||||
self._fd = os.open('/dev/uhid', os.O_RDWR)
|
||||
else:
|
||||
self._fd = fd
|
||||
self.uniq = f'uhid_{str(uuid.uuid4())}'
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_details):
|
||||
os.close(self._fd)
|
||||
|
||||
@GObject.Property
|
||||
def fd(self):
|
||||
return self._fd
|
||||
|
||||
@GObject.Property
|
||||
def rdesc(self):
|
||||
return self._rdesc
|
||||
|
||||
@rdesc.setter
|
||||
def rdesc(self, rdesc):
|
||||
self._rdesc = rdesc
|
||||
|
||||
@GObject.Property
|
||||
def phys(self):
|
||||
return self._phys
|
||||
|
||||
@phys.setter
|
||||
def phys(self, phys):
|
||||
self._phys = phys
|
||||
|
||||
@GObject.Property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, name):
|
||||
self._name = name
|
||||
|
||||
@GObject.Property
|
||||
def info(self):
|
||||
return self._info
|
||||
|
||||
@info.setter
|
||||
def info(self, info):
|
||||
self._info = info
|
||||
|
||||
@GObject.Property
|
||||
def bus(self):
|
||||
return self._info[0]
|
||||
|
||||
@GObject.Property
|
||||
def vid(self):
|
||||
return self._info[1]
|
||||
|
||||
@GObject.Property
|
||||
def pid(self):
|
||||
return self._info[2]
|
||||
|
||||
def call_set_report(self, req, err):
|
||||
buf = struct.pack('< L L H',
|
||||
UHIDDevice.UHID_SET_REPORT_REPLY,
|
||||
req,
|
||||
err)
|
||||
os.write(self._fd, buf)
|
||||
|
||||
def call_get_report(self, req, data, err):
|
||||
data = bytes(data)
|
||||
buf = struct.pack('< L L H H 4096s',
|
||||
UHIDDevice.UHID_GET_REPORT_REPLY,
|
||||
req,
|
||||
err,
|
||||
len(data),
|
||||
data)
|
||||
os.write(self._fd, buf)
|
||||
|
||||
def call_input_event(self, data):
|
||||
data = bytes(data)
|
||||
buf = struct.pack('< L H 4096s',
|
||||
UHIDDevice.UHID_INPUT2,
|
||||
len(data),
|
||||
data)
|
||||
os.write(self._fd, buf)
|
||||
|
||||
def create_kernel_device(self):
|
||||
if (self._name is None or
|
||||
self._rdesc is None or
|
||||
self._info is None):
|
||||
raise UHIDUncompleteException("missing uhid initialization")
|
||||
|
||||
buf = struct.pack('< L 128s 64s 64s H H L L L L 4096s',
|
||||
UHIDDevice.UHID_CREATE2,
|
||||
bytes(self._name, 'utf-8'), # name
|
||||
bytes(self._phys, 'utf-8'), # phys
|
||||
bytes(self.uniq, 'utf-8'), # uniq
|
||||
len(self._rdesc), # rd_size
|
||||
self.bus, # bus
|
||||
self.vid, # vendor
|
||||
self.pid, # product
|
||||
0, # version
|
||||
0, # country
|
||||
bytes(self._rdesc)) # rd_data[HID_MAX_DESCRIPTOR_SIZE]
|
||||
|
||||
n = os.write(self._fd, buf)
|
||||
assert n == len(buf)
|
||||
self.ready = True
|
||||
|
||||
def destroy(self):
|
||||
self.ready = False
|
||||
buf = struct.pack('< L',
|
||||
UHIDDevice.UHID_DESTROY)
|
||||
os.write(self._fd, buf)
|
||||
|
||||
def start(self, flags):
|
||||
print('start')
|
||||
|
||||
def stop(self):
|
||||
print('stop')
|
||||
|
||||
def open(self):
|
||||
print('open', self.sys_path)
|
||||
|
||||
def close(self):
|
||||
print('close')
|
||||
|
||||
def set_report(self, req, rnum, rtype, size, data):
|
||||
print('set report', req, rtype, size, [f'{d:02x}' for d in data[:size]])
|
||||
self.call_set_report(req, 1)
|
||||
|
||||
def get_report(self, req, rnum, rtype):
|
||||
print('get report', req, rnum, rtype)
|
||||
self.call_get_report(req, [], 1)
|
||||
|
||||
def output_report(self, data, size, rtype):
|
||||
print('output', rtype, size, [f'{d:02x}' for d in data[:size]])
|
||||
|
||||
def process_one_event(self):
|
||||
buf = os.read(self._fd, 4380)
|
||||
assert len(buf) == 4380
|
||||
evtype = struct.unpack_from('< L', buf)[0]
|
||||
if evtype == UHIDDevice.UHID_START:
|
||||
ev, flags = struct.unpack_from('< L Q', buf)
|
||||
self.start(flags)
|
||||
elif evtype == UHIDDevice.UHID_OPEN:
|
||||
self.open()
|
||||
elif evtype == UHIDDevice.UHID_STOP:
|
||||
self.stop()
|
||||
elif evtype == UHIDDevice.UHID_CLOSE:
|
||||
self.close()
|
||||
elif evtype == UHIDDevice.UHID_SET_REPORT:
|
||||
ev, req, rnum, rtype, size, data = struct.unpack_from('< L L B B H 4096s', buf)
|
||||
self.set_report(req, rnum, rtype, size, data)
|
||||
elif evtype == UHIDDevice.UHID_GET_REPORT:
|
||||
ev, req, rnum, rtype = struct.unpack_from('< L L B B', buf)
|
||||
self.get_report(req, rnum, rtype)
|
||||
elif evtype == UHIDDevice.UHID_OUTPUT:
|
||||
ev, data, size, rtype = struct.unpack_from('< L 4096s H B', buf)
|
||||
self.output_report(data, size, rtype)
|
33
tuhi/util.py
|
@ -1,33 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# 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 2 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.
|
||||
#
|
||||
|
||||
|
||||
def list2hex(l, groupsize=8):
|
||||
'''Converts a list of integers to a two-letter hex string in the form
|
||||
"1a 2b c3"'''
|
||||
|
||||
slices = []
|
||||
for idx in range(0, len(l), groupsize):
|
||||
s = ' '.join([f'{x:02x}' for x in l[idx:idx + groupsize]])
|
||||
slices.append(s)
|
||||
|
||||
return ' '.join(slices)
|
||||
|
||||
|
||||
def flatten(items):
|
||||
'''flatten an array of mixed int and arrays into a simple array of int'''
|
||||
for item in items:
|
||||
if isinstance(item, int):
|
||||
yield item
|
||||
else:
|
||||
yield from flatten(item)
|
1069
tuhi/wacom.py
After Width: | Height: | Size: 8.4 KiB |