Compare commits

...

No commits in common. "master" and "screenshots" have entirely different histories.

70 changed files with 1 additions and 12916 deletions

View File

@ -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

5
.gitignore vendored
View File

@ -1,5 +0,0 @@
.flatpak*
flatpak*
tuhi.egg-info
__pycache__
*.swp

340
COPYING
View File

@ -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.

View File

@ -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
View File

@ -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

BIN
battery-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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>

View File

@ -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;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@ -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>

View File

@ -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 Tuhis 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

BIN
drawing-buttons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
drop-down-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,3 +0,0 @@
[Device]
Address=E2:43:03:67:0E:01
UUID=dead00beef00

BIN
main-window-zoomed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
main-window.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -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)

View File

@ -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

View File

@ -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": "."
}
]
}
]
}

View File

@ -1,2 +0,0 @@
# Language list must be in alphabetical order
pl

View File

@ -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

View File

@ -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.

View File

@ -1 +0,0 @@
i18n.gettext(meson.project_name(), preset: 'glib')

208
po/pl.po
View File

@ -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 Tuhis 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}"

BIN
register-step1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
register-step2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
register-step3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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:])

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
View File

@ -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()

View File

View File

@ -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

View File

@ -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}')

View File

@ -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)

View File

@ -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]

View File

@ -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])

View File

@ -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'

View File

@ -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.

View File

@ -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.
#

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

File diff suppressed because it is too large Load Diff

View File

@ -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()

View File

@ -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)

View File

@ -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)

File diff suppressed because it is too large Load Diff

BIN
undo-delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB