Repository: Anodynous/stenogotchi Branch: main Commit: 54da07d0d681 Files: 104 Total size: 2.8 MB Directory structure: gitextract_1w8uxzjn/ ├── .github/ │ └── ISSUE_TEMPLATE/ │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── BUILDNOTES.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── initial_setup.sh ├── plover_plugin/ │ ├── setup.cfg │ ├── setup.py │ └── stenogotchi_link/ │ ├── __init__.py │ ├── clients.py │ ├── com.github.stenogotchi.conf │ ├── keymap.py │ ├── stenogotchi_link.py │ └── wpm.py ├── requirements.txt ├── stenogotchi/ │ ├── __init__.py │ ├── _version.py │ ├── agent.py │ ├── automata.py │ ├── defaults.toml │ ├── fs/ │ │ └── __init__.py │ ├── log.py │ ├── plugins/ │ │ ├── __init__.py │ │ ├── cmd.py │ │ └── default/ │ │ ├── buttonshim.py │ │ ├── dict_lookup.py │ │ ├── evdevkb.py │ │ ├── example.py │ │ ├── led.py │ │ ├── logtail.py │ │ ├── memtemp.py │ │ ├── plover_link.py │ │ ├── plover_link_btserver_sdp_record.xml │ │ └── upslite.py │ ├── ui/ │ │ ├── __init__.py │ │ ├── components.py │ │ ├── display.py │ │ ├── faces.py │ │ ├── fonts.py │ │ ├── hw/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── libs/ │ │ │ │ ├── __init__.py │ │ │ │ └── waveshare/ │ │ │ │ ├── __init__.py │ │ │ │ └── v2/ │ │ │ │ ├── __init__.py │ │ │ │ └── epd2in13_V2.py │ │ │ └── waveshare2.py │ │ ├── state.py │ │ ├── view.py │ │ └── web/ │ │ ├── __init__.py │ │ ├── handler.py │ │ ├── server.py │ │ ├── static/ │ │ │ ├── css/ │ │ │ │ ├── jquery.jqplot.css │ │ │ │ └── style.css │ │ │ └── js/ │ │ │ ├── jquery.jqplot.js │ │ │ ├── jquery.mobile/ │ │ │ │ ├── jquery.mobile-1.4.5.css │ │ │ │ ├── jquery.mobile-1.4.5.js │ │ │ │ ├── jquery.mobile.external-png-1.4.5.css │ │ │ │ ├── jquery.mobile.icons-1.4.5.css │ │ │ │ ├── jquery.mobile.inline-png-1.4.5.css │ │ │ │ ├── jquery.mobile.inline-svg-1.4.5.css │ │ │ │ ├── jquery.mobile.structure-1.4.5.css │ │ │ │ └── jquery.mobile.theme-1.4.5.css │ │ │ ├── jquery.timeago.js │ │ │ ├── plugins/ │ │ │ │ ├── jqplot.BezierCurveRenderer.js │ │ │ │ ├── jqplot.barRenderer.js │ │ │ │ ├── jqplot.blockRenderer.js │ │ │ │ ├── jqplot.bubbleRenderer.js │ │ │ │ ├── jqplot.canvasAxisLabelRenderer.js │ │ │ │ ├── jqplot.canvasAxisTickRenderer.js │ │ │ │ ├── jqplot.canvasOverlay.js │ │ │ │ ├── jqplot.canvasTextRenderer.js │ │ │ │ ├── jqplot.categoryAxisRenderer.js │ │ │ │ ├── jqplot.ciParser.js │ │ │ │ ├── jqplot.cursor.js │ │ │ │ ├── jqplot.dateAxisRenderer.js │ │ │ │ ├── jqplot.donutRenderer.js │ │ │ │ ├── jqplot.dragable.js │ │ │ │ ├── jqplot.enhancedLegendRenderer.js │ │ │ │ ├── jqplot.enhancedPieLegendRenderer.js │ │ │ │ ├── jqplot.funnelRenderer.js │ │ │ │ ├── jqplot.highlighter.js │ │ │ │ ├── jqplot.json2.js │ │ │ │ ├── jqplot.logAxisRenderer.js │ │ │ │ ├── jqplot.mekkoAxisRenderer.js │ │ │ │ ├── jqplot.mekkoRenderer.js │ │ │ │ ├── jqplot.meterGaugeRenderer.js │ │ │ │ ├── jqplot.mobile.js │ │ │ │ ├── jqplot.ohlcRenderer.js │ │ │ │ ├── jqplot.pieRenderer.js │ │ │ │ ├── jqplot.pointLabels.js │ │ │ │ ├── jqplot.pyramidAxisRenderer.js │ │ │ │ ├── jqplot.pyramidGridRenderer.js │ │ │ │ ├── jqplot.pyramidRenderer.js │ │ │ │ └── jqplot.trendline.js │ │ │ └── viewportHeight.js │ │ └── templates/ │ │ ├── base.html │ │ ├── index.html │ │ ├── plugins.html │ │ └── status.html │ ├── utils.py │ └── voice.py └── stenogotchi.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To reproduce** Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots/debug logs** If applicable, add screenshots or debug logs to help explain your problem. **Software/hardware configuration (please complete the following information):** - Stenogotchi version: - Plugin version: - OS: - Hardware: **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ ================================================ FILE: BUILDNOTES.md ================================================ # Build Notes Connect all the parts before soldering anything in place. They will not function without properly secured connections, but checking how they fit together can save you from time-consuming mistakes. You'll need a soldering iron, solder and preferably also cutting pliers, a metal file and some flux to complete the build. Below you find some tips on how to fit the parts together as neatly as possible. ## Build Process The depicted device did not have pre-soldered header pins, allowing me to shorten the pins on the underside rather than the top. Leaving the ends connecting to the display unaltered. Depending on your model you may therefore need to make some changes to the below steps. - Push the header pins through from the underside, leaving 4.5 mm of the pins exposed on the top (pre-soldered headers extend 9 mm above the board for comparison). - Layer the buttonSHIM on the top directly against the RPI0w. - Solder the headers on the top to the buttonSHIM using plenty of flux. Leaving as much of the pins clean as possible so they still fit into the display's socket. - Cut and remove the black plastic separator from the headers on the underside, leaving only bare pins on this side of the board as well. - Solder the pins to the underside of the board, ensuring a good electrical connection to the RPI0w. - Cut and file down the pins on the underside to make room for the UPS-Lite. Shortening them enough so they don't poke or scratch the battery. - Secure the UPS-Lite using the included hexagonal nuts. The screws are just barely long enough to accommodate the thickness of both the RPI0w and buttonSHIM. - Carefully press down the eINK module on the male pin headers without applying excessive force to the display itself. ## GPIO Header Whether your RPI0w comes with pre-soldered male header or not, I recommend removing the black spacer. This way you can position the buttonSHIM directly against the board on the top with only the female header of the eINK display module and the buttonSHIM itself adding to the overall height of the device. ## Waveshare Display: - In order to fit the screen onto the device as snugly as possible, make sure to desolder or by other means detach the large white connector. The yellow kapton tape didn't end up being needed since the covered components don't make contact with the RPI0w. ![waveshare_stock](https://user-images.githubusercontent.com/17461433/144749374-befd978c-a6eb-4e9a-a1fc-603ed09b6914.jpg) ![waveshare_modified](https://user-images.githubusercontent.com/17461433/112752795-6a5dab00-8fdd-11eb-8e15-bd59c9444a42.jpeg) ## ButtonSHIM: - Take care to orient the module correctly. - Soldering it into place you want to aim for small but secure solder joints. Any extra solder on the pins will limit how close to the board you can fit the screen. An X-Acto knife or small file can help with potentially needed clean-up. ![buttonshim_attached](https://user-images.githubusercontent.com/17461433/112752878-cc1e1500-8fdd-11eb-98e5-62af52a660a2.jpeg) ## UPS-Lite: - Make sure the pins on the bottom don't extend too far before screwing the module onto the board. They should not be allowed to touch the battery pack. I ended up filing the pins down quite a bit to ensure sufficient clearance. Shape and length of the pins is important for the pogo pins to make good contact. - In the picture you can also spot a couple of the pins on the top and just how low the solder joints need to be for connecting the display. ![upslite_attached](https://user-images.githubusercontent.com/17461433/112752928-1acbaf00-8fde-11eb-8281-5b35784cc348.JPG) ## Real Time Clock - If you want to add a RTC module to keep the device synced while powered off, I recommend the [DS3231](https://www.pishop.us/product/ds3231-real-time-clock-module-for-raspberry-pi/). A good [setup guide](https://learn.adafruit.com/adding-a-real-time-clock-to-raspberry-pi/set-rtc-time) has been published by Adafruit. - Removing the female headers, the module fits neatly inside the UPS-Lite. You can see the correct wiring and positioning in the below picture. Don't forget to isolate it with some tape and form neat solder joints for the pins shared by the UPS-Lite. ![ds3231_attached](https://user-images.githubusercontent.com/17461433/111912767-cff8e700-8a73-11eb-9bd0-a406bd7241ef.jpg) ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.0] - 2022-01-02 ### Added - Modifier, function and navigation-key support, both individually and in combination (unsupported inputs logged in plover.log). - Reactions to new records in WPM-tracking mode. - Plover dictionary lookup with eINK display and STENO input support. The plugin (dict_lookup) utilizes native Plover lookup functionality for full compatibility with supported dictionary formats. - Buttonshim actions, both built-in and custom, accessible through web UI. - Hardware build guide. ### Changed - Plover v4.0.0.dev10 or higher required. - Stenogotchi_link v0.3.0 required. Plugin now logs to plover.log. - Character and personality adjustments. - Simplified installation process. - Added documentation for aligning text output with target device expected input language and layout. - Web UI default port changed from 8080 to 80. - Bluetooth connection established only once Plover has started. ### Fixed - Significantly reduced input latency in STENO mode. - Improved Bluetooth pairing and connection management. - Web UI now starts only once a wifi connection has been established. ## [Unreleased] ### Added ### Changed ### Fixed ### Removed ## [0.0.5] - 2021-03-24 ### Added - DS3231 real time clock module wiring and positioning reference picture to README. - Four new faces, producing processing animation when combined. - WPM stats now track and display top result for session. ### Changed - Led plugin and default patterns to better indicate noteworthy events. ### Fixed - QWERTY-mode breaking bug introduced in v0.0.4. - Letter capitalization, symbol characters and return key in STENO-mode. ## [0.0.4] - 2021-03-21 ### Added - This CHANGELOG file. - User configurable bluetooth device name using main.plugins.plover_link.bt_device_name. - User configurable list of bluetooth mac addresses, in order of priority, to auto-connect to using main.plugins.plover_link.bt_autoconnect_mac. - User configurable option to clear eINK display at shutdown using ui.display.clear_at_shutdown. - User configurable wpm calculation method using main.plugins.plover_link.wpm_method. - User configurable wpm update frequency and calculation window in seconds using main.plugins.plover_link.wpm_timeout. - More variety in mood indicators on common events. - Requirements file. ### Changed - Improved installation guide and documentation in README. - All functionality in buttonshim plugin reworked into class for better integration with the project. - More consistent logging messages. - Stenogotchi_link version upgrade to v0.0.4. ### Fixed - Reboot after initial setup or hostname change not working. - Wifi status not showing as [OFF] if wifi is disabled at boot. - Mode not changing to STENO when Plover becomes operational. - All button press events not producing logging messages. - Dependencies for stenogotchi_link Plover plugin corrected in setup.cfg. ## [0.0.3] - 2021-03-18 ### Added - First public pre-release version on GitHub. - Stenogotchi, portable stenography using Plover and bluetooth keyboard emulation on a Raspberry Pi Zero W. With support for Waveshare 2.13 v2, ButtonSHIM and UPS-Lite v1.2 modules. - Plover plugin stenogotchi_link for communicating between Plover and Stenogotchi. - README now includes tested installation guide no longer requiring building PyQt5 from source. - README now includes basic configuration and usage documentation. - LICENSE file. [Unreleased]: https://github.com/Anodynous/stenogotchi/compare/v0.1.0...dev [0.1.0]: https://github.com/Anodynous/stenogotchi/compare/v0.0.5...v0.1.0 [0.0.5]: https://github.com/Anodynous/stenogotchi/compare/v0.0.4...v0.0.5 [0.0.4]: https://github.com/Anodynous/stenogotchi/compare/v0.0.3...v0.0.4 [0.0.3]: https://github.com/Anodynous/stenogotchi/releases/tag/v0.0.3 ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ # Stenogotchi ![stenogotchi_1](https://user-images.githubusercontent.com/17461433/107876588-8e52aa80-6ecf-11eb-81ba-14731c885ff1.jpeg) Aim of the project is to deliver a cheap and portable device for running [Plover](https://www.openstenoproject.org/ "Plover: Open Steno Project") where local installation on the host is impossible or simply not preferred. A stand-alone link enabling stenography using any input device supported by Plover on any device accepting bluetooth keyboards. Likely use-cases include: - Mobile devices - Corporate and public computers restricting software installations - Hassle-free switching between devices without the need to install and configure Plover - On-the-go stenographic recording Stenogotchi is built on top of [Pwnagotchi](https://github.com/evilsocket/pwnagotchi), but instead of hungering for WPA handshakes it feeds on your steno chords. It emulates a BT HID device for connecting to a host and output can be toggled between STENO and QWERTY mode on the fly. The friendly UI optimized for low-power eINK displays is also accessible as a web UI version, making both the eINK display and buttonSHIM modules optional. If the RPI0w always will be powered over microUSB a separate battery pack is not needed. The suggested UPS-Lite 1000 mAH battery provides 3+ hours of runtime and supports pass-through charging. ## Hardware | Module | Status | |:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------| | [Raspberry Pi Zero W](https://www.raspberrypi.org/products/raspberry-pi-zero-w/) or [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) | Required | | MicroSD card (min 4 GiB) | Required | | [Waveshare 2.13 v.2](https://www.waveshare.com/wiki/2.13inch_e-Paper_HAT) | Recommended | | [ButtonSHIM](https://shop.pimoroni.com/products/button-shim) | Recommended | | [UPS-Lite v1.2](https://hackaday.io/project/173847-ups-lite) | Recommended | | [DS3231 RTC Module](https://www.pishop.us/product/ds3231-real-time-clock-module-for-raspberry-pi/) | Optional | See the [build notes](BUILDNOTES.md) for guidance on fitting the parts together. ## Installation All commands should be executed as root. The installation process can be completed headless. 1. Flash and configure DietPi image, see https://dietpi.com/docs/install/ * For headless installation, set AUTO_SETUP_NET_WIFI_ENABLED=1 in dietpi.txt and enter wifi credentials in dietpi-wifi.txt before first boot. * When prompted allow dietpi to update, change passwords and disable serial console. Under dietpi-config > advanced options enable: * Bluetooth * SPI state (needed by eINK screen) * I2C state (needed by Buttonshim module and for UPS-Lite power readings) * Using command 'dietpi-autostart' enable automatic login of user 'root' to local terminal (option #7) 2. Install dependencies apt-get install git xorg xserver-xorg-video-fbdev python3-pip python3-rpi.gpio python3-gi libtiff5 libopenjp2-7 bluez screen rfkill -y pip3 install file_read_backwards flask flask-wtf flask-cors evdev python-xlib pillow spidev jsonpickle dbus-python toml 3. Download and install Plover (v4.0.0.dev10) wget https://github.com/openstenoproject/plover/releases/download/v4.0.0.dev10/plover-4.0.0.dev10-py3-none-any.whl pip3 install plover-4.0.0.dev10-py3-none-any.whl * If you'd rather try the [continuous build of Plover](https://github.com/openstenoproject/plover/releases/tag/continuous) for the latest improvements, you will need to install both build-essential and python3-dev through apt-get first. Switch back to dev10 if you experience issues. 4. Clone the Stenogotchi repository and install the plover plugin "stenogotchi_link" git clone https://github.com/Anodynous/stenogotchi.git pip3 install ./stenogotchi/plover_plugin/ 5. Configure Plover. Setup will ultimately depend on your own preferences and keyboard, but below is what I use. Make sure to include at least 'auto_start = True' and the '[Plugins]' section in your own config. mkdir -p /root/.config/plover/ nano /root/.config/plover/plover.cfg #---------- [Output Configuration] space_placement = After Output start_attached = True start_capitalize = False undo_levels = 30 [Logging Configuration] enable_stroke_logging = False enable_translation_logging = False [Machine Configuration] auto_start = True machine_type = Gemini PR [Gemini PR] baudrate = 9600 bytesize = 8 parity = N port = /dev/ttyACM0 stopbits = 1 timeout = 2.0 [Plugins] enabled_extensions = ["stenogotchi_link"] 6. Run installation script to finalize setup of Stenogotchi. Optional boot time improvements offered by script described in "Significantly reduce boot time" section below. chmod +x ./stenogotchi/initial_setup.sh ./stenogotchi/initial_setup.sh 7. Configure Stenogotchi settings after reboot completes nano /etc/stenogotchi/config.toml #----------modify the config as you see fit----------# main.plugins.buttonshim.enabled = true main.plugins.upslite.enabled = true main.plugins.evdevkb.enabled = true main.plugins.plover_link.bt_autoconnect_mac = '00:DE:AD:BE:EF:00,11:DE:AD:BE:EF:11' #---------- ## Updating cd ~/stenogotchi git pull pip3 install ~/stenogotchi/plover_plugin/ ## Configuration / Troubleshooting * Configuration files are placed in /etc/stenogotchi/. Create a separate file named config.toml containing overrides to the defaults. Don't edit default.toml directly as it will be overwritten on Stenogotchi version updates. * The logfile is created in /var/log, which dietpi by default mounts to RAM to preserve the SD card lifespan. To make the file persistent across reboots it needs to be written to the disk. To aid with troubleshooting, either set the location to another existing folder using 'main.log.path' in config.toml or change the global dietpi setting using dietpi-software > Log System. * If your target device expects a different input language or keyboard layout than US qwerty, use setxkbmap to align it. This should be added to the beginning of your .xinitrc file to run automatically at startup. For German language and dvorak layout for example the below would be used. setxkbmap -layout de -variant dvorak ### Bluetooth connections * Define your bluetooth devices in main.plugins.plover_link.bt_autoconnect_mac to auto-connect on boot. Multiple comma-separated devices in order of priority can be given. If no connection attempts are successful at boot, the device will fall back to listening for incoming connection and pairing attempts. * Only one active connection at a time is supported. To switch remote devices, disable bluetooth on the remote device and wait around 10 seconds before initiating a new connection. The Stenogotchi will attempt to reconnect to the lost device for a few seconds before falling back to listening for new incoming connections. * Issues with pairing or connecting after changes in bluetooth configurations can normally be fixed through unpairing and re-pairing. On the Stenogotchi side this is best handled through bluetoothctl using the below process. Re-initiate pairing process from remote device after the pairing information has been cleared on both host and client side. bluetoothctl [bluetooth]# paired-devices Device 00:DE:AD:BE:EF:00 Anodynous' Ipad [bluetooth]# remove 00:DE:AD:BE:EF:00 [bluetooth]# exit ### Significantly reduce boot time * Set ARM initial turbo to the max (60s) under dietpi-config > performance options to reduce boot time. You can also play around with overclocking, throttling and cpu governor to find a suitable balance between performance and power draw. * Disable dietpi and apt update check at boot: nano /boot/dietpi.txt #---------- CONFIG_CHECK_DIETPI_UPDATES=0 CONFIG_CHECK_APT_UPDATES=0 * Disable waiting for network and time sync at boot. Doing this you should be aware that the RPI0w does not have a hardware clock. It will lose track of real world time as soon it is powered off, making log timestamps or any time based action you may set up unreliable. None of this is important for the core functionality of the Stenogotchi and disabling time-sync at boot can shave up to a minute off the boot process. By adding a cheap I2C hardware clock you can completely remove the need for network sync. Many modules are small enough to fit in the empty space of the UPS-Lite or under the eINK screen. See the [build notes](BUILDNOTES.md) for more directions. nano /boot/dietpi.txt #---------- CONFIG_BOOT_WAIT_FOR_NETWORK=0 CONFIG_NTP_MODE=0 ## Usage ![stenogotchi_2](https://user-images.githubusercontent.com/17461433/107883149-d5539680-6ef5-11eb-86fe-41f0b6293eed.jpg) ### Buttonshim Below long-press (1s) actions are pre-defined. Short-press triggers user configurable terminal commands, e.g. rclone sync of Plover dictionaries with cloud storage. * Button A - toggle QWERTY / STENO mode * Button B - toggle wpm & strokes readings * Button C - toggle dictionary lookup mode * Button D - toggle wifi (reboot persistent) * Button E - shutdown ## Project roadmap - [x] Proof of concept. Headless RPI0W running Plover, emulating bluetooth HID device and seamlessly piping steno output over BT to host - [x] Create proper plugin for integration with Plover - [x] Integrate bluetooth HID server as Stenogotchi plugin - [x] Support for eINK display and web UI - [x] Support for buttons for built-in and user customizable actions - [x] Support for external battery charge readings on UI - [x] Create Stenogotchi plugin to capture QWERTY input while blocking Plover - [x] ButtonSHIM toggle to enable/disable WIFI, persisting reboot - [x] ButtonSHIM toggle between STENO and QWERTY output mode - [x] WPM readings for STENO mode - [x] Full installation guide for Plover and Stenogotchi using DietPi as base image - [x] Dictionary lookup using eINK screen - [x] Decrease steno latency - [x] Improved web UI, including buttonSHIM functionality - [ ] Plover chords for triggering Stenogotchi actions - [ ] Dictionary additions using eINK screen - [ ] Document configuration options and buttonSHIM functionality - [ ] Clean up and optimize code, fix bugs and add test suite - [ ] Expand Stenogotchi statuses, reactions and mood indicators - [ ] On-the-fly updating and reloading of Plover dictionaries - [ ] Simple AI for shaping personality of the Stenogotchi - [ ] Proper usage and configuration documentation - [ ] Support for other eINK display modules ## Pictures ![stenogotchi_3](https://user-images.githubusercontent.com/17461433/107877063-cb6c6c00-6ed2-11eb-9f92-9059acd9f66d.jpeg) ![stenogotchi_4](https://user-images.githubusercontent.com/17461433/107876793-e3db8700-6ed0-11eb-83bb-648b08d1a315.jpeg) ![stenogotchi_5](https://user-images.githubusercontent.com/17461433/107876790-e0480000-6ed0-11eb-820d-65188cd0a031.jpeg) ## License Released under the GPL3 license. ================================================ FILE: initial_setup.sh ================================================ #!/bin/bash # Finalize setup of Stenogotchi. Only needs to be run once. # Use the below steps if you prefer not executing the automated script or run into issues. # ------------------------------------------------------------------------------------------ # 1) Add configuration file for D-Bus service used by Stenogotchi and the Plover plugin to communicate #cp ./stenogotchi/plover_plugin/stenogotchi_link/com.github.stenogotchi.conf /etc/dbus-1/system.d/ # 2) Modify service file to remove input bluetooth plugin so it does not grab the sockets Stenogotchi requires access to. # Append '-P input' to existing line in '/lib/systemd/system/bluetooth.service' file to end up with: #ExecStart=/usr/libexec/bluetooth/bluetoothd -P input # 3) Ensure 'root' user autologin to local terminal is enabled using dietpi-autostart # 4) Create file '~/.bash_profile' with the below content (excluding #-characters) #if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then # screen -S stenogotchi -dm python3 /root/stenogotchi/stenogotchi.py --debug # xinit #fi # 5) Create file '~/.xinitrc' with the below content #screen -S plover plover -g none -l debug # 6) Launch Stenogotchi manually once which will complete setup and reboot device. # Configure settings per preference in '/etc/stenogotchi/config.toml' after reboot. # 7) Optional but highly recommended. Reduce boot time by disabling Dietpi and APT update check and setting initial turbo boost to 60s. # In /boot/dietpi.txt set below values: #CONFIG_CHECK_DIETPI_UPDATES=0 #CONFIG_CHECK_APT_UPDATES=0 # In /boot/config.txt set: #initial_turbo=60 # 8) Optional but recommended. Reduce boot time further by disabling waiting for network and NTP time sync. # Doing this you should be aware that the RPI0w does not have a hardware clock. It will lose track of real world time as soon it is powered off, making log timestamps or any time based action you may set up unreliable. None of this is important for the core functionality of the Stenogotchi and disabling time-sync at boot can shave up to a minute off the boot process. By adding a cheap I2C hardware clock you can completely remove the need for network sync. # In /boot/dietpi.txt set below values: #CONFIG_BOOT_WAIT_FOR_NETWORK=0 #CONFIG_NTP_MODE=0 # 9) Launch stenogotchi manually and configure using /etc/stenogotchi/config.toml after reboot has completed #python3 ./stenogotchi/stenogotchi.py FLAG="/var/log/_stenogotchi_setup_completed.log" BASEDIR=$(cd `dirname $0` && pwd) # Check and run script only if it hasn't been done already if [ ! -f $FLAG ]; then # Set flag indicating script has been run touch $FLAG # Configure bluetooth service printf "Adding configuration file for D-Bus service used by Stenogotchi and the Plover plugin to communicate.\n" sleep 1 cp $BASEDIR/plover_plugin/stenogotchi_link/com.github.stenogotchi.conf /etc/dbus-1/system.d/ && printf "Success!\n" || printf "Failed! \nPlease manually copy 'com.github.stenogotchi.conf' from stenogotchi_link folder to '/etc/dbus-1/system.d/'" printf "\nModifying service file to remove input bluetooth plugin so it does not grab the sockets Stenogotchi requires access to.\n" sleep 1 # Only add in case not appended yet if ! grep -q "\-P input" /lib/systemd/system/bluetooth.service; then sed '/^ExecStart=/s/$/ -P input/' /lib/systemd/system/bluetooth.service -i.bkp && printf "Success!\n" || printf "Failed! \nPlease manually modify existing line in '/lib/systemd/system/bluetooth.service' to 'ExecStart=/usr/lib/bluetooth/bluetoothd -P input' " else printf "Skipped! Bluetooth input plugin already disabled\n" fi # Configure autostart of plover printf "\nConfigure Stenogotchi to auto-start at boot. Will run under screen session named 'stenogotchi'\n" sleep 1 echo "screen -S plover plover -g none -l debug" > ~/.xinitrc && printf "Success!\n" || printf "Failed! \nPlease manually create file '~/.xinitrc' with contents 'screen -S plover plover -g none -l debug'" # Configure autostart of stenogotchi read -r -d '' AUTOSTART << "EOM" if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then screen -S stenogotchi -dm python3 /root/stenogotchi/stenogotchi.py --debug xinit fi EOM printf "\nConfigure Plover to auto-start at boot. Will run under screen session named 'plover'\n" sleep 1 echo "$AUTOSTART" > ~/.bash_profile && printf "Success!\n" || printf "Failed! \nPlease manually create '~/.bash_profile' file with contents \n"$AUTOSTART" " # Optionally: Optimize boot time printf "\nReduce boot time by disabling automatic Dietpi and APT update checks. Set initial turbo boost to 60s." printf "\nSettings can be changed later in '/boot/dietpi.txt' and '/boot/config.txt'\n" while true; do read -p "Do you wish to enable these highly recommended optimizations? (y/n)" yn case $yn in [Yy]* ) sed -i 's/CONFIG_CHECK_DIETPI_UPDATES=.*/CONFIG_CHECK_DIETPI_UPDATES=0/' /boot/dietpi.txt && printf "set CONFIG_CHECK_DIETPI_UPDATES=0 in /boot/dietpi.txt\n"; sed -i 's/CONFIG_CHECK_APT_UPDATES=.*/CONFIG_CHECK_APT_UPDATES=0/' /boot/dietpi.txt && printf "set CONFIG_CHECK_APT_UPDATES=0 in /boot/dietpi.txt\n"; sed -i 's/initial_turbo=.*/initial_turbo=60/' /boot/config.txt && printf "set initial_turbo=60 in /boot/config.txt\n"; break;; [Nn]* ) printf "Boot optimization skipped. Enable later by setting CONFIG_CHECK_DIETPI_UPDATES=0 and CONFIG_CHECK_APT_UPDATES=0 in /boot/dietpi.txt and initial_turbo=60 in /boot/config.txt\n"; break;; * ) echo "Please answer yes or no.";; esac done # Optionally: Disable waiting for network and NTP-sync printf "\nDisable waiting for network and NTP-sync at boot to significantly reduce boot time when networks are unavailable." printf "\nSettings can be changed later in '/boot/dietpi.txt'\n" while true; do read -p "Do you wish to enable these optimizations? (y/n)" yn case $yn in [Yy]* ) sed -i 's/CONFIG_BOOT_WAIT_FOR_NETWORK=.*/CONFIG_BOOT_WAIT_FOR_NETWORK=0/' /boot/dietpi.txt && printf "set CONFIG_BOOT_WAIT_FOR_NETWORK=0 in /boot/dietpi.txt\n"; sed -i 's/CONFIG_NTP_MODE=.*/CONFIG_NTP_MODE=0/' /boot/dietpi.txt && printf "set CONFIG_NTP_MODE=0 in /boot/dietpi.txt\n"; break;; [Nn]* ) printf "Boot optimization skipped. Enable later by setting CONFIG_BOOT_WAIT_FOR_NETWORK=0 and CONFIG_NTP_MODE=0 in /boot/dietpi.txt\n"; break;; * ) echo "Please answer yes or no.";; esac done # Initial launch of Stenogotchi which triggers reboot printf "\nFirst time launch of Stenogotchi. Configure settings in /etc/stenogotchi/config.toml after reboot\n" sleep 5 python3 $BASEDIR/stenogotchi.py else printf "Setup script has already been executed and should not need to be run again. (Delete '/var/log/_stenogotchi_setup_completed.log' to bypass check)\n" fi ================================================ FILE: plover_plugin/setup.cfg ================================================ [metadata] name = stenogotchi_link version = 0.3.0 description = A plugin for exposing Plover events and communicating with Stenogotchi over D-Bus long_description = author = Anodynous author_email = license = GNU General Public License v3 or later (GPLv3+) url = https://github.com/Anodynous/Stenogotchi classifiers = Development Status :: 4 - Beta Environment :: Plugins Intended Audience :: End Users/Desktop License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Operating System :: Linux Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 keywords = plover plover_plugin stenogotchi [options] zip_safe = True install_requires = plover>=4.0.0.dev10 jsonpickle dbus-python textstat PyGObject packages = stenogotchi_link [options.entry_points] plover.extension = stenogotchi_link = stenogotchi_link.stenogotchi_link:EngineServer ================================================ FILE: plover_plugin/setup.py ================================================ from setuptools import setup setup() ================================================ FILE: plover_plugin/stenogotchi_link/__init__.py ================================================ ================================================ FILE: plover_plugin/stenogotchi_link/clients.py ================================================ #!/usr/bin/env python3 import plover.log import dbus, dbus.exceptions from dbus.mainloop.glib import DBusGMainLoop from time import sleep from threading import Thread from gi.repository import GLib from Xlib import X, XK from plover.oslayer.xkeyboardcontrol import KeyboardEmulation, uchr_to_keysym from plover import key_combo as plover_key_combo from stenogotchi_link.keymap import plover_convert, plover_modkey SERVER_DBUS = 'com.github.stenogotchi' SERVER_SRVC = '/com/github/stenogotchi' ERROR_NO_SERVER: str = 'A server is not currently running' ERROR_SERVER_RUNNING: str = 'A server is already running' TIME_SLEEP = 0 class StenogotchiClient: """ Transmits Plover event updates to, and listens for signals from, Stenogotchi over D-Bus. """ def __init__(self, engineserver): self._engineserver = engineserver self._setup_dbus_loop() self._setup_object() def _setup_dbus_loop(self): DBusGMainLoop(set_as_default=True) self._mainloop = GLib.MainLoop() self._thread = Thread(target=self._mainloop.run) self._thread.start() def _setup_object(self): try: self.bus = dbus.SystemBus() self.stenogotchiobject = self.bus.get_object(SERVER_DBUS, SERVER_SRVC) self.stenogotchi_service = dbus.Interface(self.stenogotchiobject, SERVER_DBUS) # Add signal receiver for incoming messages self.stenogotchi_signal = self.bus.add_signal_receiver(path=SERVER_SRVC, handler_function=self.stenogotchi_signal_handler, dbus_interface=SERVER_DBUS, signal_name='signal_to_plover') except dbus.exceptions.DBusException as e: plover.log.error(f'[stenogotchi_link] Failed to initialize D-Bus object: {str(e)}') def _exit(self): self._mainloop.quit() def plover_is_running(self, b): self.stenogotchi_service.plover_is_running(b) # If plover is shutting down, quit mainloop if not b: self._exit() def plover_is_ready(self, b): self.stenogotchi_service.plover_is_ready(b) def plover_machine_state(self, s): self.stenogotchi_service.plover_machine_state(s) def plover_output_enabled(self, b): self.stenogotchi_service.plover_output_enabled(b) def plover_wpm_stats(self, s): self.stenogotchi_service.plover_wpm_stats(s) def plover_strokes_stats(self, s): self.stenogotchi_service.plover_strokes_stats(s) def send_backspaces(self, y): self.stenogotchi_service.send_backspaces_stenogotchi(y) def send_string(self, s): self.stenogotchi_service.send_string_stenogotchi(s) def send_key_combination(self, s): self.stenogotchi_service.send_key_combination_stenogotchi(s) def send_lookup_results(self, l): self.stenogotchi_service.plover_translation_handler(l) def stenogotchi_signal_handler(self, dict): # Enable and disable wpm/strokes meters if 'lookup_word' in dict: self._engineserver.lookup_word(dict['lookup_word']) if 'lookup_stroke' in dict: self._engineserver.lookup_stroke(dict['lookup_stroke']) if 'output_to_stenogotchi' in dict: self._engineserver._output_to_stenogotchi = dict['output_to_stenogotchi'] if 'start_wpm_meter' in dict: wpm_method = dict['wpm_method'] wpm_timeout = int(dict['wpm_timeout']) plover.log.info('[stenogotchi_link] Starting WPM meter') if dict['start_wpm_meter'] == 'wpm and strokes': self._engineserver.start_wpm_meter(enable_wpm=True, enable_strokes=True, wpm_method=wpm_method, wpm_timeout=wpm_timeout) elif dict['start_wpm_meter'] == 'wpm': self._engineserver.start_wpm_meter(enable_wpm=True, enable_strokes=False, wpm_method=wpm_method, wpm_timeout=wpm_timeout) elif dict['start_wpm_meter'] == 'strokes': self._engineserver.start_wpm_meter(enable_wpm=False, enable_strokes=True, wpm_method=wpm_method, wpm_timeout=wpm_timeout) if 'stop_wpm_meter' in dict: plover.log.info('[stenogotchi_link] Stopping WPM meter') if dict['stop_wpm_meter'] == 'wpm and strokes': self._engineserver.stop_wpm_meter(disable_wpm=True, disable_strokes=True) elif dict['stop_wpm_meter'] == 'wpm': self._engineserver.stop_wpm_meter(disable_wpm=True, disable_strokes=False) elif dict['stop_wpm_meter'] == 'strokes': self._engineserver.stop_wpm_meter(disable_wpm=False, disable_strokes=True) class CustomKeyboardEmulation(KeyboardEmulation): """ We use the Plover implementation, but change _update_keymap() to prefer lower keycode rather than low modkeys combos in mapping. This way we avoid some issues when mapping to BT HID keycodes. """ def _update_keymap(self): '''Analyse keymap, build a mapping of keysym to (keycode + modifiers), and find unused keycodes that can be used for unmapped keysyms. ''' self._keymap = {} self._custom_mappings_queue = [] # Analyse X11 keymap. keycode = self._display.display.info.min_keycode keycode_count = self._display.display.info.max_keycode - keycode + 1 for mapping in self._display.get_keyboard_mapping(keycode, keycode_count): mapping = tuple(mapping) while mapping and X.NoSymbol == mapping[-1]: mapping = mapping[:-1] if not mapping: # Free never used before keycode. custom_mapping = [self.UNUSED_KEYSYM] * self.CUSTOM_MAPPING_LENGTH custom_mapping[-1] = self.PLOVER_MAPPING_KEYSYM mapping = custom_mapping elif self.CUSTOM_MAPPING_LENGTH == len(mapping) and \ self.PLOVER_MAPPING_KEYSYM == mapping[-1]: # Keycode was previously used by Plover. custom_mapping = list(mapping) else: # Used keycode. custom_mapping = None for keysym_index, keysym in enumerate(mapping): if keysym == self.PLOVER_MAPPING_KEYSYM: continue if keysym_index not in (0, 1, 4, 5): continue modifiers = 0 if 1 == (keysym_index % 2): # The keycode needs the Shift modifier. modifiers |= X.ShiftMask if 4 <= keysym_index <= 5: # 3rd (AltGr) level. modifiers |= X.Mod5Mask mapping = self.Mapping(keycode, modifiers, keysym, custom_mapping) if keysym != X.NoSymbol and keysym != self.UNUSED_KEYSYM: # Some keysym are mapped multiple times, prefer lower keycode (Plover prefers lower modifiers combos). previous_mapping = self._keymap.get(keysym) if previous_mapping is None or mapping.keycode < previous_mapping.keycode: self._keymap[keysym] = mapping if custom_mapping is not None: self._custom_mappings_queue.append(mapping) keycode += 1 # Determine the backspace mapping. backspace_keysym = XK.string_to_keysym('BackSpace') self._backspace_mapping = self._get_mapping(backspace_keysym) assert self._backspace_mapping is not None assert self._backspace_mapping.custom_mapping is None # Get modifier mapping. self.modifier_mapping = self._display.get_modifier_mapping() class BTClient: """ Transmits keystroke output from Plover to Stenogotchi as HID messages over D-Bus. """ def __init__(self): self.target_length = 6 self.mod_keys = 0b00000000 self.pressed_keys = [] self.bus = dbus.SystemBus() self.btkobject = self.bus.get_object(SERVER_DBUS, SERVER_SRVC) self.btk_service = dbus.Interface(self.btkobject, SERVER_DBUS) self.ke = CustomKeyboardEmulation() self._backspace_mapping_hid = plover_convert(self.ke._backspace_mapping.keycode) def update_mod_keys(self, mod_key, value): """ Which modifier keys are active is stored in an 8 bit number. Each bit represents a different key. This method takes which bit and its new value as input :param mod_key: The value of the bit to be updated with new value :param value: Binary 1 or 0 depending if pressed or released """ bit_mask = 1 << (7-mod_key) if value: # set bit self.mod_keys |= bit_mask else: # clear bit self.mod_keys &= ~bit_mask def update_keys(self, norm_key, value): """ Sets the active normal keys """ if norm_key < 0: # Log for debug purposes in case keycode isn't valid plover.log.error(f"[stenogotchi_link] KeyError in update_keys, unable to send keycode: {norm_key}") return if value < 1: self.pressed_keys.remove(norm_key) elif norm_key not in self.pressed_keys: self.pressed_keys.insert(0, norm_key) len_delta = self.target_length - len(self.pressed_keys) if len_delta < 0: self.pressed_keys = self.pressed_keys[:len_delta] elif len_delta > 0: self.pressed_keys.extend([0] * len_delta) @property def state(self): """ property with the HID message to be sent :return: bytes of HID message """ return [0xA1, 0x01, self.mod_keys, 0, *self.pressed_keys] def clear_mod_keys(self): self.mod_keys = 0b00000000 def clear_keys(self): self.pressed_keys = [] def send_keys(self, state_list=None): if not state_list: self.btk_service.send_keys([state_list]) else: flat_list = state_list self.btk_service.send_keys(flat_list) def send_backspaces(self, number_of_backspaces): self.clear_keys() self.clear_mod_keys() state_list = [] for x in range(number_of_backspaces): self.update_keys(self._backspace_mapping_hid, 1) state_list.append(self.state) self.update_keys(self._backspace_mapping_hid, 0) state_list.append(self.state) self.send_keys(state_list) def map_hid_events(self, keycode, modifiers=None): """ Returns a list of HID bytearrays to produce the key combination. Arguments: keycode -- An integer in the inclusive range [8-255]. modifiers -- An 8-bit bit mask indicating if the key pressed is modified by other keys, such as Shift, Capslock, Control, and Alt. TODO: International, accented characters and a range of symbols are not working with the US keyboard layout. Could be solved using Alt codes on Windows, but Mac/iOS uses different Option codes. Complicated to solve in a clean way as we only emulate a BT keyboard and all characters must be produced on the remote host. -> Look into separate WIN/MAC translation tables. Profiles which can be linked to BT MAC adresses. """ self.clear_keys() self.clear_mod_keys() state_sublist = [] #modifiers_list = [ # self.ke.modifier_mapping[n][0] # for n in range(8) # if (modifiers & (1 << n)) #] #for mod_keycode in modifiers_list: # self.update_mod_keys(plover_modkey(mod_keycode), 1) normkey_hid = plover_convert(keycode) # Log issues with mapping any keycodes if normkey_hid < 0: plover.log.error(f"[stenogotchi_link] Unable to map keycode: {keycode} using plover_convert in keymap.py.") return if modifiers: modkey_hid = plover_modkey(modifiers) if modkey_hid < 0: plover.log.error(f"[stenogotchi_link] Unable to map keycode: {keycode} using plover_modkey in keymap.py.") # Apply modifiers if modifiers: # TODO: Handle combination of modifiers. 129 for example is shift (1) + AltGr (128) self.update_mod_keys(modkey_hid, 1) # Press and release the base key. self.update_keys(normkey_hid, 1) state_sublist.append(self.state) sleep(TIME_SLEEP) self.update_keys(normkey_hid, 0) state_sublist.append(self.state) # Release modifiers if modifiers > 0: self.update_mod_keys(modkey_hid, 0) state_sublist.append(self.state) return state_sublist #for mod_keycode in reversed(modifiers_list): # self.update_mod_keys(plover_modkey(mod_keycode), 0) def send_string(self, s): state_list = [] for char in s: keysym = uchr_to_keysym(char) mapping = self.ke._get_mapping(keysym) #plover.log.debug(f"[stenogotchi_link] mapping : '{mapping}' mapping.keycode : '{mapping.keycode}' and mapping.modifier : '{mapping.modifiers}' (for keysym : '{keysym}' given char : '{char}')") if mapping is None: continue sublist = self.map_hid_events(mapping.keycode, mapping.modifiers) if sublist: state_list.extend(sublist) if len(state_list) > 0: self.send_keys(state_list) def send_key_combination(self, combo_string: str): """ Custom implementation of Plover send_key_combination function since we do not work with emulated key events or need to fight with a KeyboardCapture instance. Arguments: combo_string -- A string representing a sequence of key combinations. Keys are represented by their names in the Xlib.XK module, without the 'XK_' prefix. For example, the left Alt key is represented by 'Alt_L'. Keys are either separated by a space or a left or right parenthesis. Parentheses must be properly formed in pairs and may be nested. A key immediately followed by a parenthetical indicates that the key is pressed down while all keys enclosed in the parenthetical are pressed and released in turn. For example, Alt_L(Tab) means to hold the left Alt key down, press and release the Tab key, and then release the left Alt key. """ self.clear_keys() self.clear_mod_keys() state_list = [] # Parse and validate combo. key_events = [ (keycode, 1 if pressed else 0) for keycode, pressed in plover_key_combo.parse_key_combo(combo_string, self.ke._get_keycode_from_keystring) ] # Send key events to emulate combination. for keycode, event_type in key_events: # Convert to HID normkey_hid = plover_convert(keycode) if normkey_hid == -1: modkey_hid = plover_modkey(keycode) else: modkey_hid = -1 # Update and send keycode if mapped, otherwise log if modkey_hid > -1: self.update_mod_keys(modkey_hid, event_type) state_list.append(self.state) elif normkey_hid > -1: self.update_keys(normkey_hid, event_type) state_list.append(self.state) else: plover.log.debug(f"[stenogotchi_link]Received key_combination from Plover: {combo_string}, resulting in key_events: {key_events}") plover.log.error(f"Unable to map keycode: {keycode}, in keymap.py (event_type: {event_type})") self.send_keys(state_list) ================================================ FILE: plover_plugin/stenogotchi_link/com.github.stenogotchi.conf ================================================ ================================================ FILE: plover_plugin/stenogotchi_link/keymap.py ================================================ # Find HID keycode mapping here: https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf # Linux keycodes can be found in /usr/share/X11/xkb/keycodes/evdev # Linux keycode used by Plover : HID keycode used for BT plover_keytable = { # Function row. 67: 58, #"F1", 68: 59, #"F2", 69: 60, #"F3", 70: 61, #"F4", 71: 62, #"F5", 72: 63, #"F6", 73: 64, #"F7", 74: 65, #"F8", 75: 66, #"F9", 76: 67, #"F10", 95: 68, #"F11", 96: 69, #"F12", # Number row. 49: 53, #"`", 10: 30, #"1", 11: 31, #"2", 12: 32, #"3", 13: 33, #"4", 14: 34, #"5", 15: 35, #"6", 16: 36, #"7", 17: 37, #"8", 18: 38, #"9", 19: 39, #"0", 20: 45, #"-", 21: 46, #"=", 51: 50, #"\\", # Upper row. 24: 20, #"q", 25: 26, #"w", 26: 8, #"e", 27: 21, #"r", 28: 23, #"t", 29: 28, #"y", 30: 24, #"u", 31: 12, #"i", 32: 18, #"o", 33: 19, #"p", 34: 47, #"[", 35: 48, #"]", # Home row. 38: 4, #"a", 39: 22, #"s", 40: 7, #"d", 41: 9, #"f", 42: 10, #"g", 43: 11, #"h", 44: 13, #"j", 45: 14, #"k", 46: 15, #"l", 47: 51, #";", 48: 52, #"'", # Bottom row. 52: 29, #"z", 53: 27, #"x", 54: 6, #"c", 55: 25, #"v", 56: 5, #"b", 57: 17, #"n", 58: 16, #"m", 59: 54, #",", 60: 55, #".", 61: 56, #"/", # Other keys. 22 : 42, #"BackSpace", 119: 76, #"Delete", 116: 81, #"Down", 115: 77, #"End", 9 : 41, #"Escape", 110: 74, #"Home", 118: 73, #"Insert", 113: 80, #"Left", 117: 78, #"Page_Down", 112: 75, #"Page_Up", 36 : 40, #"Return", 114: 79, #"Right", 23 : 43, #"Tab", 111: 82, #"Up", 65 : 44, #"Space", 66 : 57, #Caps_Lock, #"KEY_BACK": 241, #"KEY_FORWARD": 242, #"KEY_REFRESH": 250, #"KEY_SYSRQ": 70, # Keypad keys #"KEY_NUMLOCK": 83, #"KEY_SCROLLLOCK": 71, 79: 95, #kp_7 - "KEY_KP7" 80: 96, #kp_8 - "KEY_KP8" 81: 97, #kp_9 - "KEY_KP9" #"KEY_KPMINUS": 86, 83: 92, #kp_4 - "KEY_KP4" 84: 93, #kp_5 - "KEY_KP5" 85: 94, #kp_6 - "KEY_KP6" #"KEY_KPPLUS": 87, 87: 89, #kp_1 - "KEY_KP1" 88: 90, #kp_2 - "KEY_KP2" 89: 91, #kp_3 - "KEY_KP3" 90: 98, #kp_0 - "KEY_KP0" 126: 215, # "Keypad ±" #"KEY_KPDOT": 99, # TODO: Media keys not working correctly # Media keys 172: 232, # AudioPlay - "KEY_PLAYPAUSE" 209: 232, # AudioPause - "KEY_PLAYPAUSE" 121: 239, # AudioMute - "KEY_MUTE" 122: 238, #AudioLowerVolume - "KEY_VOLUMEDOWN" 123: 237, #AudioRaiseVolume - "KEY_VOLUMEUP" 173: 234, #AudioPrev - "KEY_PREVIOUSSONG" 171: 235, #AudioNext - "KEY_NEXTSONG" 174: 233, #AudioStop - "KEY_STOPCD" 169: 236, #Eject - "KEY_EJECTCD" } # Map modifier keys to array element in the bit array plover_modkeys = { # TODO: AltGr will not on MacOS/iOs/iPadOS produce expected character. 134: 0, #Super_R - "KEY_RIGHTMETA", WIN-key 128: 1, #"KEY_RIGHTALT", AltGr used to produce some symbols 62: 2, #"KEY_RIGHTSHIFT" 105: 3, #"KEY_RIGHTCTRL" 133: 4, #Super_L - "KEY_LEFTMETA", WIN-key 64: 5, #"KEY_LEFTALT" 50 : 6, #"KEY_LEFTSHIFT" 1 : 6, #"KEY_LEFTSHIFT", mapped to both 50 and 1 as '1' is used by Plover as shift modifier to capitalize letters and '50' when sent in key-combo 37: 7 #"KEY_LEFTCTRL" } def plover_convert(plover_keycode): if plover_keycode in plover_keytable: return plover_keytable[plover_keycode] else: return -1 # Return an invalid keycode def plover_modkey(plover_keycode): if plover_keycode in plover_modkeys: return plover_modkeys[plover_keycode] else: return -1 # Return an invalid array element ================================================ FILE: plover_plugin/stenogotchi_link/stenogotchi_link.py ================================================ #!/usr/bin/env python3 # This is a Plover plugin acting as link between Plover and Stenogotchi # Based on: https://github.com/nsmarkop/plover_websocket_server import plover.log import json import jsonpickle from typing import Optional, List from time import sleep from plover.engine import StenoEngine from plover.steno import Stroke from plover.config import Config from plover.formatting import _Action from plover.steno_dictionary import StenoDictionaryCollection from stenogotchi_link.clients import BTClient, StenogotchiClient from stenogotchi_link.wpm import PloverWpmMeter, PloverStrokesMeter ERROR_MISSING_ENGINE = 'Plover engine not provided' class EngineServer(): """ Hooks into Plover events and makes them available to Stenogotchi. """ # Called once to initialize an instance which lives until Plover exits def __init__(self, engine: StenoEngine) -> None: self._engine: StenoEngine = engine self._stenogotchiclient = StenogotchiClient(self) self._btclient = BTClient() self._wpm_meter = None self._strokes_meter = None self._output_to_stenogotchi = False # Started when user enables extension def start(self): """ Starts the server. """ self._connect_hooks() plover.log.info("[stenogotchi_link] Plover_link started") self._stenogotchiclient.plover_is_running(True) # Called when Plover exits or user disables the extension def stop(self): """ Stops the server. """ self._disconnect_hooks() self._stenogotchiclient.plover_is_running(False) def start_wpm_meter(self, enable_wpm=False, enable_strokes=False, wpm_method='ncra', wpm_timeout=60): """ Starts WPM and/or Strokes meters """ if enable_wpm: self._wpm_meter = PloverWpmMeter(stenogotchi_link=self, wpm_method=wpm_method, timeout=wpm_timeout) if enable_strokes: self._strokes_meter = PloverStrokesMeter(stenogotchi_link=self, strokes_method=wpm_method, timeout=wpm_timeout) def stop_wpm_meter(self, disable_wpm=True, disable_strokes=True): if disable_wpm: self._wpm_meter.quit() self._wpm_meter = None if disable_strokes: self._strokes_meter.quit() self._strokes_meter = None def _on_wpm_meter_update_strokes(self, stats): """ Sends strokes stats to stenogotchi as a string """ self._stenogotchiclient.plover_strokes_stats(stats['strokes_user']) def _on_wpm_meter_update_wpm(self, stats): """ Sends wpm stats to stenogotchi as a string """ self._stenogotchiclient.plover_wpm_stats(stats['wpm_user']) def _on_plover_translation(self, results, type): """ Sends translation results from Plover to stenogotchi as list of strings""" if type == 'word': # Result from Plover will be in format List[Tuple[str]], examine: {('EBGS', 'APL', '*EUPB'), ('KP*PL',), ('KPAPL', 'PHEUPB'), ('KPAPL', '-PB'), ('KP-PB',), ('EBGS', 'APL', '-PB'), ('KP',), ('EBGS', 'APL', 'PHEUPB'), ('EBGS', 'APL', 'EUPB')} results_list = [] chords = None for touple in results: if chords: results_list.append(chords) chords = None for result in touple: if chords: chords = chords + '/' + result else: chords = result if chords: results_list.append(chords) chords = None elif type == 'stroke': # Result from Plover will be in format str # TODO: implement functionality for stroke-lookup plover.log.debug(f"Lookup_stroke results: {results}") results_list = [results] self._stenogotchiclient.send_lookup_results(results_list) def lookup_word(self, word): matches = self._engine.reverse_lookup(word) self._on_plover_translation(matches, 'word') def lookup_stroke(self, stroke): matches = self._engine.lookup(stroke) self._on_plover_translation(matches, 'stroke') def get_server_status(self): """Gets the status of the server. Returns: The status of the server. """ pass def _connect_hooks(self): """Creates hooks into all of Plover's events.""" if not self._engine: plover.log.error(f'[stenogotchi_link] {ERROR_MISSING_ENGINE}') raise AssertionError(ERROR_MISSING_ENGINE) for hook in self._engine.HOOKS: callback = getattr(self, f'_on_{hook}') self._engine.hook_connect(hook, callback) def _disconnect_hooks(self): """Removes hooks from all of Plover's events.""" if not self._engine: raise AssertionError(ERROR_MISSING_ENGINE) for hook in self._engine.HOOKS: callback = getattr(self, f'_on_{hook}') self._engine.hook_disconnect(hook, callback) def _on_stroked(self, stroke: Stroke): """ Broadcasts when a new stroke is performed. """ pass def _on_translated(self, old: List[_Action], new: List[_Action]): """Broadcasts when a new translation occurs. Args: old: A list of the previous actions for the current translation. new: A list of the new actions for the current translation. """ # Send to WPM meter if we have one if self._wpm_meter: self._wpm_meter.on_translation(old, new) # Send to Strokes meter if we have one if self._strokes_meter: self._strokes_meter.on_translation(old, new) # print(self._wpm_meter.get_stats()) # print(self._strokes_meter.get_stats()) def _on_machine_state_changed(self, machine_type: str, machine_state: str): """Broadcasts when the active machine state changes. Args: machine_type: The name of the active machine. machine_state: The new machine state. This should be one of the state constants listed in plover.machine.base. """ self._stenogotchiclient.plover_machine_state("machine_type: " + machine_type + " machine_state: " + machine_state) def _on_output_changed(self, enabled: bool): """Broadcasts when the state of output changes. Args: enabled: If the output is now enabled or not. """ data = {'output_changed': enabled} plover.log.debug(f'[stenogotchi_link] _on_output_changed data: {data}') self._stenogotchiclient.plover_output_enabled(enabled) def _on_config_changed(self, config_update: Config): """Broadcasts when the configuration changes. Args: config_update: An object containing the full configuration or a part of the configuration that was updated. """ config_json = jsonpickle.encode(config_update, unpicklable=False) data = {'config_changed': json.loads(config_json)} plover.log.debug(f'[stenogotchi_link] _on_config_changed data: {data}') def _on_dictionaries_loaded(self, dictionaries: StenoDictionaryCollection): """Broadcasts when all of the dictionaries get loaded. Args: dictionaries: A collection of the dictionaries that loaded. """ self._stenogotchiclient.plover_is_ready(True) def _on_send_string(self, text: str): """Broadcasts when a new string is output. Args: text: The string that was output. """ if self._output_to_stenogotchi: self._stenogotchiclient.send_string(text) else: self._btclient.send_string(text) def _on_send_backspaces(self, count: int): """Broadcasts when backspaces are output. Args: count: The number of backspaces that were output. """ if self._output_to_stenogotchi: self._stenogotchiclient.send_backspaces(count) else: self._btclient.send_backspaces(count) def _on_send_key_combination(self, combination: str): """Broadcasts when a key combination is output. Args: combination: A string representing a sequence of key combinations. Keys are represented by their names based on the OS-specific keyboard implementations in plover.oslayer. """ if self._output_to_stenogotchi: self._stenogotchiclient.send_key_combination(combination) else: self._btclient.send_key_combination(combination) def _on_add_translation(self): """Broadcasts when the add translation tool is opened via a command.""" data = {'add_translation': True} plover.log.debug(f'[stenogotchi_link] _on_add_translation data: {data}') def _on_focus(self): """Broadcasts when the main window is focused via a command.""" data = {'focus': True} plover.log.debug(f'[stenogotchi_link] _on_focus data: {data}') def _on_configure(self): """Broadcasts when the configuration tool is opened via a command.""" data = {'configure': True} plover.log.debug(f'[stenogotchi_link] _on_configure data: {data}') def _on_lookup(self): """Broadcasts when the lookup tool is opened via a command.""" data = {'lookup': True} plover.log.debug(f'[stenogotchi_link] _on_lookup data: {data}') def _on_suggestions(self): """Broadcasts when the lookup tool is opened via a command.""" data = {'suggestions': True} plover.log.debug(f'[stenogotchi_link] _on_lookup data: {data}') def _on_quit(self): """Broadcasts when the application is terminated. Can be either a full quit or a restart. """ data = {'quit': True} plover.log.debug(f'[stenogotchi_link] _on_quit data: {data}') self._stenogotchiclient.plover_is_running(False) if __name__ == '__main__': print("Please enable and run as Plover plugin") ================================================ FILE: plover_plugin/stenogotchi_link/wpm.py ================================================ #!/usr/bin/env python3 # # Based on: https://github.com/arxanas/plover_wpm_meter.git, modified to remove QT dependency. # # This is a plugin for Plover to display your typing speed as you type, in either or both of words per minute or strokes per minute: ## Words per minute: Shows the rate at which you stroked words in the last 10 and 60 seconds. ## Strokes per minute: Shows how efficiently you stroked words in the last 10 and 60 seconds. Lower values are more efficient, e.g. a value of 1 means that on average it took you one stroke to write out a word. # # A word is defined in one of three ways: ## NCRA: The National Court Reporters Association defines a “word” as 1.4 syllables. This is the measure used for official NCRA testing material. ## Traditional: The traditional metric for “word” in the context of keyboarding is defined to be 5 characters per word, including spaces. This is compatible with the notion of “word” in many typing speed utilities. ## Spaces: A word is a whitespace-separated sequence of characters. This metric of course doesn’t take into account the fact that some words are longer than others, both in length and syllables. import time from threading import Timer from textstat.textstat import textstat from plover.formatting import OutputHelper class RepeatTimer(Timer): """ Perpetually repeating timer implementation of threading.Timer """ def run(self): while not self.finished.wait(self.interval): self.function(*self.args, **self.kwargs) class CaptureOutput(object): def __init__(self, chars): self.chars = chars def send_backspaces(self, n): del self.chars[-n:] def send_string(self, s): self.chars += _timestamp_items(s) def send_key_combination(self, c): pass def send_engine_command(self, c): pass class BaseMeter(): def __init__(self, timeout=60): # Set timer to calculate wpm/strokes stats each second self._timer = RepeatTimer(1, self.on_timer) self._timer.start() # Set timer to publish wpm/strokes stats each minute self._event_timer = RepeatTimer(timeout, self.trigger_event_update) self._event_timer.start() self.chars = [] def on_translation(self, old, new): output = CaptureOutput(self.chars) output_helper = OutputHelper(output, False, False) output_helper.render(None, old, new) def on_timer(self): raise NotImplementedError() def trigger_event_update(self): raise NotImplementedError() def quit(self): self._timer.cancel() self._event_timer.cancel() class PloverWpmMeter(BaseMeter): def __init__(self, stenogotchi_link, wpm_method='ncra', timeout=60): super().__init__(timeout) self._stenogotchi_link = stenogotchi_link self.strokes = [] self.wpm_methods = { 'ncra': False, # NCRA (by syllables) 'traditional': False, # Traditional (by characters) 'spaces': False, # Spaces (by whitespace) } self._timeouts = { "wpm10": 10, "wpm_user": timeout, } self.set_wpm_method(wpm_method) self.wpm_stats = {} def set_wpm_method(self, method): self.wpm_methods = dict.fromkeys(self.wpm_methods, False) self.wpm_methods[method] = True def get_wpm_method(self): for method, enabled in self.wpm_methods.items(): if enabled: return method def get_stats(self): return self.wpm_stats def on_timer(self): max_timeout = max(self._timeouts.values()) self.chars = _filter_old_items(self.chars, max_timeout) for name, timeout in self._timeouts.items(): chars = _filter_old_items(self.chars, timeout) wpm = _wpm_of_chars(chars, method=self.get_wpm_method()) self.wpm_stats[name] = str(wpm) def trigger_event_update(self): self._stenogotchi_link._on_wpm_meter_update_wpm(stats=self.wpm_stats) class PloverStrokesMeter(BaseMeter): def __init__(self, stenogotchi_link, strokes_method='ncra', timeout=60): super().__init__(timeout) self._stenogotchi_link = stenogotchi_link self.actions = [] self.strokes_methods = { 'ncra': False, # NCRA (by syllables) 'traditional': False, # Traditional (by characters) 'spaces': False, # Spaces (by whitespace) } self._timeouts = { "strokes10": 10, "strokes_user": timeout, } self.set_strokes_method(strokes_method) self.strokes_stats = {} # By default, the QLCDNumbers will just display "0", without a decimal # point, on initial render. Render them ourselves so that we don't # switch from "0" to "0.00" after a second. self.on_timer() def set_strokes_method(self, method): self.strokes_methods = dict.fromkeys(self.strokes_methods, False) self.strokes_methods[method] = True def get_strokes_method(self): for method, enabled in self.strokes_methods.items(): if enabled: return method def get_stats(self): return self.strokes_stats def on_translation(self, old, new): super().on_translation(old, new) if len(old) > 0: self.actions = self.actions[:-len(old)] self.actions += _timestamp_items(new) def on_timer(self): max_timeout = max(self._timeouts.values()) self.chars = _filter_old_items(self.chars, max_timeout) self.actions = _filter_old_items(self.actions, max_timeout) for name, timeout in self._timeouts.items(): chars = _filter_old_items(self.chars, timeout) num_strokes = len(_filter_old_items(self.actions, timeout)) strokes_per_word = _spw_of_chars( num_strokes, chars, method=self.get_strokes_method() ) self.strokes_stats[name] = str("{:0.2f}".format(strokes_per_word)) def trigger_event_update(self): self._stenogotchi_link._on_wpm_meter_update_strokes(stats=self.strokes_stats) def _timestamp_items(items): current_time = time.time() return [(i, current_time) for i in items] def _filter_old_items(items, timeout): current_time = time.time() return [(i, t) for i, t in items if (current_time - t) <= timeout] def _words_in_chars(chars, method): text = "".join(c for c, _ in chars) if method == "ncra": # The NCRA defines a "word" to be 1.4 syllables, which is the average # number of syllables per English word. syllables_per_word = 1.4 # For some reason, textstat returns syllable counts such as a # one-syllable word like "the" being 0.9 syllables. syllables_in_text = textstat.syllable_count(text) / 0.9 return syllables_in_text * (1 / syllables_per_word) elif method == "traditional": # Formal definition; see https://en.wikipedia.org/wiki/Words_per_minute return len(text) / 5 elif method == "spaces": return len([i for i in text.split() if i]) else: assert False, "bad wpm method: " + method def _time_interval_of_chars(chars): start_time = min(t for _, t in chars) current_time = time.time() time_interval = current_time - start_time time_interval = max(1, time_interval) return time_interval def _wpm_of_chars(chars, method): num_words = _words_in_chars(chars, method) if not num_words: return 0 time_interval = _time_interval_of_chars(chars) num_minutes = time_interval / 60 num_words_per_minute = num_words / num_minutes return int(round(num_words_per_minute)) def _spw_of_chars(num_strokes, chars, method): num_words = _words_in_chars(chars, method) if not num_words: return 0 return num_strokes / num_words ================================================ FILE: requirements.txt ================================================ PyGObject == 3.30.4 dbus_python == 1.2.16 evdev == 1.4.0 RPi.GPIO == 0.7.0 file_read_backwards == 2.0.0 toml == 0.10.2 spidev == 3.5 Flask == 1.1.2 Flask_Cors == 3.0.10 Flask_WTF == 0.14.3 requests == 2.25.1 Pillow >= 8.3.2 plover >= 4.0.0.dev10 ================================================ FILE: stenogotchi/__init__.py ================================================ import os import logging import time import re import subprocess from stenogotchi._version import __version__ _name = None config = None def set_name(new_name): if new_name is None: return new_name = new_name.strip() if new_name == '': return if not re.match(r'^[a-zA-Z0-9\-]{2,25}$', new_name): logging.warning("name '%s' is invalid: min length is 2, max length 25, only a-zA-Z0-9- allowed", new_name) return current = name() if new_name != current: global _name logging.info("setting unit hostname '%s' -> '%s'", current, new_name) with open('/etc/hostname', 'wt') as fp: fp.write(new_name) with open('/etc/hosts', 'rt') as fp: prev = fp.read() logging.debug("old hosts:\n%s\n", prev) with open('/etc/hosts', 'wt') as fp: patched = prev.replace(current, new_name, -1) logging.debug("new hosts:\n%s\n", patched) fp.write(patched) os.system("hostname '%s'" % new_name) reboot() def name(): global _name if _name is None: with open('/etc/hostname', 'rt') as fp: _name = fp.read().strip() return _name def uptime(): with open('/proc/uptime') as fp: return int(fp.read().split('.')[0]) def mem_usage(): with open('/proc/meminfo') as fp: for line in fp: line = line.strip() if line.startswith("MemTotal:"): kb_mem_total = int(line.split()[1]) if line.startswith("MemFree:"): kb_mem_free = int(line.split()[1]) if line.startswith("Buffers:"): kb_main_buffers = int(line.split()[1]) if line.startswith("Cached:"): kb_main_cached = int(line.split()[1]) kb_mem_used = kb_mem_total - kb_mem_free - kb_main_cached - kb_main_buffers return round(kb_mem_used / kb_mem_total, 1) return 0 def _cpu_stat(): """ Returns the splitted first line of the /proc/stat file """ with open('/proc/stat', 'rt') as fp: return list(map(int,fp.readline().split()[1:])) def cpu_load(s=0.1): """ Returns the average cpuload over a 's'-seconds period """ parts0 = _cpu_stat() time.sleep(s) parts1 = _cpu_stat() parts_diff = [p1 - p0 for (p0, p1) in zip(parts0, parts1)] user, nice, sys, idle, iowait, irq, softirq, steal, _guest, _guest_nice = parts_diff idle_sum = idle + iowait non_idle_sum = user + nice + sys + irq + softirq + steal total = idle_sum + non_idle_sum return non_idle_sum / total def temperature(celsius=True): with open('/sys/class/thermal/thermal_zone0/temp', 'rt') as fp: temp = int(fp.read().strip()) c = int(temp / 1000) return c if celsius else ((c * (9 / 5)) + 32) def get_wifi_status(): try: parts = os.popen("sudo ip -br addr show wlan0").read().split() state = parts[1] ip = parts[2].split('/')[0] return state, ip except: return None, None def get_wifi_ssid(): try: ssid = os.popen("sudo iwgetid -r").read().split('\n')[0] return ssid except: return "" def set_wifi_onoff(): wifi_updown = get_wifi_status()[0] if wifi_updown == 'UP': # switch wifi off subprocess.call(['sudo', 'ip', 'link', 'set', 'dev', 'wlan0', 'down']) subprocess.call(['sudo', 'dhclient', '-r', 'wlan0']) subprocess.call(['sudo', 'rfkill', 'block', '0']) return False else: # switch wifi on subprocess.call(['sudo', 'rfkill', 'unblock', '0']) subprocess.call(['sudo', 'dhclient', 'wlan0']) subprocess.call(['sudo', 'ip', 'link', 'set', 'dev', 'wlan0', 'up']) return True def shutdown(): logging.warning("shutting down ...") from stenogotchi.ui import view if view.ROOT: view.ROOT.on_shutdown() # give it some time to refresh the ui time.sleep(5) if view.ROOT._config['ui']['display']['enabled']: if view.ROOT._config['ui']['display']['clear_at_shutdown']: view.ROOT._agent._view.init_display() view.ROOT._agent._view.clear() # give it some time to clear the ui time.sleep(5) logging.warning("syncing...") from stenogotchi import fs for m in fs.mounts: m.sync() os.system("sync") os.system("halt") def restart(mode): logging.warning("restarting in %s mode ...", mode) if mode == 'AUTO': os.system("touch /root/.stenogotchi-auto") else: os.system("touch /root/.stenogotchi-manual") #os.system("service stenogotchi restart") def reboot(mode=None): if mode is not None: mode = mode.upper() logging.warning("rebooting in %s mode ...", mode) else: logging.warning("rebooting ...") from stenogotchi.ui import view if view.ROOT: view.ROOT.on_rebooting() # give it some time to refresh the ui time.sleep(10) if mode == 'AUTO': os.system("touch /root/.stenogotchi-auto") elif mode == 'MANU': os.system("touch /root/.stenogotchi-manual") logging.warning("syncing...") from stenogotchi import fs for m in fs.mounts: m.sync() os.system("sync") os.system("shutdown -r now") ================================================ FILE: stenogotchi/_version.py ================================================ __version__ = '0.1.0' ================================================ FILE: stenogotchi/agent.py ================================================ import time import json import os import re import logging import asyncio import _thread import stenogotchi import stenogotchi.utils as utils import stenogotchi.plugins as plugins from stenogotchi.ui.web.server import Server from stenogotchi.automata import Automata #from stenogotchi.log import LastSession RECOVERY_DATA_FILE = '/root/.stenogotchi-recovery' class Agent(Automata): def __init__(self, view, config): Automata.__init__(self, config, view) self._started_at = time.time() self._view = view self._view.set_agent(self) self._web_ui = None self._wifi_connected = None self._history = {} #self.last_session = LastSession(self._config) self.mode = 'auto' logging.info("%s (v%s)", stenogotchi.name(), stenogotchi.__version__) for _, plugin in plugins.loaded.items(): logging.debug("plugin '%s' v%s", plugin.__class__.__name__, plugin.__version__) def config(self): return self._config def view(self): return self._view def start(self): self.set_starting() # self.start_event_polling() # print initial stats self.start_session_fetcher() self.set_ready() def _update_uptime(self): secs = stenogotchi.uptime() self._view.set('uptime', utils.secs_to_hhmmss(secs)) def _update_wifi(self): status, ip = stenogotchi.get_wifi_status() if status == 'UP': if self._wifi_connected: return ssid = stenogotchi.get_wifi_ssid() if ssid and ip: self._wifi_connected = True # Create web-ui only once a wifi connection is established if not self._web_ui: self._web_ui = Server(self, self._config['ui']) else: if not ssid: ssid = "[Searching]" ip = "the ghost" if not ip: ip = "the ghost" self._wifi_connected = False self.set_wifi_connected(ssid, ip) elif status == 'DOWN' or not status: if self._wifi_connected == False: return self._wifi_connected = False self.set_wifi_disconnected('[OFF]') def _reboot(self): self.set_rebooting() self._save_recovery_data() stenogotchi.reboot() def _save_recovery_data(self): logging.warning("writing recovery data to %s ...", RECOVERY_DATA_FILE) with open(RECOVERY_DATA_FILE, 'w') as fp: data = { 'started_at': self._started_at, 'history': self._history, } json.dump(data, fp) def _load_recovery_data(self, delete=True, no_exceptions=True): try: with open(RECOVERY_DATA_FILE, 'rt') as fp: data = json.load(fp) logging.info("found recovery data: %s", data) self._started_at = data['started_at'] self._history = data['history'] if delete: logging.info("deleting %s", RECOVERY_DATA_FILE) os.unlink(RECOVERY_DATA_FILE) except: if not no_exceptions: raise def start_session_fetcher(self): _thread.start_new_thread(self._fetch_stats, ()) async def plover_status_update(self, msg): pass def _fetch_stats(self): while True: #s = self.session() # this is just the request object for connection to bettercap -> to be used for fetching plover stats instead self._update_uptime() self._update_wifi() time.sleep(30) async def _on_event(self, msg): # no bettercap to produce events for us, to be used for plover events pass def _event_poller(self, loop): self._load_recovery_data() #while True: def start_event_polling(self): # start a thread and pass in the mainloop _thread.start_new_thread(self._event_poller, (asyncio.get_event_loop(),)) ================================================ FILE: stenogotchi/automata.py ================================================ import logging import stenogotchi.plugins as plugins # basic mood system class Automata(object): def __init__(self, config, view): self._config = config self._view = view def set_starting(self): self._view.on_starting() def set_ready(self): plugins.on('ready', self) def in_good_mood(self): pass # triggered when it's a sad/bad day but you have good friends around ^_^ def set_grateful(self): self._view.on_grateful() plugins.on('grateful', self) def set_lonely(self): self._view.on_lonely() plugins.on('lonely', self) def set_bored(self): self._view.on_bored() plugins.on('bored', self) def set_sad(self): self._view.on_sad() plugins.on('sad', self) def set_angry(self): self._view.on_angry() plugins.on('angry', self) def set_excited(self): self._view.on_excited() plugins.on('excited', self) def set_processing(self): self._view.on_processing() plugins.on('processing', self) def set_rebooting(self): self._view.on_rebooting() plugins.on('rebooting', self) def wait_for(self, t, sleeping=True): self._view.wait(t, sleeping) def set_plover_boot(self): self._view.on_plover_boot() plugins.on('plover_boot', self) def set_plover_ready(self): self._view.on_plover_ready() plugins.on('plover_ready', self) def set_plover_quit(self): self._view.on_plover_quit() plugins.on('plover_quit', self) def set_bt_connected(self, bthost_name): self._view.set('bthost', bthost_name) self._view.on_bt_connected(bthost_name) plugins.on('bt_connected', self, bthost_name) def set_bt_disconnected(self): self._view.set('bthost', "") self._view.on_bt_disconnected() plugins.on('bt_disconnected', self) def set_wifi_connected(self, ssid, ip): self._view.set('wifi', ssid) self._view.on_wifi_connected(ssid, ip) plugins.on('wifi_connected', self, ssid, ip) def set_wifi_disconnected(self, ssid=''): self._view.set('wifi', ssid) self._view.on_wifi_disconnected() plugins.on('wifi_disconnected', self) def set_wpm(self, wpm, wpm_top): stats = '{:3s} {:5s}'.format(str(wpm), f'({wpm_top})') self._view.on_wpm(stats) plugins.on('wpm_set', self) def set_strokes(self, stats): self._view.on_strokes(stats) plugins.on('strokes_set', self) def set_wpm_record(self, wpm_top): self._view.on_wpm_record(wpm_top) plugins.on('wpm_record', self) self.set_wpm(wpm_top, wpm_top) # Since we set a new record wpm and wpm_top will be equal def set_on_dict_lookup_done(self): self._view.on_dict_lookup_done() plugins.on('dict_lookup_done', self) ================================================ FILE: stenogotchi/defaults.toml ================================================ # This is a TOML document # This is the default config template. To change the default values, please save your modifications to /etc/stenogotchi/config.toml # Main main.name = "Stenogotchi" main.lang = "en" main.confd = "/etc/stenogotchi/conf.d/" main.custom_plugins = "" # Filesystem fs.memory.enabled = false fs.memory.mounts.log.enabled = false fs.memory.mounts.log.mount = "/var/log" fs.memory.mounts.log.size = "50M" fs.memory.mounts.log.sync = 60 fs.memory.mounts.log.zram = true fs.memory.mounts.log.rsync = true fs.memory.mounts.data.enabled = false fs.memory.mounts.data.mount = "/var/tmp/stenogotchi" fs.memory.mounts.data.size = "10M" fs.memory.mounts.data.sync = 3600 fs.memory.mounts.data.zram = false fs.memory.mounts.data.rsync = true # UI # The lifespan of an eINK display depends on the cumulative number of refreshes. To preserve your display # over time it is recommended to keep ui.fps value 0.0 so that refreshes only take place upon major updates. # Uptime, battery status, the blinking cursor and similar will not trigger a refresh ui.fps = 0.0 ui.font.name = "DejaVuSansMono" ui.font.size_offset = 0 # added to the font size ui.faces.look_r = "( ⚆_⚆)" ui.faces.look_l = "(☉_☉ )" ui.faces.look_r_happy = "( ◕‿◕)" ui.faces.look_l_happy = "(◕‿◕ )" ui.faces.sleep = "(⇀‿‿↼)" ui.faces.sleep2 = "(≖‿‿≖)" ui.faces.awake = "(◕‿‿◕)" ui.faces.bored = "(-__-)" ui.faces.intense = "(°▃▃°)" ui.faces.cool = "(⌐■_■)" ui.faces.happy = "(•‿‿•)" ui.faces.excited = "(ᵔ◡◡ᵔ)" ui.faces.grateful = "(^‿‿^)" ui.faces.motivated = "(☼‿‿☼)" ui.faces.demotivated = "(≖__≖)" ui.faces.smart = "(✜‿‿✜)" ui.faces.lonely = "(ب__ب)" ui.faces.sad = "(╥☁╥ )" ui.faces.angry = "(-_-')" ui.faces.friend = "(♥‿‿♥)" ui.faces.broken = "(☓‿‿☓)" ui.faces.debug = "(#__#)" ui.faces.process_1 = "(1__0)" ui.faces.process_2 = "(1__1)" ui.faces.process_3 = "(0__1)" ui.faces.process_4 = "(0__0)" ui.display.enabled = true ui.display.rotation = 0 ui.display.type = "waveshare_2" ui.display.color = "black" ui.display.clear_at_shutdown = true # The web UI provides an alternative way of interacting with the device to an eINK display and ButtonSHIM module. Enabling the web UI will also load the buttonshim plugin. ui.web.enabled = false ui.web.address = "192.168.1.100" ui.web.username = "changeme" ui.web.password = "changeme" ui.web.origin = "" ui.web.port = 80 ui.web.on_frame = "" # Plugins # Populate bt_autoconnect_mac with the MAC addresses of devices to attempt auto connecting to at boot. Multiple comma-separated devices in order of priority can be given. # After exhausting the options, the device will fall back to listening for incoming connection and pairing attempts. ## Example: ## main.plugins.plover_link.bt_autoconnect_mac = "00:DE:AD:BE:EF:00, 11:DE:AD:BE:EF:11" main.plugins.plover_link.enabled = true main.plugins.plover_link.bt_autoconnect_mac = "" main.plugins.plover_link.bt_device_name = "Stenogotchi" # For WPM readings a word is defined in one of three ways: ## NCRA: The National Court Reporters Association defines a “word” as 1.4 syllables. This is the measure used for official NCRA testing material. ## Traditional: The traditional metric for “word” in the context of keyboarding is defined to be 5 characters per word, including spaces. This is compatible with the notion of “word” in many typing speed utilities. ## Spaces: A word is a whitespace-separated sequence of characters. This metric of course doesn’t take into account the fact that some words are longer than others, both in length and syllables. # Specify either "ncra", "traditional" or "spaces" as preferred method using main.plugins.plover_link.wpm_method # Specify in seconds moving time window for which wpm is calculated and updated using main.plugins.plover_link.wpm_timeout main.plugins.plover_link.wpm_method = "traditional" main.plugins.plover_link.wpm_timeout = "60" # Plugin evdevkb is responsible for direct keyboard capturing, bypassing Plover. Input mode toggleable when enabled. main.plugins.evdevkb.enabled = true # Plugin dict_lookup handles dictionary lookup from all enabled Plover dictionaries. main.plugins.dict_lookup.enabled = true # Plugin upslite provides battery level, charging indicator (+) and auto-shutdown at specified battery level when a UPS-Lite v1.2 module is connected. main.plugins.upslite.enabled = false main.plugins.upslite.shutdown_level = 5 # Plugin buttonshim enables a way to interact with the device using the five physical buttons on the ButtonSHIM hardware module or through the web UI. Enabling the web UI will automatically load the buttonshim plugin. # Each button has a predefined long-press (1s) action. The short-press action can be freely configured to any terminal command. main.plugins.buttonshim.enabled = false main.plugins.buttonshim.buttons.A.command = "" main.plugins.buttonshim.buttons.A.blink.enabled = false main.plugins.buttonshim.buttons.A.blink.red = 0 main.plugins.buttonshim.buttons.A.blink.green = 0 main.plugins.buttonshim.buttons.A.blink.blue = 0 main.plugins.buttonshim.buttons.A.blink.on_time = 0 main.plugins.buttonshim.buttons.A.blink.off_time = 0 main.plugins.buttonshim.buttons.A.blink.blink_times = 0 main.plugins.buttonshim.buttons.B.command = "" main.plugins.buttonshim.buttons.B.blink.enabled = false main.plugins.buttonshim.buttons.B.blink.red = 0 main.plugins.buttonshim.buttons.B.blink.green = 0 main.plugins.buttonshim.buttons.B.blink.blue = 0 main.plugins.buttonshim.buttons.B.blink.on_time = 0 main.plugins.buttonshim.buttons.B.blink.off_time = 0 main.plugins.buttonshim.buttons.B.blink.blink_times = 0 main.plugins.buttonshim.buttons.C.command = "" main.plugins.buttonshim.buttons.C.blink.enabled = false main.plugins.buttonshim.buttons.C.blink.red = 0 main.plugins.buttonshim.buttons.C.blink.green = 0 main.plugins.buttonshim.buttons.C.blink.blue = 0 main.plugins.buttonshim.buttons.C.blink.on_time = 0 main.plugins.buttonshim.buttons.C.blink.off_time = 0 main.plugins.buttonshim.buttons.C.blink.blink_times = 0 main.plugins.buttonshim.buttons.D.command = "" main.plugins.buttonshim.buttons.D.blink.enabled = false main.plugins.buttonshim.buttons.D.blink.red = 0 main.plugins.buttonshim.buttons.D.blink.green = 0 main.plugins.buttonshim.buttons.D.blink.blue = 0 main.plugins.buttonshim.buttons.D.blink.on_time = 0 main.plugins.buttonshim.buttons.D.blink.off_time = 0 main.plugins.buttonshim.buttons.D.blink.blink_times = 0 main.plugins.buttonshim.buttons.E.command = "" main.plugins.buttonshim.buttons.E.blink.enabled = false main.plugins.buttonshim.buttons.E.blink.red = 0 main.plugins.buttonshim.buttons.E.blink.green = 0 main.plugins.buttonshim.buttons.E.blink.blue = 0 main.plugins.buttonshim.buttons.E.blink.on_time = 0 main.plugins.buttonshim.buttons.E.blink.off_time = 0 main.plugins.buttonshim.buttons.E.blink.blink_times = 0 # Plugin led provides an alternative way to be alerted to events through the built-in LED on the rpi0w without the need of a display. main.plugins.led.enabled = false main.plugins.led.led = 0 main.plugins.led.delay = 200 main.plugins.led.patterns.loaded = "" main.plugins.led.patterns.updating = "" main.plugins.led.patterns.ready = "" main.plugins.led.patterns.grateful = "" main.plugins.led.patterns.lonely = "" main.plugins.led.patterns.bored = "" main.plugins.led.patterns.sad = "" main.plugins.led.patterns.angry = "" main.plugins.led.patterns.excited = "" main.plugins.led.patterns.rebooting = "oo oo oo oo oo" main.plugins.led.patterns.wait = "" main.plugins.led.patterns.sleep = "" main.plugins.led.patterns.epoch = "" main.plugins.led.patterns.bt_connected = "oo oo oo oo oo" main.plugins.led.patterns.bt_disconnected = "oo oo oo oo oo" main.plugins.led.patterns.plover_boot = "" main.plugins.led.patterns.plover_ready = "oo oo oo oo oo" main.plugins.led.patterns.plover_quit = "oo oo oo oo oo" main.plugins.led.patterns.wifi_connected = "oo oo oo oo oo" main.plugins.led.patterns.wifi_disconnected = "oo oo oo oo oo" main.plugins.led.patterns.wpm_set = "" main.plugins.led.patterns.strokes_set = "" # Plugin memtemp displays CPU temperature and RAM/CPU usage. main.plugins.memtemp.enabled = false main.plugins.memtemp.scale = "celsius" main.plugins.memtemp.orientation = "horizontal" # Plugin logtail gives access to the Stenogotchi log through the web UI. main.plugins.logtail.enabled = false main.plugins.logtail.max-lines = 10000 main.plugins.session-stats.enabled = false main.plugins.session-stats.save_directory = "/var/tmp/stenogotchi/sessions/" # Log main.log.path = "/var/log/stenogotchi.log" main.log.rotation.enabled = true main.log.rotation.size = "10M" ================================================ FILE: stenogotchi/fs/__init__.py ================================================ import os import re import tempfile import contextlib import shutil import _thread import logging from time import sleep from distutils.dir_util import copy_tree mounts = list() @contextlib.contextmanager def ensure_write(filename, mode='w'): path = os.path.dirname(filename) fd, tmp = tempfile.mkstemp(dir=path) with os.fdopen(fd, mode) as f: yield f f.flush() os.fsync(f.fileno()) os.replace(tmp, filename) def size_of(path): """ Calculate the sum of all the files in path """ total = 0 for root, _, files in os.walk(path): for f in files: total += os.path.getsize(os.path.join(root, f)) return total def is_mountpoint(path): """ Checks if path is mountpoint """ return os.system(f"mountpoint -q {path}") == 0 def setup_mounts(config): """ Sets up all the configured mountpoints """ global mounts fs_cfg = config['fs']['memory'] if not fs_cfg['enabled']: return for name, options in fs_cfg['mounts'].items(): if not options['enabled']: continue logging.debug("[FS] Trying to setup mount %s (%s)", name, options['mount']) size,unit = re.match(r"(\d+)([a-zA-Z]+)", options['size']).groups() target = os.path.join('/run/stenogotchi/disk/', os.path.basename(options['mount'])) is_mounted = is_mountpoint(target) logging.debug("[FS] %s is %s mounted", options['mount'], "already" if is_mounted else "not yet") m = MemoryFS( options['mount'], target, size=options['size'], zram=options['zram'], zram_disk_size=f"{int(size)*2}{unit}", rsync=options['rsync']) if not is_mounted: if not m.mount(): logging.error(f"Error while mounting {m.mountpoint}") continue if not m.sync(to_ram=True): logging.error(f"Error while syncing to {m.mountpoint}") m.umount() continue interval = int(options['sync']) if interval: logging.debug("[FS] Starting thread to sync %s (interval: %d)", options['mount'], interval) _thread.start_new_thread(m.daemonize, (interval,)) else: logging.debug("[FS] Not syncing %s, because interval is 0", options['mount']) mounts.append(m) class MemoryFS: @staticmethod def zram_install(): if not os.path.exists("/sys/class/zram-control"): logging.debug("[FS] Installing zram") return os.system("modprobe zram") == 0 return True @staticmethod def zram_dev(): logging.debug("[FS] Adding zram device") return open("/sys/class/zram-control/hot_add", "rt").read().strip("\n") def __init__(self, mount, disk, size="40M", zram=True, zram_alg="lz4", zram_disk_size="100M", zram_fs_type="ext4", rsync=True): self.mountpoint = mount self.disk = disk self.size = size self.zram = zram self.zram_alg = zram_alg self.zram_disk_size = zram_disk_size self.zram_fs_type = zram_fs_type self.zdev = None self.rsync = True self._setup() def _setup(self): if self.zram and MemoryFS.zram_install(): # setup zram self.zdev = MemoryFS.zram_dev() open(f"/sys/block/zram{self.zdev}/comp_algorithm", "wt").write(self.zram_alg) open(f"/sys/block/zram{self.zdev}/disksize", "wt").write(self.zram_disk_size) open(f"/sys/block/zram{self.zdev}/mem_limit", "wt").write(self.size) logging.debug("[FS] Creating fs (type: %s)", self.zram_fs_type) os.system(f"mke2fs -t {self.zram_fs_type} /dev/zram{self.zdev} >/dev/null 2>&1") # ensure mountpoints exist if not os.path.exists(self.disk): logging.debug("[FS] Creating %s", self.disk) os.makedirs(self.disk) if not os.path.exists(self.mountpoint): logging.debug("[FS] Creating %s", self.mountpoint) os.makedirs(self.mountpoint) def daemonize(self, interval=60): logging.debug("[FS] Daemonized...") while True: self.sync() sleep(interval) def sync(self, to_ram=False): source, dest = (self.disk, self.mountpoint) if to_ram else (self.mountpoint, self.disk) needed, actually_free = size_of(source), shutil.disk_usage(dest)[2] if actually_free >= needed: logging.debug("[FS] Syncing %s -> %s", source,dest) if self.rsync: os.system(f"rsync -aXv --inplace --no-whole-file --delete-after {source}/ {dest}/ >/dev/null 2>&1") else: copy_tree(source, dest, preserve_symlinks=True) os.system("sync") return True return False def mount(self): if os.system(f"mount --bind {self.mountpoint} {self.disk}"): return False if os.system(f"mount --make-private {self.disk}"): return False if self.zram and self.zdev is not None: if os.system(f"mount -t {self.zram_fs_type} -o nosuid,noexec,nodev,user=stenogotchi /dev/zram{self.zdev} {self.mountpoint}/"): return False else: if os.system(f"mount -t tmpfs -o nosuid,noexec,nodev,mode=0755,size={self.size} stenogotchi {self.mountpoint}/"): return False return True def umount(self): if os.system(f"umount -l {self.mountpoint}"): return False if os.system(f"umount -l {self.disk}"): return False return True ================================================ FILE: stenogotchi/log.py ================================================ import hashlib import time import re import os import logging import shutil import gzip import warnings from datetime import datetime from stenogotchi.voice import Voice from file_read_backwards import FileReadBackwards LAST_SESSION_FILE = '/root/.stenogotchi-last-session' class LastSession(object): START_TOKEN = 'connecting to http' def __init__(self, config): self.config = config self.voice = Voice(lang=config['main']['lang']) self.path = config['main']['log']['path'] self.last_session = [] self.last_session_id = '' self.last_saved_session_id = '' self.duration = '' self.duration_human = '' def _get_last_saved_session_id(self): saved = '' try: with open(LAST_SESSION_FILE, 'rt') as fp: saved = fp.read().strip() except: saved = '' return saved def save_session_id(self): with open(LAST_SESSION_FILE, 'w+t') as fp: fp.write(self.last_session_id) self.last_saved_session_id = self.last_session_id def _parse_datetime(self, dt): dt = dt.split('.')[0] dt = dt.split(',')[0] dt = datetime.strptime(dt.split('.')[0], '%Y-%m-%d %H:%M:%S') return time.mktime(dt.timetuple()) def _parse_stats(self): self.duration = '' self.duration_human = '' started_at = None stopped_at = None cache = {} for line in self.last_session: parts = line.split(']') if len(parts) < 2: continue try: line_timestamp = parts[0].strip('[') line = ']'.join(parts[1:]) stopped_at = self._parse_datetime(line_timestamp) if started_at is None: started_at = stopped_at except Exception as e: logging.error("error parsing line '%s': %s" % (line, e)) if started_at is not None: self.duration = stopped_at - started_at mins, secs = divmod(self.duration, 60) hours, mins = divmod(mins, 60) else: hours = mins = secs = 0 self.duration = '%02d:%02d:%02d' % (hours, mins, secs) self.duration_human = [] if hours > 0: self.duration_human.append('%d %s' % (hours, self.voice.hhmmss(hours, 'h'))) if mins > 0: self.duration_human.append('%d %s' % (mins, self.voice.hhmmss(mins, 'm'))) if secs > 0: self.duration_human.append('%d %s' % (secs, self.voice.hhmmss(secs, 's'))) self.duration_human = ', '.join(self.duration_human) def parse(self, ui, skip=False): if skip: logging.debug("skipping parsing of the last session logs ...") else: logging.debug("reading last session logs ...") ui.on_reading_logs() lines = [] if os.path.exists(self.path): with FileReadBackwards(self.path, encoding="utf-8") as fp: for line in fp: line = line.strip() if line != "" and line[0] != '[': continue lines.append(line) if LastSession.START_TOKEN in line: break lines_so_far = len(lines) if lines_so_far % 100 == 0: ui.on_reading_logs(lines_so_far) lines.reverse() if len(lines) == 0: lines.append("Initial Session"); ui.on_reading_logs() self.last_session = lines self.last_session_id = hashlib.md5(lines[0].encode()).hexdigest() self.last_saved_session_id = self._get_last_saved_session_id() logging.debug("parsing last session logs (%d lines) ..." % len(lines)) self._parse_stats() self.parsed = True def is_new(self): return self.last_session_id != self.last_saved_session_id def setup_logging(args, config): cfg = config['main']['log'] filename = cfg['path'] formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s") root = logging.getLogger() root.setLevel(logging.DEBUG if args.debug else logging.INFO) if filename: # since python default log rotation might break session data in different files, # we need to do log rotation ourselves log_rotation(filename, cfg) file_handler = logging.FileHandler(filename) file_handler.setFormatter(formatter) root.addHandler(file_handler) console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) root.addHandler(console_handler) def log_rotation(filename, cfg): rotation = cfg['rotation'] if not rotation['enabled']: return elif not os.path.isfile(filename): return stats = os.stat(filename) # specify a maximum size to rotate ( format is 10/10B, 10K, 10M 10G ) if rotation['size']: max_size = parse_max_size(rotation['size']) if stats.st_size >= max_size: do_rotate(filename, stats, cfg) else: raise Exception("log rotation is enabled but log.rotation.size was not specified") def parse_max_size(s): parts = re.findall(r'(^\d+)([bBkKmMgG]?)', s) if len(parts) != 1 or len(parts[0]) != 2: raise Exception("can't parse %s as a max size" % s) num, unit = parts[0] num = int(num) unit = unit.lower() if unit == 'k': return num * 1024 elif unit == 'm': return num * 1024 * 1024 elif unit == 'g': return num * 1024 * 1024 * 1024 else: return num def do_rotate(filename, stats, cfg): base_path = os.path.dirname(filename) name = os.path.splitext(os.path.basename(filename))[0] archive_filename = os.path.join(base_path, "%s.gz" % name) counter = 2 while os.path.exists(archive_filename): archive_filename = os.path.join(base_path, "%s-%d.gz" % (name, counter)) counter += 1 log_filename = archive_filename.replace('gz', 'log') print("%s is %d bytes big, rotating to %s ..." % (filename, stats.st_size, log_filename)) shutil.move(filename, log_filename) print("compressing to %s ..." % archive_filename) with open(log_filename, 'rb') as src: with gzip.open(archive_filename, 'wb') as dst: dst.writelines(src) ================================================ FILE: stenogotchi/plugins/__init__.py ================================================ import os import glob import _thread import threading import importlib, importlib.util import logging default_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "default") loaded = {} database = {} locks = {} class Plugin: @classmethod def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) global loaded, locks plugin_name = cls.__module__.split('.')[0] plugin_instance = cls() logging.debug("loaded plugin %s as %s" % (plugin_name, plugin_instance)) loaded[plugin_name] = plugin_instance for attr_name in plugin_instance.__dir__(): if attr_name.startswith('on_'): cb = getattr(plugin_instance, attr_name, None) if cb is not None and callable(cb): locks["%s::%s" % (plugin_name, attr_name)] = threading.Lock() def toggle_plugin(name, enable=True): """ Load or unload a plugin returns True if changed, otherwise False """ import stenogotchi from stenogotchi.ui import view from stenogotchi.utils import save_config global loaded, database # log event if enable: logging.debug(f"enabled plugin '{name}' in config") else: logging.debug(f"disabled plugin '{name}' in config") if stenogotchi.config: if not name in stenogotchi.config['main']['plugins']: stenogotchi.config['main']['plugins'][name] = dict() stenogotchi.config['main']['plugins'][name]['enabled'] = enable save_config(stenogotchi.config, '/etc/stenogotchi/config.toml') if not enable and name in loaded: if getattr(loaded[name], 'on_unload', None): loaded[name].on_unload(view.ROOT) del loaded[name] return True if enable and name in database and name not in loaded: load_from_file(database[name]) if name in loaded and stenogotchi.config and name in stenogotchi.config['main']['plugins']: loaded[name].options = stenogotchi.config['main']['plugins'][name] one(name, 'loaded') if stenogotchi.config: one(name, 'config_changed', stenogotchi.config) one(name, 'ui_setup', view.ROOT) one(name, 'ready', view.ROOT._agent) return True return False def on(event_name, *args, **kwargs): for plugin_name in loaded.keys(): one(plugin_name, event_name, *args, **kwargs) def locked_cb(lock_name, cb, *args, **kwargs): global locks if lock_name not in locks: locks[lock_name] = threading.Lock() with locks[lock_name]: cb(*args, *kwargs) def one(plugin_name, event_name, *args, **kwargs): global loaded if plugin_name in loaded: plugin = loaded[plugin_name] cb_name = 'on_%s' % event_name callback = getattr(plugin, cb_name, None) if callback is not None and callable(callback): try: lock_name = "%s::%s" % (plugin_name, cb_name) locked_cb_args = (lock_name, callback, *args, *kwargs) _thread.start_new_thread(locked_cb, locked_cb_args) except Exception as e: logging.error("error while running %s.%s : %s" % (plugin_name, cb_name, e)) logging.error(e, exc_info=True) def load_from_file(filename): logging.debug("loading %s" % filename) plugin_name = os.path.basename(filename.replace(".py", "")) spec = importlib.util.spec_from_file_location(plugin_name, filename) instance = importlib.util.module_from_spec(spec) spec.loader.exec_module(instance) return plugin_name, instance def load_from_path(path, enabled=()): global loaded, database logging.debug("loading plugins from %s - enabled: %s" % (path, enabled)) for filename in glob.glob(os.path.join(path, "*.py")): plugin_name = os.path.basename(filename.replace(".py", "")) database[plugin_name] = filename if plugin_name in enabled: try: load_from_file(filename) except Exception as e: logging.warning("error while loading %s: %s" % (filename, e)) logging.debug(e, exc_info=True) return loaded def load(config): enabled = [name for name, options in config['main']['plugins'].items() if 'enabled' in options and options['enabled']] # force enable buttonshim plugin when web ui is enabled logging.debug(f"REMOVEME pre status : '{enabled}'") if config['ui']['web']['enabled'] and 'buttonshim' not in enabled: enabled.append('buttonshim') logging.debug(f"REMOVEME post status : '{enabled}'") # load default plugins load_from_path(default_path, enabled=enabled) # load custom ones custom_path = config['main']['custom_plugins'] if 'custom_plugins' in config['main'] else None if custom_path is not None: load_from_path(custom_path, enabled=enabled) # propagate options for name, plugin in loaded.items(): plugin.options = config['main']['plugins'][name] print("loaded plugin:", name, plugin) on('loaded') on('config_changed', config) ================================================ FILE: stenogotchi/plugins/cmd.py ================================================ # Handles the commandline stuff import os import logging import glob import re import shutil from fnmatch import fnmatch from stenogotchi.plugins import default_path SAVE_DIR = '/usr/local/share/stenogotchi/availaible-plugins/' DEFAULT_INSTALL_PATH = '/usr/local/share/stenogotchi/installed-plugins/' def add_parsers(parser): """ Adds the plugins subcommand to a given argparse.ArgumentParser """ subparsers = parser.add_subparsers() ## pwnagotchi plugins parser_plugins = subparsers.add_parser('plugins') plugin_subparsers = parser_plugins.add_subparsers(dest='plugincmd') ## pwnagotchi plugins search parser_plugins_search = plugin_subparsers.add_parser('search', help='Search for stenogotchi plugins') parser_plugins_search.add_argument('pattern', type=str, help="Search expression (wildcards allowed)") ## pwnagotchi plugins list parser_plugins_list = plugin_subparsers.add_parser('list', help='List available stenogotchi plugins') parser_plugins_list.add_argument('-i', '--installed', action='store_true', required=False, help='List also installed plugins') ## pwnagotchi plugins update parser_plugins_update = plugin_subparsers.add_parser('update', help='Updates the database') ## pwnagotchi plugins upgrade parser_plugins_upgrade = plugin_subparsers.add_parser('upgrade', help='Upgrades plugins') parser_plugins_upgrade.add_argument('pattern', type=str, nargs='?', default='*', help="Filter expression (wildcards allowed)") ## pwnagotchi plugins enable parser_plugins_enable = plugin_subparsers.add_parser('enable', help='Enables a plugin') parser_plugins_enable.add_argument('name', type=str, help='Name of the plugin') ## pwnagotchi plugins disable parser_plugins_disable = plugin_subparsers.add_parser('disable', help='Disables a plugin') parser_plugins_disable.add_argument('name', type=str, help='Name of the plugin') ## pwnagotchi plugins install parser_plugins_install = plugin_subparsers.add_parser('install', help='Installs a plugin') parser_plugins_install.add_argument('name', type=str, help='Name of the plugin') ## pwnagotchi plugins uninstall parser_plugins_uninstall = plugin_subparsers.add_parser('uninstall', help='Uninstalls a plugin') parser_plugins_uninstall.add_argument('name', type=str, help='Name of the plugin') ## pwnagotchi plugins edit parser_plugins_edit = plugin_subparsers.add_parser('edit', help='Edit the options') parser_plugins_edit.add_argument('name', type=str, help='Name of the plugin') return parser def used_plugin_cmd(args): """ Checks if the plugins subcommand was used """ return hasattr(args, 'plugincmd') def handle_cmd(args, config): """ Parses the arguments and does the thing the user wants """ if args.plugincmd == 'update': return update(config) elif args.plugincmd == 'search': args.installed = True # also search in installed plugins return list_plugins(args, config, args.pattern) elif args.plugincmd == 'install': return install(args, config) elif args.plugincmd == 'uninstall': return uninstall(args, config) elif args.plugincmd == 'list': return list_plugins(args, config) elif args.plugincmd == 'enable': return enable(args, config) elif args.plugincmd == 'disable': return disable(args, config) elif args.plugincmd == 'upgrade': return upgrade(args, config, args.pattern) elif args.plugincmd == 'edit': return edit(args, config) raise NotImplementedError() def edit(args, config): pass def enable(args, config): pass def disable(args, config): pass def upgrade(args, config, pattern='*'): pass def list_plugins(args, config, pattern='*'): """ Lists the available and installed plugins """ installed = _get_installed(config) print(installed) return 0 def _extract_version(filename): """ Extracts the version from a python file """ return None def _get_available(): """ Get all availaible plugins """ return None def _get_installed(config): """ Get all installed plugins """ installed = dict() search_dirs = [ default_path, config['main']['custom_plugins'] ] for search_dir in search_dirs: if search_dir: for filename in glob.glob(os.path.join(search_dir, "*.py")): plugin_name = os.path.basename(filename.replace(".py", "")) installed[plugin_name] = filename return installed def uninstall(args, config): """ Uninstalls a plugin """ return 0 def install(args, config): """ Installs the given plugin """ return 0 def _analyse_dir(path): return None def update(config): """ Updates the database """ return 0 ================================================ FILE: stenogotchi/plugins/default/buttonshim.py ================================================ # ############################################################### # Updated 20-03-2021 by Anodynous # - Moved all functionality into class to remove dependency on global variables and improve integration with other plugins and core Stenogotchi functionality. # # Updated 13-01-2021 by Anodynous # - Added support for hold action in addition to press. # Based on: https://github.com/evilsocket/pwnagotchi-plugins-contrib/blob/master/buttonshim.py # which in turn is based on https://github.com/pimoroni/button-shim/commit/143f35b4b56626bd7062bdff3245658af19822b4 # ################################################################ import logging import RPi.GPIO as GPIO import subprocess import signal import smbus import time from threading import Thread import atexit from colorsys import hsv_to_rgb import stenogotchi import stenogotchi.plugins as plugins try: import queue except ImportError: import Queue as queue ADDR = 0x3f LED_DATA = 7 LED_CLOCK = 6 REG_INPUT = 0x00 REG_OUTPUT = 0x01 REG_POLARITY = 0x02 REG_CONFIG = 0x03 NUM_BUTTONS = 5 BUTTON_A = 0 """Button A""" BUTTON_B = 1 """Button B""" BUTTON_C = 2 """Button C""" BUTTON_D = 3 """Button D""" BUTTON_E = 4 """Button E""" NAMES = ['A', 'B', 'C', 'D', 'E'] """Sometimes you want to print the plain text name of the button that's triggered. You can use:: buttonshim.NAMES[button_index] To accomplish this. """ ERROR_LIMIT = 10 FPS = 60 LED_GAMMA = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 11, 11, 11, 12, 12, 13, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 21, 21, 22, 22, 23, 23, 24, 25, 25, 26, 27, 27, 28, 29, 29, 30, 31, 31, 32, 33, 34, 34, 35, 36, 37, 37, 38, 39, 40, 40, 41, 42, 43, 44, 45, 46, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 76, 77, 78, 79, 80, 81, 83, 84, 85, 86, 88, 89, 90, 91, 93, 94, 95, 96, 98, 99, 100, 102, 103, 104, 106, 107, 109, 110, 111, 113, 114, 116, 117, 119, 120, 121, 123, 124, 126, 128, 129, 131, 132, 134, 135, 137, 138, 140, 142, 143, 145, 146, 148, 150, 151, 153, 155, 157, 158, 160, 162, 163, 165, 167, 169, 170, 172, 174, 176, 178, 179, 181, 183, 185, 187, 189, 191, 193, 194, 196, 198, 200, 202, 204, 206, 208, 210, 212, 214, 216, 218, 220, 222, 224, 227, 229, 231, 233, 235, 237, 239, 241, 244, 246, 248, 250, 252, 255] # The LED is an APA102 driven via the i2c IO expander. # We must set and clear the Clock and Data pins # Each byte in self._reg_queue represents a snapshot of the pin state class Handler(): plugin = None def __init__(self, plugin): self.press = None self.release = None self.hold = None self.hold_time = 0 self.repeat = False self.repeat_time = 0 self.t_pressed = 0 self.t_repeat = 0 self.hold_fired = False self.plugin = plugin class Buttonshim(plugins.Plugin): __author__ = 'gon@o2online.de, Anodynous' __version__ = '0.0.2' __license__ = 'GPL3' __description__ = 'Pimoroni Button Shim GPIO Button and RGB LED support plugin based on the pimoroni-buttonshim-lib and the pwnagotchi-gpio-buttons-plugin' def __init__(self): self._agent = None self.running = False self.options = dict() self._running = False self._plover_wpm_meters_enabled = False self._states = None self._bus = None self._reg_queue = [] self._update_queue = [] self._brightness = 0.5 self._led_queue = queue.Queue() self._t_poll = None self._running = False self._states = 0b00011111 self._handlers = [None,None,None,None,None] self._button_was_held = False def on_loaded(self): logging.info("[buttonshim] GPIO Button plugin loaded.") self.running = True self._handlers = [Handler(self) for x in range(NUM_BUTTONS)] self.on_press([BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D, BUTTON_E], self.press_handler) self.on_hold([BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D, BUTTON_E], self.hold_handler) self.on_release([BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D, BUTTON_E], self.release_handler) def on_config_changed(self, config): self.config = config def on_ready(self, agent): self._agent = agent def set_ui_update(self, key, value): self._agent.view().set(key, value) def trigger_ui_update(self): self._agent.view().update() def _run(self): self._running = True _last_states = 0b00011111 _errors = 0 while self._running: led_data = None try: led_data = self._led_queue.get(False) self._led_queue.task_done() except queue.Empty: pass try: if led_data: for chunk in self._chunk(led_data, 32): self._bus.write_i2c_block_data(ADDR, REG_OUTPUT, chunk) self._states = self._bus.read_byte_data(ADDR, REG_INPUT) except IOError: _errors += 1 if _errors > ERROR_LIMIT: self._running = False raise IOError("More than {} IO errors have occurred!".format(ERROR_LIMIT)) for x in range(NUM_BUTTONS): last = (_last_states >> x) & 1 curr = (self._states >> x) & 1 handler = self._handlers[x] # If last > curr then it's a transition from 1 to 0 # since the buttons are active low, that's a press event if last > curr: handler.t_pressed = time.time() handler.hold_fired = False if callable(handler.press): handler.t_repeat = time.time() Thread(target=handler.press, args=(x, True, handler.plugin)).start() continue if last < curr and callable(handler.release): Thread(target=handler.release, args=(x, False, handler.plugin)).start() continue if curr == 0: if callable(handler.hold) and not handler.hold_fired and (time.time() - handler.t_pressed) > handler.hold_time: Thread(target=handler.hold, args=(x,)).start() handler.hold_fired = True if handler.repeat and callable(handler.press) and (time.time() - handler.t_repeat) > handler.repeat_time: self._handlers[x].t_repeat = time.time() Thread(target=self._handlers[x].press, args=(x, True, handler.plugin)).start() _last_states = self._states time.sleep(1.0 / FPS) def _quit(self): if self._running: self._led_queue.join() self.set_pixel(0, 0, 0) self._led_queue.join() self._running = False self._t_poll.join() def setup(self): if self._bus is not None: return try: self._bus = smbus.SMBus(1) self._bus.write_byte_data(ADDR, REG_CONFIG, 0b00011111) self._bus.write_byte_data(ADDR, REG_POLARITY, 0b00000000) self._bus.write_byte_data(ADDR, REG_OUTPUT, 0b00000000) self._t_poll = Thread(target=self._run) self._t_poll.daemon = True self._t_poll.start() self.set_pixel(0, 0, 0) atexit.register(self._quit) except OSError as ex: logging.error(f"[buttonshim] Ignore if no ButtonSHIM hw module present and web UI enabled: OSError encountered during setup: {ex}") def _set_bit(self, pin, value): if value: self._reg_queue[-1] |= (1 << pin) else: self._reg_queue[-1] &= ~(1 << pin) def _next(self): if len(self._reg_queue) == 0: self._reg_queue = [0b00000000] else: self._reg_queue.append(self._reg_queue[-1]) def _enqueue(self): self._led_queue.put(self._reg_queue) self._reg_queue = [] def _chunk(self, l, n): for i in range(0, len(l)+1, n): yield l[i:i + n] def _write_byte(self, byte): for i in range(8): self._next() self._set_bit(LED_CLOCK, 0) self._set_bit(LED_DATA, byte & 0b10000000) self._next() self._set_bit(LED_CLOCK, 1) byte <<= 1 def on_hold(self, buttons, handler=None, hold_time=1): """Attach a hold handler to one or more buttons. This handler is fired when you hold a button for hold_time seconds. When fired it will run in its own Thread. It will be passed one argument, the button index:: @buttonshim.on_hold(buttonshim.BUTTON_A) def handler(button): # Your code here :param buttons: A single button, or a list of buttons :param handler: Optional: a function to bind as the handler :param hold_time: Optional: the hold time in seconds (default 2) """ self.setup() if buttons is None: buttons = [BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D, BUTTON_E] if isinstance(buttons, int): buttons = [buttons] def attach_handler(handler): for button in buttons: self._handlers[button].hold = handler self._handlers[button].hold_time = hold_time if handler is not None: attach_handler(handler) else: return attach_handler def on_press(self, buttons, handler=None, repeat=False, repeat_time=0.5): """Attach a press handler to one or more buttons. This handler is fired when you press a button. When fired it will be run in its own Thread. It will be passed two arguments, the button index and a boolean indicating whether the button has been pressed/released:: @buttonshim.on_press(buttonshim.BUTTON_A) def handler(button, pressed): # Your code here :param buttons: A single button, or a list of buttons :param handler: Optional: a function to bind as the handler :param repeat: Optional: Repeat the handler if the button is held :param repeat_time: Optional: Time, in seconds, after which to repeat """ self.setup() if buttons is None: buttons = [BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D, BUTTON_E] if isinstance(buttons, int): buttons = [buttons] def attach_handler(handler): for button in buttons: self._handlers[button].press = handler self._handlers[button].repeat = repeat self._handlers[button].repeat_time = repeat_time if handler is not None: attach_handler(handler) else: return attach_handler def on_release(self, buttons=None, handler=None): """Attach a release handler to one or more buttons. This handler is fired when you let go of a button. When fired it will be run in its own Thread. It will be passed two arguments, the button index and a boolean indicating whether the button has been pressed/released:: @buttonshim.on_release(buttonshim.BUTTON_A) def handler(button, pressed): # Your code here :param buttons: A single button, or a list of buttons :param handler: Optional: a function to bind as the handler """ self.setup() if buttons is None: buttons = [BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D, BUTTON_E] if isinstance(buttons, int): buttons = [buttons] def attach_handler(handler): for button in buttons: self._handlers[button].release = handler if handler is not None: attach_handler(handler) else: return attach_handler def set_brightness(self, brightness): self.setup() if not isinstance(brightness, int) and not isinstance(brightness, float): raise ValueError("Brightness should be an int or float") if brightness < 0.0 or brightness > 1.0: raise ValueError("Brightness should be between 0.0 and 1.0") self._brightness = brightness def set_pixel(self, r, g, b): """Set the Button SHIM RGB pixel Display an RGB colour on the Button SHIM pixel. :param r: Amount of red, from 0 to 255 :param g: Amount of green, from 0 to 255 :param b: Amount of blue, from 0 to 255 You can use HTML colours directly with hexadecimal notation in Python. EG:: buttonshim.self.set_pixel(0xFF, 0x00, 0xFF) """ self.setup() if not isinstance(r, int) or r < 0 or r > 255: raise ValueError("Argument r should be an int from 0 to 255") if not isinstance(g, int) or g < 0 or g > 255: raise ValueError("Argument g should be an int from 0 to 255") if not isinstance(b, int) or b < 0 or b > 255: raise ValueError("Argument b should be an int from 0 to 255") r, g, b = [int(x * self._brightness) for x in (r, g, b)] self._write_byte(0) self._write_byte(0) self._write_byte(0b11101111) self._write_byte(LED_GAMMA[b & 0xff]) self._write_byte(LED_GAMMA[g & 0xff]) self._write_byte(LED_GAMMA[r & 0xff]) self._write_byte(0) self._write_byte(0) self._enqueue() def blink(self, r, g, b, ontime, offtime, blinktimes): logging.debug("[buttonshim] Blink") for i in range(0, blinktimes): self.set_pixel(r, g, b) time.sleep(ontime) self.set_pixel(0, 0, 0) time.sleep(offtime) def press_handler(self, button, pressed, plugin): """ On press reset button held status """ self._button_was_held = False def toggle_qwerty_steno(self): try: cap_state = plugins.loaded['evdevkb'].get_capture_state() if not cap_state: plugins.loaded['evdevkb'].start_capture() logging.info(f"[buttonshim] Switched to QWERTY mode") else: plugins.loaded['evdevkb'].stop_capture() logging.info(f"[buttonshim] Switched to STENO mode") except Exception as ex: logging.exception(f"[buttonshim] Check if evdevkb is loaded, exception: {str(ex)}") def toggle_wpm_meters(self): command = {} try: wpm_method = plugins.loaded['plover_link'].options['wpm_method'] wpm_timeout = plugins.loaded['plover_link'].options['wpm_timeout'] except Exception as ex: logging.exception(f"[buttonshim] Check that wpm_method and wpm_timeout is configured. Falling back to defaults. Exception: {str(ex)}") wpm_method = 'ncra' wpm_timeout = '60' if self._plover_wpm_meters_enabled: command = {'stop_wpm_meter': 'wpm and strokes'} self.set_ui_update('wpm', '') self.set_ui_update('strokes', '') self.trigger_ui_update() logging.info(f"[buttonshim] Disabled WPM readings") elif not self._plover_wpm_meters_enabled: command = {'start_wpm_meter': 'wpm and strokes', 'wpm_method' : wpm_method, 'wpm_timeout' : wpm_timeout} self.set_ui_update('wpm', wpm_method) self.set_ui_update('strokes', f"{wpm_timeout}s") self.trigger_ui_update() logging.info(f"[buttonshim] Enabled WPM readings using method {wpm_method} and timeout {wpm_timeout}") self._plover_wpm_meters_enabled = not self._plover_wpm_meters_enabled plugins.loaded['plover_link'].send_signal_to_plover(command) def toggle_dictionary_lookup(self): if plugins.loaded['dict_lookup'].get_running(): if not plugins.loaded['dict_lookup'].get_input_mode(): # If not currently enabled, enable input mode plugins.loaded['dict_lookup'].enable_input_mode() else: # If currently enabled, revert to normal view plugins.loaded['dict_lookup'].disable_input_mode() else: logging.debug(f"[buttonshim] dict_lookup is not ready yet. Check that Plover is running.") def hold_handler(self, button): """ On long press run built in internal Stenogotchi commands """ # Set button held status to prevent release_handler from triggering on release self._button_was_held = True # Blink in response to long hold event red = 0 green = 70 blue = 70 on_time = 1 off_time = 0 blink_times = 1 thread = Thread(target=self.blink, args=(red, green, blue, on_time, off_time, blink_times)) thread.start() if NAMES[button] == 'A': # Toggle QWERTY/STENO mode self.toggle_qwerty_steno() elif NAMES[button] == 'B': # Toggle WPM & strokes meters for Plover self.toggle_wpm_meters() elif NAMES[button] == 'C': # Toggle dictionary lookup mode self.toggle_dictionary_lookup() elif NAMES[button] == 'D': # Toggle wifi on/off stenogotchi.set_wifi_onoff() # Check for changes in wifi status over a short while for i in range(5): self._agent._update_wifi() time.sleep(2) logging.info(f"[buttonshim] Toggled wifi state") elif NAMES[button] == 'E': # Initiate clean shutdown process logging.info(f"[buttonshim] Initiated clean shutdown") stenogotchi.shutdown() def release_handler(self, button, pressed, plugin): """ On short press run command from config """ if not self._button_was_held: logging.info(f"[buttonshim] Button Pressed! Loading command from slot '{button}' for button '{NAMES[button]}'") bCfg = plugin.options['buttons'][NAMES[button]] blinkCfg = bCfg['blink'] logging.debug(f'[buttonshim] {self.blink}') if blinkCfg['enabled'] == True: logging.debug(f"[buttonshim] Blinking led") red = int(blinkCfg['red']) green = int(blinkCfg['green']) blue = int(blinkCfg['blue']) on_time = float(blinkCfg['on_time']) off_time = float(blinkCfg['off_time']) blink_times = int(blinkCfg['blink_times']) logging.debug(f"[buttonshim] red {red} green {green} blue {blue} on_time {on_time} off_time {off_time} blink_times {blink_times}") thread = Thread(target=self.blink, args=(red, green, blue, on_time, off_time, blink_times)) thread.start() logging.debug(f"[buttonshim] Blink thread started") command = bCfg['command'] if command == '': logging.debug(f"[buttonshim] Command empty") else: logging.debug(f"[buttonshim] Process create: {command}") process = subprocess.Popen(command, shell=True, stdin=None, stdout=open("/dev/null", "w"), stderr=None, executable="/bin/bash") process.wait() process = None logging.debug(f"[buttonshim] Process end") ================================================ FILE: stenogotchi/plugins/default/dict_lookup.py ================================================ #!/usr/bin/env python3 import logging import time import random import stenogotchi.plugins as plugins from stenogotchi.ui.components import LabeledValue, Text, Line from stenogotchi.ui.view import BLACK from stenogotchi.ui import view, state, faces import stenogotchi.ui.fonts as fonts class UiHandler(): def __init__(self, agent): self.clear_elements = ('name', 'ups', 'wpm', 'status', 'strokes', 'uptime', 'line1') self.input_mode = False self._agent = agent self._view = view.ROOT self._stored_state = state.State() self.minion_font = fonts.ImageFont.truetype("%s-Bold" % fonts.FONT_NAME, 18) self.minion_offset = 65 def store_state(self): for key, element in self._view._state.items(): if key in self.clear_elements or key == 'face': self._stored_state.add_element(key, element) def restore_state(self): # Add all removed items back for key, element in self._stored_state.items(): if key in self.clear_elements: self.add_element(key, element) def surprise_exit(self): if not self._view.get('face') == faces.LOOK_L: self._view.set('face', faces.LOOK_L) self.update_view() time.sleep(1) self._view.set('face', faces.LOOK_R) self.update_view() time.sleep(1) def relocate_face(self, minion): self.remove_element('face') if minion: # Shrink and relocate face = random.choices((faces.LOOK_L_HAPPY, faces.LOOK_L), weights=(0.9, 0.10), k=1) self.add_element('face', Text(value=face[0], position=((self._view._width - self.minion_offset + 2), 0), color=BLACK, font=self.minion_font)) else: # Grow and relocate, state doesn't matter as we will update it before view refresh self.add_element('face', self._stored_state._state['face']) def check_element(self, key): return self._view.has_element(key) def remove_element(self, key): try: self._view.remove_element(key) except KeyError: # TODO: fix self._view.has_element to replace this. Always seems to return None right now. logging.error(f"[dict_lookup] No element '{key}' available for removal") def add_element(self, key, element): self._view.add_element(key, element) def update_view(self): self._view.update() def enable_input_mode(self): # Storing current state self.store_state() # Removing elements we will be overlapping and block them from triggering ui updates for element in self.clear_elements: self.remove_element(element) # Block update of ui elements we removed self._view._ignore_changes = self.clear_elements # Shrink stenogotchi face self.relocate_face(minion=True) # Add new ui elements for input and output line1_offset = self._view._layout['line1'].copy() line1_offset[2] = self._view._width - self.minion_offset self.add_element('input', LabeledValue(color=BLACK, label='', value='', position=(0, 0) , label_font=fonts.Bold, text_font=fonts.Medium, max_length=29)) self.add_element('line1_offset', Line(line1_offset, color=BLACK)) self.add_element('out1', Text(value='', position=self._agent._view._layout['name'], color=BLACK, font=fonts.Bold, wrap=True, max_length=self._agent._view._layout['status']['max']-1)) self.add_element('out2', Text(value='', position=self._agent._view._layout['status']['pos'], color=BLACK, font=self._agent._view._layout['status']['font'], wrap=True, max_length=self._agent._view._layout['status']['max'])) self.input_mode = True # Used instead of self.update_view() to indicate input position from the start self.display_input("") logging.info("[dict_lookup] Enabled dictionary lookup mode") def disable_input_mode(self): # Add some variety to the exit if random.randint(0, 9) > 7: self.surprise_exit() self._view.remove_element('line1_offset') self._view.remove_element('input') self._view.remove_element('out1') self._view.remove_element('out2') # Restore removed elements self.restore_state() # Grow stenogotchi face self.relocate_face(minion=False) # revert default update-ignore state if self._view._config['ui']['fps'] > 0.0: self._view._ignore_changes = () else: self._view._ignore_changes = ('uptime', 'name') # Prepare new empty state object self._stored_state = state.State() # Refresh uptime, set return message which will trigger ui update self._agent._update_uptime() self._agent.set_on_dict_lookup_done() self.input_mode = False logging.info("[dict_lookup] Disabled dictionary lookup mode") def get_input_mode(self): return self.input_mode def display_output(self, list): if self.get_input_mode(): out1_str = "" out2_str = "" #TODO: handle list items longer than max supported length for column cnt = 0 for item in list: if cnt < 6: # lines 0-5 fit in first column out1_str += f"{item}\n" elif cnt < 13: # lines 6-11 fit in second column out2_str += f"{item}\n" else: break # no more room cnt += 1 self._view.set('out1', out1_str) self._view.set('out2', out2_str) self.update_view() def display_input(self, string, position_indicator="_"): if self.get_input_mode(): string += position_indicator # Character added to highlight input position self._agent._view.set('input', string) self.update_view() class InputHandler(): # TODO: Add support for evdevkb as input device def __init__(self, agent): self._agent = agent self.input_mode = False self._input = "" def _on_send_string(self, text: str): # If enter key received if text in ('\n', '\r', '\r\n'): if plugins.loaded['dict_lookup'].get_input_mode(): self._input += " " self.push_input(position_indicator="") plugins.loaded['dict_lookup'].lookup_word(self._input) else: self._input += text self.push_input() def _on_send_backspaces(self, count: int): if count >= len(self._input): self.clear_input() else: self._input = self._input[:-count] self.push_input() def _on_send_key_combination(self, combination: str): if combination == 'Control_L(BackSpace)': self.clear_input() else: logging.warning(f"[dict_lookup] Key-combinations not supported. Input '{combination}' ignored") def _on_lookup_results(self, results_list): # logging.debug(f"[dict_lookup] Lookup result from Plover '{results_list}'") plugins.loaded['dict_lookup'].display_lookup_result(results_list) def enable_input_mode(self): # TODO: trigger routing from evdevkb to here command = {'output_to_stenogotchi': True} plugins.loaded['plover_link'].send_signal_to_plover(command) self.input_mode = True def disable_input_mode(self): # TODO: revert routing from evdevkb to btclient command = {'output_to_stenogotchi': False} plugins.loaded['plover_link'].send_signal_to_plover(command) self.input_mode = False def push_input(self, position_indicator="_"): plugins.loaded['dict_lookup'].push_input(self._input, position_indicator) def clear_input(self): self._input = "" self.push_input() class DictLookup(plugins.Plugin): __autohor__ = 'Anodynous' __version__ = '0.3' __license__ = 'GPL3' __description__ = 'This plugin enables looking up words and strokes in enabled plover dictionaries' def __init__(self): self._agent = None self.config = None self.running = False self.input_mode = False self.input_handler = None self.ui_handler = None def on_plover_ready(self, agent): self._agent = agent self.ui_handler = UiHandler(agent) self.input_handler = InputHandler(agent) self.running = True def on_config_changed(self, config): self.config = config def on_unload(self, ui): self.lookup = None self.ui_handler = None def get_running(self): return self.running def get_input_mode(self): return self.input_mode def enable_input_mode(self): self.ui_handler.enable_input_mode() self.input_handler.enable_input_mode() self.input_mode = True def disable_input_mode(self): self.ui_handler.disable_input_mode() self.input_handler.disable_input_mode() self.input_handler.clear_input() self.input_mode = False def lookup_word(self, word): # Remove leading/trailing whitespaces and send word to plover for dictionary lookup word = word.strip() command = {'lookup_word': word} plugins.loaded['plover_link'].send_signal_to_plover(command) def lookup_stroke(self, stroke): # Remove leading/trailing whitespaces and send stroke to plover for dictionary lookup # TODO: implement functionality to use stroke-lookup stroke = stroke.strip() logging.debug(f"[dict_lookup] Looking up stroke '{stroke}'") command = {'lookup_word': stroke} plugins.loaded['plover_link'].send_signal_to_plover(command) def display_lookup_result(self, results_list): self.push_output(results_list) def sort_list(self, rlist): # Sorts the results in ascending order. # Primary sorting key: number of chords # Secondary sorting key: length of chord(combination) sort_key = lambda k : (k.count('/'), len(k)) rlist_sorted = [] if len(rlist) < 2: return rlist else: rlist_sorted = sorted(rlist, key=sort_key) return rlist_sorted def push_input(self, string="", position_indicator="_"): if self.input_mode: self.ui_handler.display_input(string, position_indicator) def push_output(self, rlist): if self.input_mode: if rlist: rlist_sorted = self.sort_list(rlist) else: rlist_sorted = [] self.ui_handler.display_output(rlist_sorted) if __name__ == '__main__': print("Please enable and run as Stenogotchi plugin") ================================================ FILE: stenogotchi/plugins/default/evdevkb.py ================================================ """ Evdev based keyboard client for capturing input and relays the keypress to a Bluetooth HID keyboard emulator D-BUS Service Based on: https://gist.github.com/ukBaz/a47e71e7b87fbc851b27cde7d1c0fcf0#file-readme-md Which in turn takes the original idea from: http://yetanotherpointlesstechblog.blogspot.com/2016/04/emulating-bluetooth-keyboard-with.html Tested on: Python 3.7 (needs 3.4+) Evdev 1.3.0 """ import logging import evdev from select import select from time import sleep if not __name__ == '__main__': import stenogotchi.plugins as plugins ObjectClass = plugins.Plugin else: import dbus ObjectClass = object HID_DBUS = 'com.github.stenogotchi' HID_SRVC = '/com/github/stenogotchi' KEYTABLE = { "KEY_RESERVED": 0, "KEY_ESC": 41, "KEY_1": 30, "KEY_2": 31, "KEY_3": 32, "KEY_4": 33, "KEY_5": 34, "KEY_6": 35, "KEY_7": 36, "KEY_8": 37, "KEY_9": 38, "KEY_0": 39, "KEY_MINUS": 45, "KEY_EQUAL": 46, "KEY_BACKSPACE": 42, "KEY_TAB": 43, "KEY_Q": 20, "KEY_W": 26, "KEY_E": 8, "KEY_R": 21, "KEY_T": 23, "KEY_Y": 28, "KEY_U": 24, "KEY_I": 12, "KEY_O": 18, "KEY_P": 19, "KEY_LEFTBRACE": 47, "KEY_RIGHTBRACE": 48, "KEY_ENTER": 40, "KEY_LEFTCTRL": 224, "KEY_A": 4, "KEY_S": 22, "KEY_D": 7, "KEY_F": 9, "KEY_G": 10, "KEY_H": 11, "KEY_J": 13, "KEY_K": 14, "KEY_L": 15, "KEY_SEMICOLON": 51, "KEY_APOSTROPHE": 52, "KEY_GRAVE": 53, "KEY_LEFTSHIFT": 225, "KEY_BACKSLASH": 50, "KEY_Z": 29, "KEY_X": 27, "KEY_C": 6, "KEY_V": 25, "KEY_B": 5, "KEY_N": 17, "KEY_M": 16, "KEY_COMMA": 54, "KEY_DOT": 55, "KEY_SLASH": 56, "KEY_RIGHTSHIFT": 229, "KEY_KPASTERISK": 85, "KEY_LEFTALT": 226, "KEY_SPACE": 44, "KEY_CAPSLOCK": 57, "KEY_F1": 58, "KEY_F2": 59, "KEY_F3": 60, "KEY_F4": 61, "KEY_F5": 62, "KEY_F6": 63, "KEY_F7": 64, "KEY_F8": 65, "KEY_F9": 66, "KEY_F10": 67, "KEY_NUMLOCK": 83, "KEY_SCROLLLOCK": 71, "KEY_KP7": 95, "KEY_KP8": 96, "KEY_KP9": 97, "KEY_KPMINUS": 86, "KEY_KP4": 92, "KEY_KP5": 93, "KEY_KP6": 94, "KEY_KPPLUS": 87, "KEY_KP1": 89, "KEY_KP2": 90, "KEY_KP3": 91, "KEY_KP0": 98, "KEY_KPDOT": 99, "KEY_ZENKAKUHANKAKU": 148, "KEY_102ND": 100, "KEY_F11": 68, "KEY_F12": 69, "KEY_RO": 135, "KEY_KATAKANA": 146, "KEY_HIRAGANA": 147, "KEY_HENKAN": 138, "KEY_KATAKANAHIRAGANA": 136, "KEY_MUHENKAN": 139, "KEY_KPJPCOMMA": 140, "KEY_KPENTER": 88, "KEY_RIGHTCTRL": 228, "KEY_KPSLASH": 84, "KEY_SYSRQ": 70, "KEY_RIGHTALT": 230, "KEY_HOME": 74, "KEY_UP": 82, "KEY_PAGEUP": 75, "KEY_LEFT": 80, "KEY_RIGHT": 79, "KEY_END": 77, "KEY_DOWN": 81, "KEY_PAGEDOWN": 78, "KEY_INSERT": 73, "KEY_DELETE": 76, "KEY_MUTE": 239, "KEY_VOLUMEDOWN": 238, "KEY_VOLUMEUP": 237, "KEY_POWER": 102, "KEY_KPEQUAL": 103, "KEY_PAUSE": 72, "KEY_KPCOMMA": 133, "KEY_HANGEUL": 144, "KEY_HANJA": 145, "KEY_YEN": 137, "KEY_LEFTMETA": 227, "KEY_RIGHTMETA": 231, "KEY_COMPOSE": 101, "KEY_STOP": 243, "KEY_AGAIN": 121, "KEY_PROPS": 118, "KEY_UNDO": 122, "KEY_FRONT": 119, "KEY_COPY": 124, "KEY_OPEN": 116, "KEY_PASTE": 125, "KEY_FIND": 244, "KEY_CUT": 123, "KEY_HELP": 117, "KEY_CALC": 251, "KEY_SLEEP": 248, "KEY_WWW": 240, "KEY_COFFEE": 249, "KEY_BACK": 241, "KEY_FORWARD": 242, "KEY_EJECTCD": 236, "KEY_NEXTSONG": 235, "KEY_PLAYPAUSE": 232, "KEY_PREVIOUSSONG": 234, "KEY_STOPCD": 233, "KEY_REFRESH": 250, "KEY_EDIT": 247, "KEY_SCROLLUP": 245, "KEY_SCROLLDOWN": 246, "KEY_F13": 104, "KEY_F14": 105, "KEY_F15": 106, "KEY_F16": 107, "KEY_F17": 108, "KEY_F18": 109, "KEY_F19": 110, "KEY_F20": 111, "KEY_F21": 112, "KEY_F22": 113, "KEY_F23": 114, "KEY_F24": 115 } # Map modifier keys to array element in the bit array MODKEYS = { "KEY_RIGHTMETA": 0, "KEY_RIGHTALT": 1, "KEY_RIGHTSHIFT": 2, "KEY_RIGHTCTRL": 3, "KEY_LEFTMETA": 4, "KEY_LEFTALT": 5, "KEY_LEFTSHIFT": 6, "KEY_LEFTCTRL": 7 } class EvdevKbrd: """ Take the events from a physically attached keyboard and send the HID messages to the keyboard D-Bus server. """ def __init__(self, skip_dbus = False): self._skip_dbus = skip_dbus self.do_capture = False self.keytable = KEYTABLE self.modkeys = MODKEYS self.target_length = 6 self.mod_keys = 0b00000000 self.pressed_keys = [] self.have_kb = False self.devs = None if self._skip_dbus: self.bus = None self.btkobject = None self.btk_service = None else: self.bus = dbus.SystemBus() self.btkobject = self.bus.get_object(HID_DBUS, HID_SRVC) self.btk_service = dbus.Interface(self.btkobject, HID_DBUS) def convert(self, evdev_keycode): return self.keytable[evdev_keycode] def modkey(self, evdev_keycode): if evdev_keycode in self.modkeys: return self.modkeys[evdev_keycode] else: return -1 # Return an invalid array element def set_do_capture(self, toggle): self.do_capture = toggle def grab(self): # Make input device unavailable for other applications for dev in self.devs: dev.grab() def ungrab(self): # Release input device for other applications for dev in self.devs: dev.ungrab() def get_input_devices(self): # Returns all input devices connected to device input_devices = [evdev.InputDevice(path) for path in evdev.list_devices()] return input_devices def get_keyboards(self): # Returns all input devices that look like keyboards that are connected to device input_devices = self.get_input_devices() keyboards = [] for device in input_devices: # Check if the input device has a KEY_A has_key_a = evdev.ecodes.KEY_A in device.capabilities().get(evdev.ecodes.EV_KEY, []) if has_key_a: keyboards.append(device) logging.debug(f"[evdevkb] Found keyboard '{device.name}' at path '{device.path}'") return keyboards def set_keyboards(self): # Sets all keyboards as device to listen for key-inputs from while not self.have_kb: if not self.do_capture: break keyboards = self.get_keyboards() if keyboards: self.devs = keyboards self.have_kb = True else: logging.debug('[evdevkb] Keyboard not found, waiting 3 seconds and retrying') sleep(3) def update_mod_keys(self, mod_key, value): """ Which modifier keys are active is stored in an 8 bit number. Each bit represents a different key. This method takes which bit and its new value as input :param mod_key: The value of the bit to be updated with new value :param value: Binary 1 or 0 depending if pressed or released """ bit_mask = 1 << (7-mod_key) if value: # set bit self.mod_keys |= bit_mask else: # clear bit self.mod_keys &= ~bit_mask def update_keys(self, norm_key, value): if value < 1: self.pressed_keys.remove(norm_key) elif norm_key not in self.pressed_keys: self.pressed_keys.insert(0, norm_key) len_delta = self.target_length - len(self.pressed_keys) if len_delta < 0: self.pressed_keys = self.pressed_keys[:len_delta] elif len_delta > 0: self.pressed_keys.extend([0] * len_delta) @property def state(self): """ property with the HID message to send for the current keys pressed on the keyboards :return: bytes of HID message """ return [0xA1, 0x01, self.mod_keys, 0, *self.pressed_keys] def send_keys(self): # If ran as part of Stenogotchi, communicate directly with plugin if self._skip_dbus: plugins.loaded['plover_link']._stenogotchiservice.send_keys([self.state]) # If ran as stand-alone, assume dbus is needed to access send_keys() function else: self.btk_service.send_keys(self.state) def event_loop(self): """ Reads keypresses from all identified keyboards and sends them to emulated bluetooth HID as long as do_capture is True. """ self.do_capture = True self.grab() while self.do_capture: r, w, x = select(self.devs, [], [], 0.01) # 0.1 is default for fd in r: for event in fd.read(): # We only want up/down key-events if event.type == evdev.ecodes.EV_KEY and event.value < 2: key_str = evdev.ecodes.KEY[event.code] mod_key = self.modkey(key_str) if mod_key > -1: self.update_mod_keys(mod_key, event.value) else: self.update_keys(self.convert(key_str), event.value) self.send_keys() self.ungrab() for dev in self.devs: dev.close() class EvdevKeyboard(ObjectClass): __autohor__ = 'Anodynous' __version__ = '0.2' __license__ = 'MIT' __description__ = 'This plugin captures and blocks keypress events using evdev and sends to module emulating bluetooth HID device.' def __init__(self): self._agent = None self.evdevkb = None self.do_capture = False def on_ready(self, agent): self._agent = agent def on_config_changed(self, config): self.config = config def trigger_ui_update(self, input_mode): self._agent.view().set('mode', input_mode) self._agent.view().update() def start_capture(self): logging.info('[evdevkb] Capturing evdev keypress events...') self.trigger_ui_update('QWERTY') self.evdevkb = EvdevKbrd(skip_dbus=True) self.evdevkb.set_do_capture(True) self.do_capture = True self.evdevkb.set_keyboards() self.evdevkb.event_loop() def stop_capture(self): logging.info('[evdevkb] Ignoring evdev keypress events...') self.evdevkb.set_do_capture(False) self.do_capture = False self.evdevkb = None self.trigger_ui_update('STENO') def get_capture_state(self): return self.do_capture if __name__ == '__main__': try: print('Setting up keyboard') kb = EvdevKbrd() print('starting event loop') kb.set_keyboards() kb.event_loop() except RuntimeError: pass # Handling for bug in evdev 1.3.0, see https://github.com/gvalkov/python-evdev/issues/120 ================================================ FILE: stenogotchi/plugins/default/example.py ================================================ # ############################################################### # Based on: https://github.com/evilsocket/pwnagotchi/blob/master/pwnagotchi/plugins/default/example.py # # Changed 22-03-2021 by Anodynous # - Changed to fit Stenogotchi events # ################################################################ import logging import stenogotchi.plugins as plugins from stenogotchi.ui.components import LabeledValue from stenogotchi.ui.view import BLACK import stenogotchi.ui.fonts as fonts class Example(plugins.Plugin): __author__ = 'evilsocket@gmail.com, Anodynous' __version__ = '1.0.0' __license__ = 'GPL3' __description__ = 'An example plugin for pwnagotchi that implements all the available callbacks.' def __init__(self): logging.debug("[example] example plugin created") # called when http://:/plugins// is called # must return a html page # IMPORTANT: If you use "POST"s, add a csrf-token (via csrf_token() and render_template_string) def on_webhook(self, path, request): logging.info("[example] webhook established") # called when the plugin is loaded def on_loaded(self): logging.warning("[example] WARNING: this plugin should be disabled! options = " % self.options) # called before the plugin is unloaded def on_unload(self, ui): logging.info("[example] is unloaded") # called hen there's internet connectivity def on_internet_available(self, agent): logging.info("[example] unit has internet connection") # called to setup the ui elements def on_ui_setup(self, ui): # add custom UI elements ui.add_element('ups', LabeledValue(color=BLACK, label='UPS', value='0%/0V', position=(ui.width() / 2 - 25, 0), label_font=fonts.Bold, text_font=fonts.Medium)) # called when the ui is updated def on_ui_update(self, ui): # update those elements some_voltage = 0.1 some_capacity = 100.0 ui.set('ups', "%4.2fV/%2i%%" % (some_voltage, some_capacity)) # called when the hardware display setup is done, display is an hardware specific object def on_display_setup(self, display): logging.info("[example] unit has set up display") # called when everything is ready and the main loop is about to start def on_ready(self, agent): logging.info("[example] unit is ready") # you can run custom bettercap commands if you want # agent.run('ble.recon on') # or set a custom state # agent.set_bored() # called when the status is set to grateful def on_grateful(self, agent): logging.info("[example] unit is grateful") # called when the status is set to lonely def on_lonely(self, agent): logging.info("[example] unit is lonely") # called when the status is set to bored def on_bored(self, agent): logging.info("[example] unit is bored") # called when the status is set to sad def on_sad(self, agent): logging.info("[example] unit is sad") # called when the status is set to angry def on_angry(self, agent): logging.info("[example] unit is angry") # called when the status is set to excited def on_excited(self, agent): logging.info("[example] unit is excited") # called when the agent is rebooting the board def on_rebooting(self, agent): logging.info("[example] unit is rebooting") # called when the agent is waiting for t seconds def on_wait(self, agent, t): logging.info(f"[example] unit is waiting {t} seconds") # called when the agent is sleeping for t seconds def on_sleep(self, agent, t): logging.info(f"[example] unit is sleeping {t} seconds") # called when an epoch is over (where an epoch is a single loop of the main algorithm) def on_epoch(self, agent, epoch, epoch_data): logging.info(f"[example] epoch is over") # called when successfully connected to a bluetooth host def on_bt_connected(self, agent, bthost_name): logging.info(f"[example] unit connected to bluetooth host '{bthost_name}'") # called when disconnected from bluetooth host def on_bt_disconnected(self, agent): logging.info("[example] unit disconnected from bluetooth host") # called when plover boots def on_plover_boot(self, agent): logging.info("[example] Plover is starting up") # called when plover is ready def on_plover_ready(self, agent): logging.info("[example] Plover is ready") # called when plover quits def on_plover_quit(self, agent): logging.info("[example] Plover has quit") # called when wifi is connected def on_wifi_connected(self, agent, ssid, ip): logging.info(f"[example] unit is connected to wifi '{ssid}' using ip '{ip}'") # called when wifi is disconnected def on_wifi_disconnected(self, agent): logging.info("[example] unit is disconnected from wifi") # called when wpm stats are updated def on_wpm_stats(self, agent): logging.info("[example] unit received new WPM stats") # called when wpm-strokes stats are updated def on_strokes_stats(self, agent): logging.info("[example] unit received new WPM-strokes stats") ================================================ FILE: stenogotchi/plugins/default/led.py ================================================ # ############################################################### # Based on: https://github.com/evilsocket/pwnagotchi/blob/master/pwnagotchi/plugins/default/led.py # # Changed 22-03-2021 by Anodynous # - Changed to fit Stenogotchi events # ################################################################ from threading import Event import _thread import logging import time import stenogotchi.plugins as plugins class Led(plugins.Plugin): __author__ = 'evilsocket@gmail.com, Anodynous' __version__ = '1.0.0' __license__ = 'GPL3' __description__ = 'This plugin blinks the PWR led with different patterns depending on the event.' def __init__(self): self._is_busy = False self._event = Event() self._event_name = None self._led_file = "/sys/class/leds/led0/brightness" self._delay = 200 # called when the plugin is loaded def on_loaded(self): self._led_file = "/sys/class/leds/led%d/brightness" % self.options['led'] self._delay = int(self.options['delay']) logging.info("[led] plugin loaded for %s" % self._led_file) self._on_event('loaded') _thread.start_new_thread(self._worker, ()) def on_config_changed(self, config): self.config = config def _on_event(self, event): if not self._is_busy: self._event_name = event self._event.set() logging.debug("[led] event '%s' set", event) else: logging.debug("[led] skipping event '%s' because the worker is busy", event) def _led(self, on): with open(self._led_file, 'wt') as fp: fp.write(str(on)) def _blink(self, pattern): logging.debug("[led] using pattern '%s' ..." % pattern) for c in pattern: if c == ' ': self._led(1) else: self._led(0) time.sleep(self._delay / 1000.0) # reset self._led(0) def _worker(self): while True: self._event.wait() self._event.clear() self._is_busy = True try: if self._event_name in self.options['patterns']: pattern = self.options['patterns'][self._event_name] self._blink(pattern) else: logging.debug("[led] no pattern defined for %s" % self._event_name) except Exception as e: logging.exception("[led] error while blinking") finally: self._is_busy = False # called when the unit is updating its software def on_updating(self): self._on_event('updating') # called when there's internet connectivity def on_internet_available(self, agent): self._on_event('internet_available') # called when everything is ready and the main loop is about to start def on_ready(self, agent): self._on_event('ready') # called when the status is set to grateful def on_grateful(self, agent): self._on_event('grateful') # called when the status is set to lonely def on_lonely(self, agent): self._on_event('lonely') # called when the status is set to bored def on_bored(self, agent): self._on_event('bored') # called when the status is set to sad def on_sad(self, agent): self._on_event('sad') # called when the status is set to angry def on_angry(self, agent): self._on_event('angry') # called when the status is set to excited def on_excited(self, agent): self._on_event('excited') # called when the agent is rebooting the board def on_rebooting(self, agent): self._on_event('rebooting') # called when the agent is waiting for t seconds def on_wait(self, agent, t): self._on_event('wait') # called when the agent is sleeping for t seconds def on_sleep(self, agent, t): self._on_event('sleep') # called when an epoch is over (where an epoch is a single loop of the main algorithm) def on_epoch(self, agent, epoch, epoch_data): self._on_event('epoch') # called when successfully connected to a bluetooth host def on_bt_connected(self, agent, bthost_name): self._on_event('bt_connected') # called when disconnected from bluetooth host def on_bt_disconnected(self, agent): self._on_event('bt_disconnected') # called when plover boots def on_plover_boot(self, agent): self._on_event('plover_boot') # called when plover is ready def on_plover_ready(self, agent): self._on_event('plover_ready') # called when plover quits def on_plover_quit(self, agent): self._on_event('plover_quit') # called when wifi is connected def on_wifi_connected(self, agent, ssid, ip): self._on_event('wifi_connected') # called when wifi is disconnected def on_wifi_disconnected(self, agent): self._on_event('wifi_disconnected') # called when wpm stats are updated def on_wpm_stats(self, agent): self._on_event('wpm_set') # called when wpm-strokes stats are updated def on_strokes_stats(self, agent): self._on_event('strokes_set') ================================================ FILE: stenogotchi/plugins/default/logtail.py ================================================ import os import logging import threading from itertools import islice from time import sleep from datetime import datetime,timedelta from stenogotchi import plugins from stenogotchi.utils import StatusFile from flask import render_template_string from flask import jsonify from flask import abort from flask import Response TEMPLATE = """ {% extends "base.html" %} {% set active_page = "plugins" %} {% block title %} Logtail {% endblock %} {% block styles %} {{ super() }} {% endblock %} {% block script %} var table = document.getElementById('table'); var filter = document.getElementById('filter'); var filterVal = filter.value.toUpperCase(); var xhr = new XMLHttpRequest(); xhr.open('GET', '{{ url_for('plugins') }}/logtail/stream'); xhr.send(); var position = 0; var data; var time; var level; var msg; var colorClass; function handleNewData() { var messages = xhr.responseText.split('\\n'); filterVal = filter.value.toUpperCase(); messages.slice(position, -1).forEach(function(value) { if (value.charAt(0) != '[') { msg = value; time = ''; level = ''; } else { data = value.split(']'); time = data.shift() + ']'; level = data.shift() + ']'; msg = data.join(']'); switch(level) { case ' [INFO]': colorClass = 'info'; break; case ' [WARNING]': colorClass = 'warning'; break; case ' [ERROR]': colorClass = 'error'; break; case ' [DEBUG]': colorClass = 'debug'; break; default: colorClass = 'default'; break; } } var tr = document.createElement('tr'); var td1 = document.createElement('td'); var td2 = document.createElement('td'); var td3 = document.createElement('td'); td1.textContent = time; td2.textContent = level; td3.textContent = msg; tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.className = colorClass; if (filterVal.length > 0 && value.toUpperCase().indexOf(filterVal) == -1) { tr.style.display = "none"; } table.appendChild(tr); }); position = messages.length - 1; } var scrollingElement = (document.scrollingElement || document.body) function scrollToBottom () { scrollingElement.scrollTop = scrollingElement.scrollHeight; } var timer; var scrollElm = document.getElementById('autoscroll'); timer = setInterval(function() { handleNewData(); if (scrollElm.checked) { scrollToBottom(); } if (xhr.readyState == XMLHttpRequest.DONE) { clearInterval(timer); } }, 1000); var typingTimer; var doneTypingInterval = 1000; filter.onkeyup = function() { clearTimeout(typingTimer); typingTimer = setTimeout(doneTyping, doneTypingInterval); } filter.onkeydown = function() { clearTimeout(typingTimer); } function doneTyping() { document.body.style.cursor = 'progress'; var tr, tds, td, i, txtValue; filterVal = filter.value.toUpperCase(); tr = table.getElementsByTagName("tr"); for (i = 1; i < tr.length; i++) { txtValue = tr[i].textContent || tr[i].innerText; if (txtValue.toUpperCase().indexOf(filterVal) > -1) { tr[i].style.display = "table-row"; } else { tr[i].style.display = "none"; } } document.body.style.cursor = 'default'; } {% endblock %} {% block content %}

Time Level Message
{% endblock %} """ class Logtail(plugins.Plugin): __author__ = '33197631+dadav@users.noreply.github.com' __version__ = '0.1.0' __license__ = 'GPL3' __description__ = 'This plugin tails the logfile.' def __init__(self): self.lock = threading.Lock() self.options = dict() self.ready = False def on_config_changed(self, config): self.config = config self.ready = True def on_loaded(self): """ Gets called when the plugin gets loaded """ logging.info("[logtail] Logtail plugin loaded.") def on_webhook(self, path, request): if not self.ready: return "Plugin not ready" if not path or path == "/": return render_template_string(TEMPLATE) if path == 'stream': def generate(): with open(self.config['main']['log']['path']) as f: yield ''.join(f.readlines()[-self.options.get('max-lines', 4096):]) while True: yield f.readline() return Response(generate(), mimetype='text/plain') abort(404) ================================================ FILE: stenogotchi/plugins/default/memtemp.py ================================================ # memtemp shows memory infos and cpu temperature # # mem usage, cpu load, cpu temp # ############################################################### # # Updated 13-01-2021 by Anodynous # - Changed CPU reading from using default method to having a # continuously updating 60s average to draw upon. Default # method returns 90-100% on RPI0 when triggered alongside # an on_ui_update event # # Updated 18-10-2019 by spees # - Changed the place where the data was displayed on screen # - Made the data a bit more compact and easier to read # - removed the label so we wont waste screen space # - Updated version to 1.0.1 # # 20-10-2019 by spees # - Refactored to use the already existing functions # - Now only shows memory usage in percentage # - Added CPU load # - Added horizontal and vertical orientation # ############################################################### from stenogotchi.ui.components import LabeledValue from stenogotchi.ui.view import BLACK import stenogotchi.ui.fonts as fonts import stenogotchi.plugins as plugins import stenogotchi import logging import _thread class MemTemp(plugins.Plugin): __author__ = 'https://github.com/xenDE' __version__ = '1.0.1' __license__ = 'GPL3' __description__ = 'A plugin that will display memory/cpu usage and temperature' def __init__(self): self.cpu_load_avg = 0 def on_loaded(self): logging.info("[memtemp] memtemp plugin loaded.") _thread.start_new_thread(self._cpu_poller(), ()) def on_config_changed(self, config): self.config = config def mem_usage(self): return int(stenogotchi.mem_usage() * 100) def cpu_load(self): #return int(stenogotchi.cpu_load() * 100) return int(self.cpu_load_avg * 100) def _cpu_poller(self, s=60): """ Runs in own thread and continually recalculates the CPU load over a (s)-second long window """ while True: self.cpu_load_avg = stenogotchi.cpu_load(s) def on_ui_setup(self, ui): if ui.is_waveshare_v2(): h_pos = (180, 80) v_pos = (180, 61) elif ui.is_waveshare_v1(): h_pos = (170, 80) v_pos = (170, 61) elif ui.is_waveshare144lcd(): h_pos = (53, 77) v_pos = (78, 67) elif ui.is_inky(): h_pos = (140, 68) v_pos = (165, 54) elif ui.is_waveshare27inch(): h_pos = (192, 138) v_pos = (216, 122) else: h_pos = (155, 76) v_pos = (180, 61) if self.options['orientation'] == "vertical": ui.add_element('memtemp', LabeledValue(color=BLACK, label='', value=' mem:-\n cpu:-\ntemp:-', position=v_pos, label_font=fonts.Small, text_font=fonts.Small)) else: # default to horizontal ui.add_element('memtemp', LabeledValue(color=BLACK, label='', value='mem cpu temp\n - - -', position=h_pos, label_font=fonts.Small, text_font=fonts.Small)) def on_unload(self, ui): with ui._lock: ui.remove_element('memtemp') def on_ui_update(self, ui): if self.options['scale'] == "fahrenheit": temp = (stenogotchi.temperature() * 9 / 5) + 32 symbol = "f" elif self.options['scale'] == "kelvin": temp = stenogotchi.temperature() + 273.15 symbol = "k" else: # default to celsius temp = stenogotchi.temperature() symbol = "c" if self.options['orientation'] == "vertical": ui.set('memtemp', " mem:%s%%\n cpu:%s%%\ntemp:%s%s" % (self.mem_usage(), self.cpu_load(), temp, symbol)) else: # default to horizontal ui.set('memtemp', " mem cpu temp\n %s%% %s%% %s%s" % (self.mem_usage(), self.cpu_load(), temp, symbol)) ================================================ FILE: stenogotchi/plugins/default/plover_link.py ================================================ """ Stenogotchi and Bluetooth HID keyboard emulator D-BUS Service Based on: https://gist.github.com/ukBaz/a47e71e7b87fbc851b27cde7d1c0fcf0#file-readme-md Which in turn takes the original idea from: http://yetanotherpointlesstechblog.blogspot.com/2016/04/emulating-bluetooth-keyboard-with.html Tested on: Python 3.7-3.9 BlueZ 5.5 """ import os import sys import logging from types import MethodDescriptorType import dbus import dbus.service import socket from time import sleep from gi.repository import GLib from dbus.mainloop.glib import DBusGMainLoop if not __name__ == '__main__': import stenogotchi.plugins as plugins ObjectClass = plugins.Plugin else: ObjectClass = object class BluezErrorRejected(dbus.DBusException): _dbus_error_name = "org.bluez.Error.Rejected" class BluezErrorCanceled(dbus.DBusException): _dbus_error_name = "org.bluez.Error.Canceled" class Agent(dbus.service.Object): """ BT Pairing agent API: https://git.kernel.org/pub/scm/bluetooth/bluez.git/plain/doc/agent-api.txt examples: https://github.com/elsampsa/btdemo/blob/master/bt_studio.py """ @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='') def AuthorizeService(self, device, uuid): logging.info(f"[plover_link] Successfully paired device: {device} using Secure Simple Pairing (SSP)") return @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='') def RequestAuthorization(self, device): logging.info(f"[plover_link] Accepted RequestAuthorization from {device}") return @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') def Cancel(self): logging.info("[plover_link] Cancel request received from BT client") raise(BluezErrorCanceled) @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') def Release(self): self.logging("[plover_link] Connection released due to BT client request") mainloop.quit() class HumanInterfaceDeviceProfile(dbus.service.Object): """ BlueZ D-Bus Profile for HID """ fd = -1 @dbus.service.method('org.bluez.Profile1', in_signature='', out_signature='') def Release(self): logging.info('[plover_link] PloverLink: Release') mainloop.quit() @dbus.service.method('org.bluez.Profile1', in_signature='oha{sv}', out_signature='') def NewConnection(self, path, fd, properties): self.fd = fd.take() logging.info('[plover_link] NewConnection({}, {})'.format(path, self.fd)) for key in properties.keys(): if key == 'Version' or key == 'Features': logging.info('[plover_link] {} = 0x{:04x}'.format(key, properties[key])) else: logging.info('[plover_link] {} = {}'.format(key, properties[key])) @dbus.service.method('org.bluez.Profile1', in_signature='o', out_signature='') def RequestDisconnection(self, path): logging.info('[plover_link] RequestDisconnection {}'.format(path)) if self.fd > 0: os.close(self.fd) self.fd = -1 class BTKbDevice: """ Create a bluetooth device to emulate a HID keyboard """ # Service control port - must match port configured in SDP record P_CTRL = 17 # Service interrupt port - must match port configured in SDP record P_INTR = 19 # BlueZ dbus PROFILE_DBUS_PATH = '/bluez/yaptb/btkb_profile' AGENT_DBUS_PATH = '/org/bluez' ADAPTER_IFACE = 'org.bluez.Adapter1' DEVICE_INTERFACE = 'org.bluez.Device1' DBUS_PROP_IFACE = 'org.freedesktop.DBus.Properties' DBUS_OM_IFACE = 'org.freedesktop.DBus.ObjectManager' # file path of the sdp record to laod install_dir = os.path.dirname(os.path.realpath(__file__)) SDP_RECORD_PATH = os.path.join(install_dir, 'plover_link_btserver_sdp_record.xml') # UUID for HID service (1124) # https://www.bluetooth.com/specifications/assigned-numbers/service-discovery UUID = '00001124-0000-1000-8000-00805f9b34fb' def __init__(self, hci=0): self._agent = plugins.loaded['plover_link']._agent self.bt_autoconnect_list = None self.bt_last_conn = None self.autoconnect_in_progress = False self.bt_agent_running = False self.scontrol = None self.ccontrol = None # Socket object for control self.sinterrupt = None self.cinterrupt = None # Socket object for interrupt self.dev_path = '/org/bluez/hci{}'.format(hci) logging.info('[plover_link] Setting up BT device') self.bus = dbus.SystemBus() self.adapter_methods = dbus.Interface(self.bus.get_object('org.bluez', self.dev_path), self.ADAPTER_IFACE) self.adapter_property = dbus.Interface(self.bus.get_object('org.bluez', self.dev_path), self.DBUS_PROP_IFACE) self.bus.add_signal_receiver(self.interfaces_added, dbus_interface=self.DBUS_OM_IFACE, signal_name='InterfacesAdded') self.bus.add_signal_receiver(self._properties_changed, dbus_interface=self.DBUS_PROP_IFACE, signal_name='PropertiesChanged', arg0=self.DEVICE_INTERFACE, path_keyword='path') self.register_hid_profile() # Set the Bluetooth device configuration try: self.alias = plugins.loaded['plover_link'].options['bt_device_name'] except: self.alias = 'Stenogotchi' self.discoverabletimeout = 0 self.discoverable = True self.bthost_mac = None self.bthost_name = "" # Get list of Bluetooth devices to auto connect to bt_autoconnect_str = plugins.loaded['plover_link'].options['bt_autoconnect_mac'] if bt_autoconnect_str: self.bt_autoconnect_list = list(map(str.strip, bt_autoconnect_str.split(','))) else: self.bt_autoconnect_list = None logging.info('[plover_link] Configured BT device with name {}'.format(self.alias)) def interfaces_added(self, path, device_info): pass def _properties_changed(self, interface, changed, invalidated, path): if self.on_disconnect is not None: if 'Connected' in changed: if not changed['Connected']: self.on_disconnect() def on_disconnect(self): if self.autoconnect_in_progress: return logging.info('[plover_link] The client has been disconnected') self.bthost_mac = None self.bthost_name = "" self._agent.set_bt_disconnected() # Attempt to auto_connect once, then go back to listening mode connected = self.auto_connect() if not connected: if not self.bt_agent_running: self.register_bt_pairing_agent() if not self.scontrol: self.listen() @property def address(self): """ Return the adapter MAC address. """ return self.adapter_property.Get(self.ADAPTER_IFACE, 'Address') @property def powered(self): """ Power state of the Adapter. """ return self.adapter_property.Get(self.ADAPTER_IFACE, 'Powered') @powered.setter def powered(self, new_state): self.adapter_property.Set(self.ADAPTER_IFACE, 'Powered', new_state) @property def alias(self): return self.adapter_property.Get(self.ADAPTER_IFACE, 'Alias') @alias.setter def alias(self, new_alias): self.adapter_property.Set(self.ADAPTER_IFACE, 'Alias', new_alias) @property def discoverabletimeout(self): """ Discoverable timeout of the Adapter. """ return self.adapter_props.Get(self.ADAPTER_IFACE, 'DiscoverableTimeout') @discoverabletimeout.setter def discoverabletimeout(self, new_timeout): self.adapter_property.Set(self.ADAPTER_IFACE, 'DiscoverableTimeout', dbus.UInt32(new_timeout)) @property def discoverable(self): """ Discoverable state of the Adapter. """ return self.adapter_props.Get( self.ADAPTER_INTERFACE, 'Discoverable') @discoverable.setter def discoverable(self, new_state): self.adapter_property.Set(self.ADAPTER_IFACE, 'Discoverable', new_state) def register_hid_profile(self): """ Setup and register HID Profile """ logging.debug('[plover_link] Configuring Bluez Profile') service_record = self.read_sdp_service_record() opts = { 'Role': 'server', 'RequireAuthentication': True, 'RequireAuthorization': True, 'AutoConnect': True, 'ServiceRecord': service_record, } manager = dbus.Interface(self.bus.get_object('org.bluez', '/org/bluez'), 'org.bluez.ProfileManager1') HumanInterfaceDeviceProfile(self.bus, BTKbDevice.PROFILE_DBUS_PATH) manager.RegisterProfile(BTKbDevice.PROFILE_DBUS_PATH, BTKbDevice.UUID, opts) logging.debug('[plover_link] Profile registered ') def register_bt_pairing_agent(self): """ Setup and register BT paring agent """ capability = 'NoInputNoOutput' manager = dbus.Interface(self.bus.get_object('org.bluez', '/org/bluez'), 'org.bluez.AgentManager1') Agent(self.bus, BTKbDevice.AGENT_DBUS_PATH) manager.RegisterAgent(BTKbDevice.AGENT_DBUS_PATH, capability) #manager.UnregisterAgent(BTKbDevice.AGENT_DBUS_PATH, capability) manager.RequestDefaultAgent(BTKbDevice.AGENT_DBUS_PATH) self.bt_agent_running = True logging.debug(f'[plover_link] Registered secure Bluez pairing agent with capability: {capability}') @staticmethod def read_sdp_service_record(): """ Read and return SDP record from a file :return: (string) SDP record """ logging.debug('[plover_link] Reading service record') try: fh = open(BTKbDevice.SDP_RECORD_PATH, 'r') except OSError: sys.exit('Could not open the sdp record. Exiting...') return fh.read() def create_ssockets(self): """ Create passive listening sockets and close possibly exising ones """ logging.debug("[plover_link] Creating BT listening ssockets") if self.scontrol: self.scontrol.close() if self.sinterrupt: self.sinterrupt.close() self.scontrol = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) self.scontrol.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sinterrupt = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) self.sinterrupt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) def listen(self): """ Listen for connections coming from HID client """ if not self.scontrol or not self.sinterrupt: self.create_ssockets() try: # Bind address/port to existing sockets self.scontrol.bind((self.address, self.P_CTRL)) self.sinterrupt.bind((self.address, self.P_INTR)) # Start listening on the server sockets with limit of 1 connection per socket self.scontrol.listen(1) self.sinterrupt.listen(1) logging.info('[plover_link] Waiting for connections') self.ccontrol, cinfo = self.scontrol.accept() logging.debug('[plover_link] {} connected on the ccontrol socket'.format(cinfo[0])) self.cinterrupt, cinfo = self.sinterrupt.accept() logging.debug('[plover_link] {} connected on the cinterrupt channel'.format(cinfo[0])) self.bthost_mac = cinfo[0] self.bthost_name = self.get_connected_device_name() self._agent.set_bt_connected(self.bthost_name) self.bt_last_conn = self.bthost_mac except OSError as ex: logging.error(f"[plover_link] Failed to enter listening mode: {ex}") logging.info(f"[plover_link] If issue persists, check that your '/lib/systemd/system/bluetooth.service' file still contains 'ExecStart=/usr/lib/bluetooth/bluetoothd -P input'") # def reconnect(self): # print("Trying reconnect...") # if not self.bt_last_conn: # return # logging.info('[plover_link] Waiting 5 seconds before attempting to reconnect to lost BT device...') # sleep(5) # for i in range(3): # try: # hidHost = self.bt_last_conn # self.ccontrol = socket.socket(socket.AF_BLUETOOTH, # socket.SOCK_SEQPACKET, # socket.BTPROTO_L2CAP) # self.cinterrupt = socket.socket(socket.AF_BLUETOOTH, # socket.SOCK_SEQPACKET, # socket.BTPROTO_L2CAP) # self.ccontrol.connect((hidHost, self.P_CTRL)) # self.cinterrupt.connect((hidHost, self.P_INTR)) # print("Connected!") # break # except Exception as ex: # logging.info(f"[plover_link] Failed to reconnect, will retry in 5s... Reason: '{ex}'") # sleep(5) def auto_connect(self): """ Automatically connects to preferred BT devices in listed order. Also handles reconnect attempts at lost connection. """ # Check if we should make a reconnect attempt to previous BT device if self.bt_last_conn: reconnect = True else: reconnect = False if not self.bt_autoconnect_list and not reconnect: logging.info('[plover_link] No bt_autoconnect_mac set in config. Listening for incoming connections instead...') return False else: self.autoconnect_in_progress = True if reconnect: if not self.bt_last_conn: return False logging.info('[plover_link] Waiting 5 seconds before attempting to reconnect to lost BT device...') sleep(5) # Sleep 5 seconds before attempting a reconnect to lost BT device autoconnect_list = [self.bt_last_conn] else: logging.info('[plover_link] Trying to auto connect to preferred BT host(s)...') autoconnect_list = self.bt_autoconnect_list.copy() for i in autoconnect_list: logging.info(f'[plover_link] Trying to auto connect to {i}') try: self.ccontrol = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) self.cinterrupt = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) self.ccontrol.connect((i, self.P_CTRL)) self.cinterrupt.connect((i, self.P_INTR)) # On successful connection self.autoconnect_in_progress = False self.bthost_mac = i self.bt_last_conn = i self.bthost_name = self.get_connected_device_name() self._agent.set_bt_connected(self.bthost_name) return True # stop trying to auto connect upon success except Exception as e: if e.__class__.__name__ == "OSError" and str(e) == "[Errno 52] Invalid exchange": self.unpair_device(i) logging.info(f'[plover_link] Invalid handshake exchange with {i}. Unpaired device. Please re-initiate pairing from remote device.') self._agent._view.on_custom(f"Had to purge {i} due to invalid handshake. Please re-pair us.") elif (e.__class__.__name__ == "PermissionError" and str(e) == "[Errno 13] Permission denied"): logging.info(f'[plover_link] Permission to connect to {i} denied. Ensure device has been paired.') self._agent._view.on_custom(f"Hey... {i} refused my connection! Please re-pair us.") elif (e.__class__.__name__ == "ConnectionRefusedError" and str(e) == "[Errno 111] Connection refused"): logging.info(f'[plover_link] Connection to {i} refused. Ensure device has been paired and is available.') self._agent._view.on_custom(f"Hmpf... {i} refused my connection. Ensure device has been paired and is available.") elif e.__class__.__name__ == "OSError" and str(e) == "[Errno 112] Host is down": logging.info(f'[plover_link] Host {i} is down.') else: logging.info(f'[plover_link] Failed to connect to {i} due to "{e.__class__.__name__}" : "{e}"') if self.bt_autoconnect_list and not reconnect: if i in self.bt_autoconnect_list: self.bt_autoconnect_list.remove(i) # If all addresses attempted without success if not self.bt_autoconnect_list or reconnect: logging.info('[plover_link] Unsuccessful auto connect attempt. Listening for incoming connections instead...') # self.ccontrol.close() # self.cinterrupt.close() self.autoconnect_in_progress = False self.bt_last_conn = None return False sleep(2) def unpair_device(self, address): """ Removes remote device including pairing information """ proxy_object = self.bus.get_object("org.bluez","/") manager = dbus.Interface(proxy_object, "org.freedesktop.DBus.ObjectManager") managed_objects = manager.GetManagedObjects() for path in managed_objects: adr = managed_objects[path].get('org.bluez.Device1', {}).get('Address', False) if adr == address: self.adapter_methods.RemoveDevice(path) def get_connected_device_name(self): """ Returns name (Alias) of connected BT device """ proxy_object = self.bus.get_object("org.bluez","/") manager = dbus.Interface(proxy_object, "org.freedesktop.DBus.ObjectManager") managed_objects = manager.GetManagedObjects() for path in managed_objects: con_state = managed_objects[path].get('org.bluez.Device1', {}).get('Connected', False) if con_state: addr = managed_objects[path].get('org.bluez.Device1', {}).get('Address') alias = managed_objects[path].get('org.bluez.Device1', {}).get('Alias') logging.info(f'[plover_link] Device {alias} [{addr}] is connected') return alias def send(self, msg): """ Send HID message :param msg: (bytes) HID packet to send """ self.cinterrupt.send(bytes(bytearray(msg))) class StenogotchiService(dbus.service.Object): """ Setup of a D-Bus service to receive: Status updates and HID messages from Plover plugin. HID messages from Stenogotchi evdevkb plugin """ def __init__(self): logging.info('[plover_link] Setting up Stenogotchi D-Bus service') bus_name = dbus.service.BusName('com.github.stenogotchi', bus=dbus.SystemBus()) dbus.service.Object.__init__(self, bus_name, '/com/github/stenogotchi') self._agent = plugins.loaded['plover_link']._agent self.device = BTKbDevice() # create and setup our BTKbDevice self.wpm_top = None def auto_connect(self): """ Connect to preferred bt_mac(s). If unspecified or unavailable fall back to await new and trusted incoming connections """ connected = self.device.auto_connect() if not connected: self.device.register_bt_pairing_agent() # Handler for new pairing attempts self.device.listen() # Handler for incoming trusted connections @dbus.service.method('com.github.stenogotchi', in_signature='aay') # array of bytearrays def send_keys(self, key_list): for key in key_list: self.device.send(key) @dbus.service.method('com.github.stenogotchi', in_signature='b') # boolean def plover_is_running(self, b): logging.debug('[plover_link] plover_is_running = ' + str(b)) if b: self._agent.set_plover_boot() else: self._agent.set_plover_quit() @dbus.service.method('com.github.stenogotchi', in_signature='b') # boolean def plover_is_ready(self, b): logging.debug('[plover_link] plover_is_ready = ' + str(b)) self._agent.set_plover_ready() @dbus.service.method('com.github.stenogotchi', in_signature='s') # string def plover_machine_state(self, s): logging.debug('[plover_link] plover_machine_state = ' + s) @dbus.service.method('com.github.stenogotchi', in_signature='b') # boolean def plover_output_enabled(self, b): logging.debug('[plover_link] plover_output_enabled = ' + str(b)) @dbus.service.method('com.github.stenogotchi', in_signature='s') # string def plover_wpm_stats(self, s): logging.debug('[plover_link] plover_wpm_stats = ' + s) wpm = int(s) if not self.wpm_top: self.wpm_top = wpm self._agent.set_wpm(wpm, self.wpm_top) else: if wpm > self.wpm_top: self.wpm_top = wpm logging.debug(f'[plover_link] new wpm record: {self.wpm_top}') self._agent.set_wpm_record(self.wpm_top) else: self._agent.set_wpm(wpm, self.wpm_top) @dbus.service.method('com.github.stenogotchi', in_signature='s') # string def plover_strokes_stats(self, s): # logging.debug(f"[plover_link] plover_strokes_stats = '{s}'") self._agent.set_strokes(s) @dbus.service.method('com.github.stenogotchi', in_signature='s') # string def send_string_stenogotchi(self, s): plugins.loaded['dict_lookup'].input_handler._on_send_string(s) # logging.debug(f"[plover_link] send_string_stenogotchi = '{s}'") @dbus.service.method('com.github.stenogotchi', in_signature='n') # 16-bit signed int def send_backspaces_stenogotchi(self, n): plugins.loaded['dict_lookup'].input_handler._on_send_backspaces(n) # logging.debug(f"[plover_link] send_backspaces_stenogotchi = '{n}'") @dbus.service.method('com.github.stenogotchi', in_signature='s') # string def send_key_combination_stenogotchi(self, s): plugins.loaded['dict_lookup'].input_handler._on_send_key_combination(s) # logging.debug(f"[plover_link] send_key_combination_stenogotchi = '{s}'") @dbus.service.method('com.github.stenogotchi', in_signature='as') # list of strings def plover_translation_handler(self, l): plugins.loaded['dict_lookup'].input_handler._on_lookup_results(l) # logging.debug(f"[plover_link] plover_translation_handler = '{l}'") @dbus.service.signal('com.github.stenogotchi', signature='a{sv}') # dictionary of strings to variants def signal_to_plover(self, message): # The signal is emitted when this method exits pass class PloverLink(ObjectClass): __autohor__ = 'Anodynous' __version__ = '0.3' __license__ = 'MIT' __description__ = 'This plugin enables connectivity to Plover through D-Bus. Note that it needs root permissions due to using sockets' def __init__(self): self._agent = None self.running = False self._stenogotchiservice = None self.mainloop = None # called when everything is ready and the main loop is about to start def on_ready(self, agent): self._agent = agent # used for agent/automata functionsadded to be able to do callbacks to agent events DBusGMainLoop(set_as_default=True) self._stenogotchiservice = StenogotchiService() self.mainloop = GLib.MainLoop() try: self.mainloop.run() self.running = True logging.info("[plover_link] PloverLink is up") except: logging.error("[plover_link] Could not start PloverLink") def on_plover_ready(self, agent): self._stenogotchiservice.auto_connect() def on_config_changed(self, config): self.config = config def on_unload(self, ui): self.mainloop.quit() def send_signal_to_plover(self, message): self._stenogotchiservice.signal_to_plover(message) if __name__ == '__main__': # The sockets require root permission if not os.geteuid() == 0: sys.exit('Only root can run this script') DBusGMainLoop(set_as_default=True) stenogotchiservice = StenogotchiService() mainloop = GLib.MainLoop() mainloop.run() ================================================ FILE: stenogotchi/plugins/default/plover_link_btserver_sdp_record.xml ================================================ ================================================ FILE: stenogotchi/plugins/default/upslite.py ================================================ #!/usr/bin/env python3 """ Based on: https://github.com/linshuqin329/UPS-Lite Requires i2c to be enabled in dietpi-config (or raspi-config) Supports readings from the https://hackaday.io/project/173847-ups-lite platform for Raspberry Pi Zero W """ import logging import struct import smbus import sys import time import RPi.GPIO as GPIO import stenogotchi if not __name__ == '__main__': import stenogotchi.plugins as plugins ObjectClass = plugins.Plugin else: ObjectClass = object I2CBUS = 1 # Run "sudo i2cdetect -l" to see which bus is being used (bcm2835 is what you are looking for). 0 = /dev/i2c-0 (port I2C0), 1 = /dev/i2c-1 (port I2C1) I2CADDRESS = 0x36 # Run "sudo i2cdetect -y 1" to see address 36 mounted on the i2c bus. This is MAXI17040G which we want to interact with class Upslite(ObjectClass): __autohor__ = 'Anodynous' __version__ = '0.1' __license__ = 'GPL3' __description__ = 'This plugin enables battery readings for the UPS-Lite V1.2 RPI0 module using integrated MAX17040' def __init__(self): self.GPIO = GPIO self.bus = None self.address = I2CADDRESS self.is_plugged = False self.voltage = 0 self.charge = 0 # Called when plugin is loaded def on_loaded(self): try: self.GPIO.setmode(self.GPIO.BCM) self.GPIO.setwarnings(False) self.GPIO.setup(4,self.GPIO.IN) self.bus = smbus.SMBus(I2CBUS) self._power_on_reset() self._quickstart() except: logging.error("[upslite] Could not start UPS-Lite plugin") def on_config_changed(self, config): self.config = config # Called when the ui is updated def on_ui_update(self, ui): # update those elements self._read_charge() self._check_plugged() # Set battery reading ui_string = str(self.charge) if self.is_plugged: ui_string += "+" ui.set('ups', ui_string) # Check for critical level. Initiate shutdown if too low if not self.is_plugged: if self.charge <= self.options['shutdown_level']: logging.info(f'[upslite] Battery charge critical: {self.charge}') ui.update(force=True, new_data={'status': 'Battery level critical. Shutting down in 1m unless connected to charger...'}) time.sleep(60) self._check_plugged() if not self.is_plugged: logging.info('[upslite] Shutting down') stenogotchi.shutdown() else: logging.info('[upslite] Battery charging. Aborting shutdown process') ui.update(force=True, new_data={'status': 'Pheew... That was a close one! Feeling better already.'}) def _read_voltage(self): """ Reads and sets as a float the voltage from the Raspi UPS Hat via the provided SMBus object""" read = self.bus.read_word_data(self.address, 0X02) swapped = struct.unpack("H", read))[0] voltage = swapped * 1.25 /1000/16 if voltage > 0: # if we get a non-zero value self.voltage = round(5.2 % voltage, 2) def _read_charge(self): """ Reads and sets as an int the remaining charge of the battery connected to the Raspi UPS Hat via the provided SMBus object. """ self.is_full = False self.is_low = False self.is_critical = False read = self.bus.read_word_data(self.address, 0X04) swapped = struct.unpack("H", read))[0] charge = swapped/256 if charge > 100: charge = 100 self.charge = round(charge) def _check_plugged(self): if (self.GPIO.input(4) == self.GPIO.HIGH): self.is_plugged = True elif (self.GPIO.input(4) == self.GPIO.LOW): self.is_plugged = False def _quickstart(self): self.bus.write_word_data(self.address, 0x06,0x4000) def _power_on_reset(self): self.bus.write_word_data(self.address, 0xfe,0x0054) def get_charge(self): self._read_charge() return self.charge def get_voltage(self): self._read_voltage() return self.voltage def get_is_plugged(self): self._check_plugged() return self.is_plugged if __name__ == '__main__': ups = Upslite() ups.on_loaded() time.sleep(1) # needs a second to ensure non-zero values are returned on initial read print("++++++++++++++++++++") while True: voltage = ups.get_voltage() charge = ups.get_charge() is_plugged = ups.get_is_plugged() print("Voltage:%5.2fV" % voltage) print("Battery:%5i%%" % charge) if charge > 99: print("Battery FULL") else: if charge < 2: print("Battery CRITICAL") elif charge < 6: print("Battery LOW") if is_plugged: print("Power Adapter Plugged In ") else: print("Power Adapter Unplugged") print("++++++++++++++++++++") time.sleep(5) ================================================ FILE: stenogotchi/ui/__init__.py ================================================ ================================================ FILE: stenogotchi/ui/components.py ================================================ from PIL import Image from textwrap import TextWrapper class Widget(object): def __init__(self, xy, color=0): self.xy = xy self.color = color def draw(self, canvas, drawer): raise Exception("not implemented") class Bitmap(Widget): def __init__(self, path, xy, color=0): super().__init__(xy, color) self.image = Image.open(path) def draw(self, canvas, drawer): canvas.paste(self.image, self.xy) class Line(Widget): def __init__(self, xy, color=0, width=1): super().__init__(xy, color) self.width = width def draw(self, canvas, drawer): drawer.line(self.xy, fill=self.color, width=self.width) class Rect(Widget): def draw(self, canvas, drawer): drawer.rectangle(self.xy, outline=self.color) class FilledRect(Widget): def draw(self, canvas, drawer): drawer.rectangle(self.xy, fill=self.color) class Text(Widget): def __init__(self, value="", position=(0, 0), font=None, color=0, wrap=False, max_length=0): super().__init__(position, color) self.value = value self.font = font self.wrap = wrap self.max_length = max_length self.wrapper = TextWrapper(width=self.max_length, replace_whitespace=False) if wrap else None def draw(self, canvas, drawer): if self.value is not None: if self.wrap: text = '\n'.join(self.wrapper.wrap(self.value)) else: text = self.value drawer.text(self.xy, text, font=self.font, fill=self.color) class LabeledValue(Widget): def __init__(self, label, value="", position=(0, 0), label_font=None, text_font=None, color=0, label_spacing=5, max_length=0): super().__init__(position, color) self.label = label self.label_font = label_font self.max_length = max_length self.value = value self.text_font = text_font self.label_spacing = label_spacing def draw(self, canvas, drawer): if self.max_length > 0: value = self.value[:self.max_length] else: value = self.value if self.label is None: drawer.text(self.xy, value, font=self.label_font, fill=self.color) else: pos = self.xy drawer.text(pos, self.label, font=self.label_font, fill=self.color) drawer.text((pos[0] + self.label_spacing + 5 * len(self.label), pos[1]), value, font=self.text_font, fill=self.color) ================================================ FILE: stenogotchi/ui/display.py ================================================ import os import logging import threading import stenogotchi.plugins as plugins import stenogotchi.ui.hw as hw from stenogotchi.ui.view import View class Display(View): def __init__(self, config, state={}): super(Display, self).__init__(config, hw.display_for(config), state) config = config['ui']['display'] self._enabled = config['enabled'] self._rotation = config['rotation'] self.init_display() self._canvas_next_event = threading.Event() self._canvas_next = None self._render_thread_instance = threading.Thread( target=self._render_thread, daemon=True ) self._render_thread_instance.start() def is_waveshare_v2(self): return self._implementation.name == 'waveshare_2' def init_display(self): if self._enabled: self._implementation.initialize() plugins.on('display_setup', self._implementation) else: logging.warning("display module is disabled") self.on_render(self._on_view_rendered) def clear(self): self._implementation.clear() def image(self): img = None if self._canvas is not None: img = self._canvas if self._rotation == 0 else self._canvas.rotate(-self._rotation) return img def _render_thread(self): """Used for non-blocking screen updating.""" while True: self._canvas_next_event.wait() self._canvas_next_event.clear() self._implementation.render(self._canvas_next) def _on_view_rendered(self, img): try: if self._config['ui']['web']['on_frame'] != '': os.system(self._config['ui']['web']['on_frame']) except Exception as e: logging.error("%s" % e) if self._enabled: self._canvas = (img if self._rotation == 0 else img.rotate(self._rotation)) if self._implementation is not None: self._canvas_next = self._canvas self._canvas_next_event.set() ================================================ FILE: stenogotchi/ui/faces.py ================================================ LOOK_R = '( ⚆_⚆)' LOOK_L = '(☉_☉ )' LOOK_R_HAPPY = '( ◕‿◕)' LOOK_L_HAPPY = '(◕‿◕ )' SLEEP = '(⇀‿‿↼)' SLEEP2 = '(≖‿‿≖)' AWAKE = '(◕‿‿◕)' BORED = '(-__-)' INTENSE = '(°▃▃°)' COOL = '(⌐■_■)' HAPPY = '(•‿‿•)' GRATEFUL = '(^‿‿^)' EXCITED = '(ᵔ◡◡ᵔ)' MOTIVATED = '(☼‿‿☼)' DEMOTIVATED = '(≖__≖)' SMART = '(✜‿‿✜)' LONELY = '(ب__ب)' SAD = '(╥☁╥ )' ANGRY = "(-_-')" FRIEND = '(♥‿‿♥)' BROKEN = '(☓‿‿☓)' DEBUG = '(#__#)' PROCESS_1 = '(1__0)' PROCESS_2 = '(1__1)' PROCESS_3 = '(0__1)' PROCESS_4 = '(0__0)' def load_from_config(config): for face_name, face_value in config.items(): globals()[face_name.upper()] = face_value ================================================ FILE: stenogotchi/ui/fonts.py ================================================ from PIL import ImageFont # should not be changed FONT_NAME = 'DejaVuSansMono' # can be changed STATUS_FONT_NAME = None SIZE_OFFSET = 0 Bold = None BoldSmall = None BoldBig = None Medium = None Small = None Huge = None def init(config): global STATUS_FONT_NAME, SIZE_OFFSET STATUS_FONT_NAME = config['ui']['font']['name'] SIZE_OFFSET = config['ui']['font']['size_offset'] setup(10, 9, 10, 35, 25, 9) def status_font(old_font): global STATUS_FONT_NAME, SIZE_OFFSET return ImageFont.truetype(STATUS_FONT_NAME, size=old_font.size + SIZE_OFFSET) def setup(bold, bold_small, medium, huge, bold_big, small): global Bold, BoldSmall, Medium, Huge, BoldBig, Small, FONT_NAME Small = ImageFont.truetype(FONT_NAME, small) Medium = ImageFont.truetype(FONT_NAME, medium) BoldSmall = ImageFont.truetype("%s-Bold" % FONT_NAME, bold_small) Bold = ImageFont.truetype("%s-Bold" % FONT_NAME, bold) BoldBig = ImageFont.truetype("%s-Bold" % FONT_NAME, bold_big) Huge = ImageFont.truetype("%s-Bold" % FONT_NAME, huge) ================================================ FILE: stenogotchi/ui/hw/__init__.py ================================================ from stenogotchi.ui.hw.waveshare2 import WaveshareV2 def display_for(config): # config has been normalized already in utils.load_config if config['ui']['display']['type'] == 'waveshare_2': return WaveshareV2(config) else: print("no display specified") ================================================ FILE: stenogotchi/ui/hw/base.py ================================================ import stenogotchi.ui.fonts as fonts class DisplayImpl(object): def __init__(self, config, name): self.name = name self.config = config['ui']['display'] self._layout = { 'width': 0, 'height': 0, 'face': (0, 0), 'name': (0, 0), 'ups': (0, 0), 'wpm': (0, 0), 'strokes': (0, 0), 'uptime': (0, 0), 'line1': (0, 0), 'line2': (0, 0), 'friend_face': (0, 0), 'friend_name': (0, 0), 'bthost': { 'pos': (0, 0), 'max': 20 }, 'wifi': { 'pos': (0, 0), 'max': 20 }, 'mode': (0, 0), # status is special :D 'status': { 'pos': (0, 0), 'font': fonts.status_font(fonts.Medium), 'max': 20 } } def layout(self): raise NotImplementedError def initialize(self): raise NotImplementedError def render(self, canvas): raise NotImplementedError def clear(self): raise NotImplementedError ================================================ FILE: stenogotchi/ui/hw/libs/__init__.py ================================================ ================================================ FILE: stenogotchi/ui/hw/libs/waveshare/__init__.py ================================================ ================================================ FILE: stenogotchi/ui/hw/libs/waveshare/v2/__init__.py ================================================ ================================================ FILE: stenogotchi/ui/hw/libs/waveshare/v2/epd2in13_V2.py ================================================ # //***************************************************************************** # * | File : epd2in13.py # * | Author : Waveshare team # * | Function : Electronic paper driver # * | Info : # *---------------- # * | This version: V3.0 # * | Date : 2018-11-01 # * | Info : python2 demo # * 1.Remove: # digital_write(self, pin, value) # digital_read(self, pin) # delay_ms(self, delaytime) # set_lut(self, lut) # self.lut = self.lut_full_update # * 2.Change: # display_frame -> TurnOnDisplay # set_memory_area -> SetWindow # set_memory_pointer -> SetCursor # * 3.How to use # epd = epd2in13.EPD() # epd.init(epd.lut_full_update) # image = Image.new('1', (epd2in13.EPD_WIDTH, epd2in13.EPD_HEIGHT), 255) # ... # drawing ...... # ... # epd.display(getbuffer(image)) # ******************************************************************************// # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and//or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # import time import spidev import RPi.GPIO as GPIO from PIL import Image # Pin definition RST_PIN = 17 DC_PIN = 25 CS_PIN = 8 BUSY_PIN = 24 # SPI device, bus = 0, device = 0 SPI = spidev.SpiDev(0, 0) def digital_write(pin, value): GPIO.output(pin, value) def digital_read(pin): return GPIO.input(BUSY_PIN) def delay_ms(delaytime): time.sleep(delaytime / 1000.0) def spi_writebyte(data): SPI.writebytes(data) def module_init(): GPIO.setmode(GPIO.BCM) GPIO.setwarnings(False) GPIO.setup(RST_PIN, GPIO.OUT) GPIO.setup(DC_PIN, GPIO.OUT) GPIO.setup(CS_PIN, GPIO.OUT) GPIO.setup(BUSY_PIN, GPIO.IN) SPI.max_speed_hz = 2000000 SPI.mode = 0b00 return 0; # Display resolution EPD_WIDTH = 122 EPD_HEIGHT = 250 class EPD: def __init__(self): self.reset_pin = RST_PIN self.dc_pin = DC_PIN self.busy_pin = BUSY_PIN self.width = EPD_WIDTH self.height = EPD_HEIGHT FULL_UPDATE = 0 PART_UPDATE = 1 lut_full_update = [ 0x80, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, # LUT0: BB: VS 0 ~7 0x10, 0x60, 0x20, 0x00, 0x00, 0x00, 0x00, # LUT1: BW: VS 0 ~7 0x80, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, # LUT2: WB: VS 0 ~7 0x10, 0x60, 0x20, 0x00, 0x00, 0x00, 0x00, # LUT3: WW: VS 0 ~7 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # LUT4: VCOM: VS 0 ~7 0x03, 0x03, 0x00, 0x00, 0x02, # TP0 A~D RP0 0x09, 0x09, 0x00, 0x00, 0x02, # TP1 A~D RP1 0x03, 0x03, 0x00, 0x00, 0x02, # TP2 A~D RP2 0x00, 0x00, 0x00, 0x00, 0x00, # TP3 A~D RP3 0x00, 0x00, 0x00, 0x00, 0x00, # TP4 A~D RP4 0x00, 0x00, 0x00, 0x00, 0x00, # TP5 A~D RP5 0x00, 0x00, 0x00, 0x00, 0x00, # TP6 A~D RP6 0x15, 0x41, 0xA8, 0x32, 0x30, 0x0A, ] lut_partial_update = [ # 20 bytes 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # LUT0: BB: VS 0 ~7 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # LUT1: BW: VS 0 ~7 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # LUT2: WB: VS 0 ~7 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # LUT3: WW: VS 0 ~7 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # LUT4: VCOM: VS 0 ~7 0x0A, 0x00, 0x00, 0x00, 0x00, # TP0 A~D RP0 0x00, 0x00, 0x00, 0x00, 0x00, # TP1 A~D RP1 0x00, 0x00, 0x00, 0x00, 0x00, # TP2 A~D RP2 0x00, 0x00, 0x00, 0x00, 0x00, # TP3 A~D RP3 0x00, 0x00, 0x00, 0x00, 0x00, # TP4 A~D RP4 0x00, 0x00, 0x00, 0x00, 0x00, # TP5 A~D RP5 0x00, 0x00, 0x00, 0x00, 0x00, # TP6 A~D RP6 0x15, 0x41, 0xA8, 0x32, 0x30, 0x0A, ] # Hardware reset def reset(self): digital_write(self.reset_pin, GPIO.HIGH) delay_ms(200) digital_write(self.reset_pin, GPIO.LOW) # module reset delay_ms(200) digital_write(self.reset_pin, GPIO.HIGH) delay_ms(200) def send_command(self, command): digital_write(self.dc_pin, GPIO.LOW) spi_writebyte([command]) def send_data(self, data): digital_write(self.dc_pin, GPIO.HIGH) spi_writebyte([data]) def wait_until_idle(self): while (digital_read(self.busy_pin) == 1): # 0: idle, 1: busy delay_ms(100) def TurnOnDisplay(self): self.send_command(0x22) self.send_data(0xC7) self.send_command(0x20) self.wait_until_idle() def init(self, update): if (module_init() != 0): return -1 # EPD hardware init start self.reset() if (update == self.FULL_UPDATE): self.wait_until_idle() self.send_command(0x12) # soft reset self.wait_until_idle() self.send_command(0x74) # set analog block control self.send_data(0x54) self.send_command(0x7E) # set digital block control self.send_data(0x3B) self.send_command(0x01) # Driver output control self.send_data(0xF9) self.send_data(0x00) self.send_data(0x00) self.send_command(0x11) # data entry mode self.send_data(0x01) self.send_command(0x44) # set Ram-X address start//end position self.send_data(0x00) self.send_data(0x0F) # 0x0C-->(15+1)*8=128 self.send_command(0x45) # set Ram-Y address start//end position self.send_data(0xF9) # 0xF9-->(249+1)=250 self.send_data(0x00) self.send_data(0x00) self.send_data(0x00) self.send_command(0x3C) # BorderWavefrom self.send_data(0x03) self.send_command(0x2C) # VCOM Voltage self.send_data(0x55) # self.send_command(0x03) self.send_data(self.lut_full_update[70]) self.send_command(0x04) # self.send_data(self.lut_full_update[71]) self.send_data(self.lut_full_update[72]) self.send_data(self.lut_full_update[73]) self.send_command(0x3A) # Dummy Line self.send_data(self.lut_full_update[74]) self.send_command(0x3B) # Gate time self.send_data(self.lut_full_update[75]) self.send_command(0x32) for count in range(70): self.send_data(self.lut_full_update[count]) self.send_command(0x4E) # set RAM x address count to 0 self.send_data(0x00) self.send_command(0x4F) # set RAM y address count to 0X127 self.send_data(0xF9) self.send_data(0x00) self.wait_until_idle() else: self.send_command(0x2C) # VCOM Voltage self.send_data(0x26) self.wait_until_idle() self.send_command(0x32) for count in range(70): self.send_data(self.lut_partial_update[count]) self.send_command(0x37) self.send_data(0x00) self.send_data(0x00) self.send_data(0x00) self.send_data(0x00) self.send_data(0x40) self.send_data(0x00) self.send_data(0x00) self.send_command(0x22) self.send_data(0xC0) self.send_command(0x20) self.wait_until_idle() self.send_command(0x3C) # BorderWavefrom self.send_data(0x01) return 0 def getbuffer(self, image): if self.width % 8 == 0: linewidth = self.width // 8 else: linewidth = self.width // 8 + 1 buf = [0xFF] * (linewidth * self.height) image_monocolor = image.convert('1') imwidth, imheight = image_monocolor.size pixels = image_monocolor.load() if (imwidth == self.width and imheight == self.height): # print("Vertical") for y in range(imheight): for x in range(imwidth): if pixels[x, y] == 0: x = imwidth - x buf[x // 8 + y * linewidth] &= ~(0x80 >> (x % 8)) elif (imwidth == self.height and imheight == self.width): # print("Horizontal") for y in range(imheight): for x in range(imwidth): newx = y newy = self.height - x - 1 if pixels[x, y] == 0: newy = imwidth - newy - 1 buf[newx // 8 + newy * linewidth] &= ~(0x80 >> (y % 8)) return buf def display(self, image): if self.width % 8 == 0: linewidth = self.width // 8 else: linewidth = self.width // 8 + 1 self.send_command(0x24) for j in range(0, self.height): for i in range(0, linewidth): self.send_data(image[i + j * linewidth]) self.TurnOnDisplay() def displayPartial(self, image): if self.width % 8 == 0: linewidth = self.width // 8 else: linewidth = self.width // 8 + 1 self.send_command(0x24) for j in range(0, self.height): for i in range(0, linewidth): self.send_data(image[i + j * linewidth]) self.send_command(0x26) for j in range(0, self.height): for i in range(0, linewidth): self.send_data(~image[i + j * linewidth]) self.TurnOnDisplay() def Clear(self, color): if self.width % 8 == 0: linewidth = self.width // 8 else: linewidth = self.width // 8 + 1 # print(linewidth) self.send_command(0x24) for j in range(0, self.height): for i in range(0, linewidth): self.send_data(color) self.TurnOnDisplay() def sleep(self): self.send_command(0x22) # POWER OFF self.send_data(0xC3) self.send_command(0x20) self.send_command(0x10) # enter deep sleep self.send_data(0x01) delay_ms(100) ### END OF FILE ### ================================================ FILE: stenogotchi/ui/hw/waveshare2.py ================================================ import logging import stenogotchi.ui.fonts as fonts from stenogotchi.ui.hw.base import DisplayImpl class WaveshareV2(DisplayImpl): def __init__(self, config): super(WaveshareV2, self).__init__(config, 'waveshare_2') self._display = None def layout(self): if self.config['color'] == 'black': fonts.setup(10, 9, 10, 35, 25, 9) self._layout['width'] = 250 self._layout['height'] = 122 self._layout['face'] = (0, 40) self._layout['name'] = (5, 20) self._layout['ups'] = (0, 0) self._layout['wpm'] = { 'pos': (50, 0), 'max': 9 } self._layout['strokes'] = { 'pos': (128, 0), 'max': 5 } self._layout['uptime'] = (185, 0) self._layout['line1'] = [0, 14, 250, 14] self._layout['line2'] = [0, 108, 250, 108] self._layout['friend_face'] = (0, 92) self._layout['friend_name'] = (40, 94) self._layout['bthost'] = { 'pos': (0, 109), 'max': 15 } self._layout['wifi'] = { 'pos': (113, 109), 'max': 12 } self._layout['mode'] = (210, 109) self._layout['status'] = { 'pos': (125, 20), 'font': fonts.status_font(fonts.Medium), 'max': 20 } else: # these are not correctly configured based on different width and height of color display fonts.setup(10, 9, 10, 35, 25, 9) self._layout['width'] = 212 self._layout['height'] = 104 self._layout['face'] = (0, 26) self._layout['name'] = (5, 15) self._layout['ups'] = (0, 0) self._layout['wpm'] = { 'pos': (50, 0), 'max': 9 } self._layout['strokes'] = { 'pos': (128, 0), 'max': 5 } self._layout['status'] = (91, 15) self._layout['uptime'] = (147, 0) self._layout['line1'] = [0, 12, 212, 12] self._layout['line2'] = [0, 92, 212, 92] self._layout['friend_face'] = (0, 76) self._layout['friend_name'] = (40, 78) self._layout['bthost'] = { 'pos': (0, 93), 'max': 15 } self._layout['wifi'] = { 'pos': (113, 109), 'max': 12 } self._layout['mode'] = (187, 93) self._layout['status'] = { 'pos': (125, 20), 'font': fonts.status_font(fonts.Medium), 'max': 14 } return self._layout def initialize(self): logging.info("initializing waveshare v2 display") from stenogotchi.ui.hw.libs.waveshare.v2.epd2in13_V2 import EPD self._display = EPD() self._display.init(self._display.FULL_UPDATE) self._display.Clear(0xff) self._display.init(self._display.PART_UPDATE) def render(self, canvas): buf = self._display.getbuffer(canvas) self._display.displayPartial(buf) def clear(self): self._display.Clear(0xff) ================================================ FILE: stenogotchi/ui/state.py ================================================ from threading import Lock class State(object): def __init__(self, state={}): self._state = state self._lock = Lock() self._listeners = {} self._changes = {} def add_element(self, key, elem): self._state[key] = elem self._changes[key] = True def has_element(self, key): return key in self._state def remove_element(self, key): del self._state[key] self._changes[key] = True def add_listener(self, key, cb): with self._lock: self._listeners[key] = cb def items(self): with self._lock: return self._state.items() def get(self, key): with self._lock: return self._state[key].value if key in self._state else None def reset(self): with self._lock: self._changes = {} def changes(self, ignore=()): with self._lock: changes = [] for change in self._changes.keys(): if change not in ignore: changes.append(change) return changes def has_changes(self): with self._lock: return len(self._changes) > 0 def set(self, key, value): with self._lock: if key in self._state: prev = self._state[key].value self._state[key].value = value if prev != value: self._changes[key] = True if key in self._listeners and self._listeners[key] is not None: self._listeners[key](prev, value) ================================================ FILE: stenogotchi/ui/view.py ================================================ import _thread from threading import Lock import time import logging import random from PIL import ImageDraw import stenogotchi import stenogotchi.utils as utils import stenogotchi.plugins as plugins from stenogotchi.voice import Voice import stenogotchi.ui.web as web import stenogotchi.ui.fonts as fonts import stenogotchi.ui.faces as faces from stenogotchi.ui.components import * from stenogotchi.ui.state import State WHITE = 0xff BLACK = 0x00 ROOT = None class View(object): def __init__(self, config, impl, state=None): global ROOT # setup faces from the configuration in case the user customized them faces.load_from_config(config['ui']['faces']) self._agent = None self._render_cbs = [] self._config = config self._canvas = None self._frozen = False self._lock = Lock() self._voice = Voice(lang=config['main']['lang']) self._implementation = impl self._layout = impl.layout() self._width = self._layout['width'] self._height = self._layout['height'] self._state = State(state={ 'ups': LabeledValue(color=BLACK, label='BAT', value='', position=self._layout['ups'], label_font=fonts.Bold,text_font=fonts.Medium), 'wpm': LabeledValue(color=BLACK, label='WPM', value='', position=self._layout['wpm']['pos'], label_font=fonts.Bold, text_font=fonts.Medium, max_length=self._layout['wpm']['max']), 'strokes': LabeledValue(color=BLACK, label='STR', value='', position=self._layout['strokes']['pos'], label_font=fonts.Bold, text_font=fonts.Medium, max_length=self._layout['strokes']['max']), 'uptime': LabeledValue(color=BLACK, label='UP', value='', position=self._layout['uptime'], label_font=fonts.Bold, text_font=fonts.Medium), 'line1': Line(self._layout['line1'], color=BLACK), 'line2': Line(self._layout['line2'], color=BLACK), 'face': Text(value=faces.SLEEP, position=self._layout['face'], color=BLACK, font=fonts.Huge), 'friend_face': Text(value=None, position=self._layout['friend_face'], font=fonts.Bold, color=BLACK), 'friend_name': Text(value=None, position=self._layout['friend_name'], font=fonts.BoldSmall, color=BLACK), 'name': Text(value='%s>' % 'stenogotchi', position=self._layout['name'], color=BLACK, font=fonts.Bold), 'status': Text(value=self._voice.default(), position=self._layout['status']['pos'], color=BLACK, font=self._layout['status']['font'], wrap=True, # the current maximum number of characters per line, assuming each character is 6 pixels wide max_length=self._layout['status']['max']), 'bthost': LabeledValue(label='BT', value='', color=BLACK, position=self._layout['bthost']['pos'], label_font=fonts.Bold, text_font=fonts.Medium, max_length=self._layout['bthost']['max']), 'wifi': LabeledValue(label='WIFI', value='', color=BLACK, position=self._layout['wifi']['pos'], label_font=fonts.Bold, text_font=fonts.Medium, max_length=self._layout['wifi']['max']), 'mode': Text(value='NONE', position=self._layout['mode'], font=fonts.Bold, color=BLACK), }) if state: for key, value in state.items(): self._state.set(key, value) plugins.on('ui_setup', self) if config['ui']['fps'] > 0.0: _thread.start_new_thread(self._refresh_handler, ()) self._ignore_changes = () else: logging.warning("ui.fps is 0, the display will only update for major changes") self._ignore_changes = ('uptime', 'name') ROOT = self def set_agent(self, agent): self._agent = agent def has_element(self, key): self._state.has_element(key) def add_element(self, key, elem): self._state.add_element(key, elem) def remove_element(self, key): self._state.remove_element(key) def width(self): return self._width def height(self): return self._height def on_state_change(self, key, cb): self._state.add_listener(key, cb) def on_render(self, cb): if cb not in self._render_cbs: self._render_cbs.append(cb) def _refresh_handler(self): delay = 1.0 / self._config['ui']['fps'] while True: try: name = self._state.get('name') self.set('name', name.rstrip('█').strip() if '█' in name else (name + ' █')) self.update() except Exception as e: logging.warning("non fatal error while updating view: %s" % e) time.sleep(delay) def set(self, key, value): self._state.set(key, value) def get(self, key): return self._state.get(key) def on_starting(self): self.set('status', self._voice.on_starting() + ("\n(v%s)" % stenogotchi.__version__)) self.set('face', faces.AWAKE) self.update() def on_manual_mode(self, last_session): self.set('mode', 'MANU') self.set('face', faces.HAPPY) self.set('status', self._voice.on_last_session_data(last_session)) self.set('uptime', last_session.duration) self.update() def is_normal(self): return self._state.get('face') not in ( faces.INTENSE, faces.COOL, faces.BORED, faces.HAPPY, faces.EXCITED, faces.MOTIVATED, faces.DEMOTIVATED, faces.SMART, faces.SAD, faces.LONELY) def on_keys_generation(self): self.set('face', faces.AWAKE) self.set('status', self._voice.on_keys_generation()) self.update() def on_normal(self): self.set('face', faces.AWAKE) self.set('status', self._voice.on_normal()) self.update() def on_reading_logs(self, lines_so_far=0): self.set('face', faces.SMART) self.set('status', self._voice.on_reading_logs(lines_so_far)) self.update() def wait(self, secs, sleeping=True): was_normal = self.is_normal() part = secs / 10.0 for step in range(0, 10): # if we weren't in a normal state before going # to sleep, keep that face and status on for # a while, otherwise the sleep animation will # always override any minor state change before it if was_normal or step > 5: if sleeping: if secs > 1: self.set('face', faces.SLEEP) self.set('status', self._voice.on_napping(int(secs))) else: self.set('face', faces.SLEEP2) self.set('status', self._voice.on_awakening()) else: self.set('status', self._voice.on_waiting(int(secs))) good_mood = self._agent.in_good_mood() if step % 2 == 0: self.set('face', faces.LOOK_R_HAPPY if good_mood else faces.LOOK_R) else: self.set('face', faces.LOOK_L_HAPPY if good_mood else faces.LOOK_L) time.sleep(part) secs -= part self.on_normal() def on_shutdown(self): face = random.choices((faces.SLEEP, faces.SLEEP2), weights=[0.7, 0.3], k=1) self.set('face', face[0]) self.set('status', self._voice.on_shutdown()) self.update(force=True) self._frozen = True def on_bored(self): self.set('face', faces.BORED) self.set('status', self._voice.on_bored()) self.update() def on_sad(self): self.set('face', faces.SAD) self.set('status', self._voice.on_sad()) self.update() def on_angry(self): self.set('face', faces.ANGRY) self.set('status', self._voice.on_angry()) self.update() def on_motivated(self, reward): self.set('face', faces.MOTIVATED) self.set('status', self._voice.on_motivated(reward)) self.update() def on_demotivated(self, reward): self.set('face', faces.DEMOTIVATED) self.set('status', self._voice.on_demotivated(reward)) self.update() def on_excited(self): self.set('face', faces.EXCITED) self.set('status', self._voice.on_excited()) self.update() def on_miss(self, who): self.set('face', faces.SAD) self.set('status', self._voice.on_miss(who)) self.update() def on_grateful(self): self.set('face', faces.GRATEFUL) self.set('status', self._voice.on_grateful()) self.update() def on_lonely(self): self.set('face', faces.LONELY) self.set('status', self._voice.on_lonely()) self.update() def on_processing(self): start_face = self._state.get('face') processing_faces = [faces.PROCESS_1, faces.PROCESS_2, faces.PROCESS_3, faces.PROCESS_4] for face in processing_faces: self.set('face', face) self.update() time.sleep(2) self.set('face', start_face) self.update() def on_rebooting(self): self.set('face', faces.BROKEN) self.set('status', self._voice.on_rebooting()) self.update() def on_custom(self, text): self.set('face', faces.DEBUG) self.set('status', self._voice.custom(text)) self.update() def on_plover_boot(self): face = random.choices([faces.SLEEP, faces.SLEEP2, faces.BORED], weights=[0.6, 0.3, 0.1], k=1) self.set('face', face[0]) self.set('status', self._voice.on_plover_boot()) self.update() def on_plover_ready(self): face = random.choices([faces.AWAKE, faces.LOOK_R_HAPPY, faces.LOOK_L_HAPPY, faces.EXCITED, faces.GRATEFUL], weights=[0.5, 0.2, 0.2, 0.05, 0.05], k=1) self.set('face', face[0]) self.set('status', self._voice.on_plover_ready()) if self._state.get('mode') == 'NONE': self.set('mode', 'STENO') self.update() def on_plover_quit(self): face = random.choice((faces.BROKEN, faces.DEBUG)) self.set('face', face) self.set('status', 'Uh-oh... I think Plover just quit on us') if self._state.get('mode') == 'STENO': self.set('mode', 'NONE') self.update() def on_wpm(self, wpm): self.set('wpm', wpm) self.update() def on_strokes(self, strokes): self.set('strokes', strokes) self.update() def on_wpm_record(self, wpm_top): if wpm_top < 100: self.set('face', faces.LOOK_R_HAPPY) elif wpm_top < 200: self.set('face', faces.EXCITED) else: self.set('face', faces.INTENSE) self.set('status', self._voice.on_wpm_record(wpm_top)) # Don't trigger self.update as we always will call set_wpm() afterwards. def on_bt_connected(self, bthost_name): self.set('face', faces.LOOK_L_HAPPY) self.set('status', self._voice.on_bt_connected(bthost_name)) self.update() def on_bt_disconnected(self): self.set('face', faces.INTENSE) self.set('status', self._voice.on_bt_disconnected()) self.update() def on_wifi_connected(self, ssid, ip): self.set('face', faces.COOL) self.set('status', self._voice.on_wifi_connected(ssid, ip)) self.update() def on_wifi_disconnected(self): self.set('face', faces.LONELY) self.set('status', self._voice.on_wifi_disconnected()) self.update() def on_dict_lookup_done(self): face = random.choices([faces.AWAKE, faces.LOOK_R_HAPPY, faces.LOOK_L_HAPPY, faces.EXCITED, faces.GRATEFUL], weights=[0.5, 0.2, 0.2, 0.05, 0.05], k=1) self.set('face', face[0]) self.set('status', self._voice.on_dict_lookup_done()) self.update() def update(self, force=False, new_data={}): for key, val in new_data.items(): self.set(key, val) with self._lock: if self._frozen: return state = self._state changes = state.changes(ignore=self._ignore_changes) if force or len(changes): self._canvas = Image.new('1', (self._width, self._height), WHITE) drawer = ImageDraw.Draw(self._canvas) plugins.on('ui_update', self) for key, lv in state.items(): lv.draw(self._canvas, drawer) web.update_frame(self._canvas) for cb in self._render_cbs: cb(self._canvas) self._state.reset() ================================================ FILE: stenogotchi/ui/web/__init__.py ================================================ import os from threading import Lock frame_path = '/var/tmp/stenogotchi/stenogotchi.png' frame_format = 'PNG' frame_ctype = 'image/png' frame_lock = Lock() def update_frame(img): global frame_lock, frame_path, frame_format if not os.path.exists(os.path.dirname(frame_path)): os.makedirs(os.path.dirname(frame_path)) with frame_lock: img.save(frame_path, format=frame_format) ================================================ FILE: stenogotchi/ui/web/handler.py ================================================ import logging import os import _thread import secrets import subprocess from functools import wraps # https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server logging.getLogger('werkzeug').setLevel(logging.ERROR) os.environ['WERKZEUG_RUN_MAIN'] = 'true' import stenogotchi import stenogotchi.ui.web as web from stenogotchi import plugins from flask import send_file from flask import Response from flask import request from flask import abort from flask import redirect from flask import render_template, render_template_string class Handler: def __init__(self, config, agent, app): self._config = config self._agent = agent self._app = app self._app.add_url_rule('/', 'index', self.with_auth(self.index)) self._app.add_url_rule('/ui', 'ui', self.with_auth(self.ui)) self._app.add_url_rule('/shutdown', 'shutdown', self.with_auth(self.shutdown), methods=['POST']) self._app.add_url_rule('/toggle_input', 'toggle_input', self.with_auth(self.toggle_input), methods=['POST']) self._app.add_url_rule('/toggle_wpm', 'toggle_wpm', self.with_auth(self.toggle_wpm), methods=['POST']) self._app.add_url_rule('/toggle_lookup', 'toggle_lookup', self.with_auth(self.toggle_lookup), methods=['POST']) self._app.add_url_rule('/buttonshim/