diff --git a/.gitignore b/.gitignore index 69ecdf7..7750d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,3 @@ dist docs/build sideband*.egg-info sbapp*.egg-info -LXST -environment -archived_build_tools -.gradle diff --git a/FUNDING.yml b/FUNDING.yml deleted file mode 100644 index d125d55..0000000 --- a/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -liberapay: Reticulum -ko_fi: markqvist -custom: "https://unsigned.io/donate" diff --git a/MIRROR.md b/MIRROR.md deleted file mode 100644 index ef2fdd4..0000000 --- a/MIRROR.md +++ /dev/null @@ -1,33 +0,0 @@ -This repository is a public mirror. All potential future development is happening elsewhere. - -I am stepping back from all public-facing interaction with this project. Reticulum has always been primarily my work, and continuing in the current public, internet-facing model is no longer sustainable. - -The software remains available for use as-is. Occasional updates may appear at unpredictable intervals, but there will be no support, no responses to issues, no discussions, and no community management in this or any other public venue. If it doesn't work for you, it doesn't work. That is the entire extent of available troubleshooting assistance I can offer you. - -If you've followed this project for a while, you already know what this means. You know who designed, wrote and tested this, and you know how many years of my life it took. You'll also know about both my particular challenges and strengths, and how I believe anything worth building needs to be built and maintained with our own hands. - -Seven months ago, I said I needed to step back, that I was exhausted, and that I needed to recover. I believed a public resolve would be enough to effectuate that, but while striving to get just a few more useful features and protocols out, the unproductive requests and demands also ramped up, and I got pulled back into the same patterns and draining interactions that I'd explicitly said I couldn't sustain anymore. - -So here's what you might have already guessed: I'm done playing the game by rules I can't win at. - -Everything you need is right here, and by any sensible measure, it's done. Anyone who wants to invest the time, skill and persistence can build on it, or completely re-imagine it with different priorities. That was always the point. - -The people who actually contributed - you know who you are, and you know I mean it when I say: Thank you. All of you who've used this to build something real - that was the goal, and you did it without needing me to hold your hand. - -The rest of you: You have what you need. Use it or don't. I am not going to be the person who explains it to you anymore. - -This is not a temporary break. It's not "see you after some rest", but a recognition that the current model is fundamentally incompatible with my life, my health, and my reality. - -If you want to support continued work, you can do so at the donation links listed in this repository. But please understand, that this is not purchasing support or guaranteeing updates. It is support for work that happens on my timeline, according to my capacity, which at the moment is not what it was. - -If you want Reticulum to continue evolving, you have the power to make that happen. The protocol is public domain. The code is open source. Everything you need is right here. I've provided the tools, but building what comes next is not my responsibility anymore. It's yours. - -To the small group of people who has actually been here, and understood what this work was and what it cost - you already know where to find me if it actually matters. - -To everyone else: This is where we part ways. No hard feelings. It's just time. - ---- - -असतो मा सद्गमय -तमसो मा ज्योतिर्गमय -मृत्योर्मा अमृतं गमय diff --git a/Makefile b/Makefile index 204a51a..6f5ed59 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,3 @@ -include environment - devapk: make -C sbapp devapk @@ -26,64 +24,15 @@ cleanbuildozer: cleanall: clean cleanbuildozer -remove_symlinks: - @echo Removing symlinks for build... - -rm ./RNS - -rm ./LXST - -rm ./LXMF - -create_symlinks: - @echo Creating symlinks... - -ln -s ../Reticulum/RNS ./RNS - -ln -s ../LXST/LXST ./LXST - -ln -s ../LXMF/LXMF ./LXMF - preparewheel: pyclean . $(MAKE) -C sbapp cleanrns -compile_wheel: - python3 setup.py bdist_wheel +build_wheel: + python3 setup.py sdist bdist_wheel -compile_sourcepkg: - python3 setup.py sdist - -update_share: - $(MAKE) -C sbapp fetchshare - -build_wheel: remove_symlinks update_share compile_wheel create_symlinks - -build_spkg: remove_symlinks update_share compile_sourcepkg create_symlinks - -prepare_win_pkg: clean build_spkg - -rm -r build/winpkg - mkdir -p build/winpkg - LC_ALL=C $(MAKE) -C ../Reticulum clean build_spkg - cp ../Reticulum/dist/rns-*.*.*.tar.gz build/winpkg - cd build/winpkg; tar -zxf rns-*.*.*.tar.gz - mv build/winpkg/rns-*.*.*/RNS build/winpkg; rm -r build/winpkg/rns-*.*.* - LC_ALL=C $(MAKE) -C ../LXMF clean build_spkg - cp ../LXMF/dist/lxmf-*.*.*.tar.gz build/winpkg - cd build/winpkg; tar -zxf lxmf-*.*.*.tar.gz - mv build/winpkg/lxmf-*.*.*/LXMF build/winpkg; rm -r build/winpkg/lxmf-*.*.* - LC_ALL=C $(MAKE) -C ../LXST clean build_spkg - cp ../LXST/dist/lxst-*.*.*.tar.gz build/winpkg - cd build/winpkg; tar -zxf lxst-*.*.*.tar.gz - mv build/winpkg/lxst-*.*.*/LXST build/winpkg; rm -r build/winpkg/lxst-*.*.* - rm build/winpkg/LXST/filterlib*.so - cp dist/sbapp-*.*.*.tar.gz build/winpkg - cd build/winpkg; tar -zxf sbapp-*.*.*.tar.gz - mv build/winpkg/sbapp-*.*.*/* build/winpkg; rm -r build/winpkg/sbapp-*.*.* - rm build/winpkg/LXST/Codecs/libs/pyogg/libs/macos -r - rm build/winpkg/sbapp/Makefile - rm build/winpkg/sbapp/buildozer.spec - cp winbuild.bat build/ - mv build/winpkg build/sideband_sources - cd build; zip -r winbuild.zip sideband_sources winbuild.bat - mv build/winbuild.zip dist/winbuild.zip - -build_winexe: prepare_win_pkg - cp dist/winbuild.zip $(WINDOWS_BUILD_TARGET) +build_win_exe: + python -m PyInstaller sideband.spec --noconfirm release: build_wheel apk fetchapk @@ -91,4 +40,4 @@ upload: @echo Ready to publish release, hit enter to continue @read VOID @echo Uploading to PyPi... - twine upload dist/sbapp-* + twine upload dist/sbapp-* \ No newline at end of file diff --git a/README.md b/README.md index 51edec3..557b655 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,7 @@ Sideband ========= -*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.* - -To understand the foundational philosophy and goals of this system, read the [Zen of Reticulum](Zen%20of%20Reticulum.md). - -Sideband is an extensible LXMF messaging and LXST telephony client, situational awareness tracker and remote control and monitoring system for Android, Linux, macOS and Windows. It allows you to communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, Encrypted QR Paper Messages, or anything else Reticulum supports. +Sideband is an extensible LXMF messaging client, situational awareness tracker and remote control and monitoring system for Android, Linux, macOS and Windows. It allows you to communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, Encrypted QR Paper Messages, or anything else Reticulum supports. ![Screenshot](https://github.com/markqvist/Sideband/raw/main/docs/screenshots/devices_small.webp) @@ -17,11 +13,10 @@ This also means that Sideband operates differently than what you might be used t Sideband provides many useful and interesting functions, such as: -- **Secure** and **self-sovereign** messaging and voice calls using the LXMF and LXST protocols over Reticulum. +- **Secure** and **self-sovereign** messaging using the LXMF protocol over Reticulum. - **Image** and **file transfers** over all supported mediums. - **Audio messages** that work even over **LoRa** and **radio links**, thanks to [Codec2](https://github.com/drowe67/codec2/) and [Opus](https://github.com/xiph/opus) encoding. - Secure and direct P2P **telemetry and location sharing**. No third parties or servers ever have your data. -- The telemetry system is **completely extensible** via [simple plugins](#creating-plugins). - Situation display on both online and **locally stored offline maps**. - Geospatial awareness calculations. - Exchanging messages through **encrypted QR-codes on paper**, or through messages embedded directly in **lxm://** links. @@ -44,68 +39,52 @@ Sideband can run on most computing devices, but installation methods vary by dev ## On Android -For your Android devices, you can download an [APK on the latest release page](https://github.com/markqvist/Sideband/releases/latest). +For your Android devices, you can install Sideband through F-Droid, by adding the [Between the Borders Repo](https://reticulum.betweentheborders.com/fdroid/repo/), or you can download an [APK on the latest release page](https://github.com/markqvist/Sideband/releases/latest). Both sources are signed with the same release keys, and can be used interchangably. After the application is installed on your Android device, it is also possible to pull updates directly through the **Repository** section of the application. -The Sideband APK file is always signed with a consistent signing certificate directly at build time. Before installing it for the first time, you can verify that the APK has not been modified, by checking that the APK file's signing certificate matches these hashes: - -```text -SHA-256 digest: 1c65f01f586a2b73ac4eb8bf48730b3899d046447185fd9d005685a4af20cdea -SHA-1 digest: 4ab9269c320c72f4e4057ec7ea5acade320c2a48 -MD5 digest: 09afff8c505089a544ad2bf371c29422 -``` - -Sideband will never be released on app store platforms that does not support complete control of the APK signing directly from the developer. If you download Sideband from any other source than this repository, and the certificate hashes do not match, **do not install it**. - -The Android version of Sideband has been carefully set up to **not** use any Android APIs or functionality that are dependent on Google (or other vendor-specific) components or libraries. It uses only raw Android OS APIs, and accesses them directly, instead of through "compatibility", "support" or "helper" libraries, which can often hijack application data flow into privacy-compromising pipelines controlled by Google or other vendors. - -This also means that Sideband is designed to be fully compatible with custom (and more privacy-friendly) Android versions and ROMs, such as GrapheneOS, de-googled devices and other custom ROMs. - ## On Linux -On all Linux-based operating systems, Sideband is available as a `pip`/`pipx` package. You can either pull and install Sideband directly from the `pip` repository, or download and install locally using `whl` package from the [latest release page](https://github.com/markqvist/Sideband/releases/latest). +On all Linux-based operating systems, Sideband is available as a `pipx`/`pip` package. This installation method **includes desktop integration**, so that Sideband will show up in your applications menu and launchers. Below are install steps for the most common recent Linux distros. For Debian 11, see the end of this section. -This installation method **includes desktop integration**, so that Sideband will show up in your applications menu and launchers. Below are install steps for the most common recent Linux distros. For Debian 11, see the end of this section. +**Please note!** The very latest Python release, Python 3.13 is currently **not** compatible with the Kivy framework, that Sideband uses to render its user interface. If your Linux distribution uses Python 3.13 as its default Python installation, you will need to install an earlier version as well. Using [the latest release of Python 3.12](https://www.python.org/downloads/release/python-3127/) is recommended. -#### Basic Installation -You will first need to install a few dependencies for voice calls, audio messaging and Codec2 support to work: +You will first need to install a few dependencies for audio messaging and Codec2 support to work: ```bash -# For Debian 13+, Ubuntu 24.04+ and derivatives -sudo apt install python3-pip python3-pyaudio libopusfile0 codec2 xclip xsel - -# For Debian 12+, Ubuntu 22.04+ and derivatives -sudo apt install python3-pip python3-pyaudio python3-dev build-essential libopusfile0 portaudio19-dev codec2 xclip xsel +# For Debian (12+), Ubuntu (22.04+) and derivatives +sudo apt install pipx python3-pyaudio python3-dev build-essential libopusfile0 portaudio19-dev codec2 xclip xsel # For Manjaro and derivatives -pamac install python-pyaudio opusfile codec2 xclip xsel +pamac install python-pipx python-pyaudio base-devel codec2 xclip xsel # For Arch and derivatives -sudo pacman -Sy python-pyaudio opusfile codec2 xclip xsel +sudo pacman -Sy python-pipx python-pyaudio base-devel codec2 xclip xsel ``` Once those are installed, install the Sideband application itself: ```bash -# Finally, install Sideband using pip: -pip install sbapp --break-system-packages +# Finally, install Sideband using pipx: +pipx install sbapp -# If Sideband does not show up in your application menu -# or as an executable command in your terminal, reboot -# your computer. -sudo reboot +# If you need to specify a specific Python version, +# use something like the following: +pipx install sbapp --python python3.12 ``` After installation, you can now run Sideband in a number of different ways: ```bash +# If this is the first time installing something with pipx, +# you may need to use the following command, to make your +# installed applications available. You'll probably need +# to close and reopen your terminal after this. +pipx ensurepath + # The first time you run Sideband, you will need to do it -# from the terminal, for the application launcher item to -# show up. On some distros you may also need to log out -# and back in again, or simply reboot the machine for the -# application entry to show up in your menu. +# from the terminal: sideband # At the first launch, it will add an application icon @@ -122,18 +101,18 @@ sideband --daemon sideband -v ``` -If you do not already have Reticulum connectivity set up on your computer or local network, you will probably want to edit the Reticulum configuration file at `~/.reticulum/config` and [add any interfaces](https://reticulum.network/manual/interfaces.html) you need for connectivity. - -#### Advanced Installation You can also install Sideband in various alternative ways: ```bash -# Install Sideband via pipx instead of pip: -pipx install sbapp +# Install Sideband via pip instead of pipx: +pip install sbapp + +# Or, if pip is externally managed: +pip install sbapp --break-system-packages # Or, if you intend to run Sideband in headless -# daemon or console mode, you can also install -# it without any of the normal UI dependencies: +# daemon mode, you can also install it without +# any of the normal UI dependencies: pip install sbapp --no-dependencies # In the case of using --no-dependencies, you @@ -141,9 +120,6 @@ pip install sbapp --no-dependencies # and LXMF dependencies: pip install rns lxmf -# Dependencies can vary slightly on older OS -# versions, due to different package names. - # Install Sideband on Debian 11 and derivatives: sudo apt install python3-pip python3-pyaudio python3-dev build-essential libopusfile0 portaudio19-dev codec2 xclip xsel pip install sbapp @@ -155,42 +131,34 @@ python3 -m sbapp.main ## On Raspberry Pi -You can install Sideband on all Raspberry Pi models that support 64-bit operating systems, and can run at least Python version 3.11. +You can install Sideband on all Raspberry Pi models that support 64-bit operating systems, and can run at least Python version 3.11. Since some of Sideband's dependencies don't have pre-built packages ready for 64-bit ARM processors yet, you'll need to install a few extra packages, that will allow building these while installing. -The install instructions below assume that you are installing Sideband on 64-bit Raspberry Pi OS (based on Debian 13 "Trixie" or later). If you're running something else on your Pi, you might need to modify some commands slightly. +Aditionally, the `pycodec2` package needs to be installed manually. I have provided a pre-built version, that you can download and install with a single command, or if you don't want to trust my pre-built version, you can [build and install it from source yourself](https://github.com/gregorias/pycodec2/blob/main/DEV.md). -To install Sideband on Raspberry Pi with full support for voice calls, audio messages and Codec2, follow these steps: +The install instructions below assume that you are installing Sideband on 64-bit Raspberry Pi OS (based on Debian Bookworm). If you're running something else on your Pi, you might need to modify some commands slightly. To install Sideband on Raspberry Pi, follow these steps: ```bash -# First of all, install the dependencies required -# for audio processing and voice calls. -# -# On Raspberry Pi OS "Trixie" (based on Debian 13) -# or newer, install these packages: -sudo apt install python3-pyaudio codec2 xclip xsel +# First of all, install the required dependencies: +sudo apt install python3-pip python3-pyaudio python3-dev python3-cryptography build-essential libopusfile0 libsdl2-dev libavcodec-dev libavdevice-dev libavfilter-dev portaudio19-dev codec2 libcodec2-1.0 xclip xsel + +# If you don't want to compile pycodec2 yourself, +# download the pre-compiled package provided here +wget https://raw.githubusercontent.com/markqvist/Sideband/main/docs/utilities/pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl + +# Install it: +pip install ./pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl --break-system-packages # You can now install Sideband pip install sbapp --break-system-packages -# Restart your Raspberry Pi to ensure the program -# is available in your PATH and application menus: +# Restart your Raspberry Pi sudo reboot # Everything is ready! You can now run Sideband -# from the terminal, or from the application menu: +# from the terminal, or from the application menu sideband ``` -If you're using an older version of Raspberry Pi OS, you will need to install these dependencies instead, before installing Sideband: - -```bash -# Package dependencies for Raspberry Pi OS based -# on Debian 12 Bookworm -sudo apt install python3-pip python3-pyaudio python3-dev python3-cryptography build-essential libopusfile0 libsdl2-dev libavcodec-dev libavdevice-dev libavfilter-dev portaudio19-dev codec2 libcodec2-1.0 xclip xsel -``` - -If you do not already have Reticulum connectivity set up on your computer or local network, you will probably want to edit the Reticulum configuration file at `~/.reticulum/config` and [add any interfaces](https://reticulum.network/manual/interfaces.html) you need for connectivity. - ## On macOS To install Sideband on macOS, you have two options available: @@ -208,38 +176,26 @@ If you install Sideband from the DMG file, it is still recommended to install th ```bash # Install Reticulum and utilities with pip: -pip3 install rns --user +pip3 install rns # On some versions, you may need to use the # flag --break-system-packages to install: -pip3 install rns --user --break-system-packages +pip3 install rns --break-system-packages ``` If you do not have Python and `pip` available, [download and install it](https://www.python.org/downloads/) first. -If you do not already have Reticulum connectivity set up on your computer or local network, you will probably want to edit the Reticulum configuration file at `~/.reticulum/config` and [add any interfaces](https://reticulum.network/manual/interfaces.html) you need for connectivity. - #### Source Package Install For more advanced setups, including the ability to run Sideband in headless daemon mode, enable debug logging output, configuration import and export and more, you may want to install it from the source package via `pip` instead. +**Please note!** The very latest Python release, Python 3.13 is currently **not** compatible with the Kivy framework, that Sideband uses to render its user interface. If your version of macOS uses Python 3.13 as its default Python installation, you will need to install an earlier version as well. Using [the latest release of Python 3.12](https://www.python.org/downloads/release/python-3127/) is recommended. + To install Sideband via `pip`, follow these instructions: ```bash # Install Sideband and dependencies on macOS using pip: -pip3 install sbapp --user - -# Or, if your Python environment is externally managed: -pip3 install sbapp --user --break-system-packages - -# Important note! On some macOS versions, programs that -# were installed with pip does not get added to your -# PATH environment variable. If this is the case, pip -# will print out a warning about it during installation. -# In that case, you will have to manually add the path -# shown in the installation warning to your PATH, before -# you can run commands such as "sideband" or "rnstatus" -# from your shell. +pip3 install sbapp # Run Sideband from the terminal: ################################# @@ -282,8 +238,6 @@ Simply download the packaged Windows ZIP file from the [latest release page](htt When running Sideband for the first time, a default Reticulum configuration file will be created, if you don't already have one. If you don't have any existing Reticulum connectivity available locally, you may want to edit the file, located at `C:\Users\USERNAME\.reticulum\config` and manually add an interface that provides connectivity to a wider network. If you just want to connect over the Internet, you can add one of the public hubs on the [Reticulum Testnet](https://reticulum.network/connect.html). -#### Installing Reticulum Utilities - Though the ZIP file contains everything necessary to run Sideband, it is also recommended to install the Reticulum command line utilities separately, so that you can use commands like `rnstatus` and `rnsd` from the command line. This will make it easier to manage Reticulum connectivity on your system. If you do not already have Python installed on your system, [download and install it](https://www.python.org/downloads/) first. **Important!** When asked by the installer, make sure to add the Python program to your `PATH` environment variables. If you don't do this, you will not be able to use the `pip` installer, or run any of the installed commands. When Python has been installed, you can open a command prompt and install the Reticulum package via `pip`: @@ -308,28 +262,6 @@ The Sideband application can now be launched by running the command `sideband` i Since this installation method automatically installs the `rns` and `lxmf` packages as well, you will also have access to using all the included RNS and LXMF utilities like `rnstatus`, `rnsd` and `lxmd` on your system. -# Creating Plugins - -Sideband features a flexible and extensible plugin system, that allows you to hook all kinds of control, status reporting, command execution and telemetry collection into the LXMF messaging system. Plugins can be created as either *Telemetry*, *Command* or *Service* plugins, for different use-cases. - -To create plugins for Sideband, you can find a variety of [code examples](https://github.com/markqvist/Sideband/tree/main/docs/example_plugins) in this repository, that you can use as a basis for writing your own plugins. The example plugins include: - -- [Custom telemetry](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/telemetry.py) -- [Getting BME280 temperature, humidity and pressure](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/bme280_telemetry.py) -- [Basic commands](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/basic.py) -- [Location telemetry from GPSd on Linux](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/gpsd_location.py) -- [Location telemetry from Windows Location Provider](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/windows_location.py) -- [Getting statistics from your LXMF propagation node](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/lxmd_telemetry.py) -- [Viewing cameras and streams](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/view.py) -- [Fetching an XKCD comic](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/comic.py) -- [Creating a service plugin](https://github.com/markqvist/Sideband/blob/main/docs/example_plugins/service.py) - -For creating telemetry plugins, Sideband includes 20+ built-in sensor types to chose from, for representing all kinds telemetry data. If none of those fit your needs, there is a `Custom` sensor type, that can include any kind of data. - -Command plugins allow you to define any kind of action or command to be run when receiving command messages from other LXMF clients. In the example directory, you will find various command plugin templates, for example for viewing security cameras or webcams through Sideband. - -Service plugins allow you to integrate any kind of service, bridge or other system into Sideband, and have that react to events or state changes in Sideband itself. - # Paper Messaging Example You can try out the paper messaging functionality by using the following QR-code. It is a paper message sent to the LXMF address `6b3362bd2c1dbf87b66a85f79a8d8c75`. To be able to decrypt and read the message, you will need to import the following base32-encoded Reticulum Identity into the app: @@ -353,21 +285,38 @@ You can help support the continued development of open, free and private communi ``` 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w ``` -- Bitcoin - ``` - bc1p4a6axuvl7n9hpapfj8sv5reqj8kz6uxa67d5en70vzrttj0fmcusgxsfk5 - ``` - Ethereum ``` - 0xae89F3B94fC4AD6563F0864a55F9a697a90261ff + 0xFDabC71AC4c0C78C95aDDDe3B4FA19d6273c5E73 + ``` +- Bitcoin + ``` + 35G9uWVzrpJJibzUwpNUQGQNFzLirhrYAH ``` -- Liberapay: https://liberapay.com/Reticulum/ - - Ko-Fi: https://ko-fi.com/markqvist -
+# Planned Features + +- Secure and private location and telemetry sharing +- Including images in messages +- Sending file attachments +- Offline and online maps +- Paper messages +- Using Sideband as a Reticulum Transport Instance +- Encryption keys export and import +- Plugin support for commands, services and telemetry +- Sending voice messages (using Codec2 and Opus) +- Adding a Linux desktop integration +- Adding prebuilt Windows binaries to the releases +- Adding prebuilt macOS binaries to the releases +- A debug log viewer +- Adding a Nomad Net page browser +- LXMF sneakernet functionality +- Network visualisation and test tools +- Better message sorting mechanism + # License Unless otherwise noted, this work is licensed under a [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License][cc-by-nc-sa]. diff --git a/Zen of Reticulum.md b/Zen of Reticulum.md deleted file mode 100644 index aa286be..0000000 --- a/Zen of Reticulum.md +++ /dev/null @@ -1,415 +0,0 @@ -# Zen of Reticulum - -## I: The Illusion Of The Center - -For the better part of a generation, we have been taught to visualize the digital world through the lens of hierarchy. The mental maps we carry are dominated by a single, misleading image: **The Cloud**. - -We imagine the network as a vast, ethereal space "up there" or "out there". A centralized repository of services and data to which we, the lowly clients, must connect. We build our software with this assumption hardcoded into our logic: *There is a server. The server has the authority. The server knows the way. I must find the server to function*. - -This is the Client-Server mental model, and it is the primary obstacle to understanding Reticulum. - -### Fallacy Of The Cloud - -The first step in the Zen of Reticulum is to realize that *there is no cloud*. There is only other people's computers. When you build for the cloud, you are building *for* a landlord. You are accepting that your application's existence is conditional on the permission, uptime, and continued goodwill of a central authority. - -In Reticulum, you must shift your thinking from "connecting to" to "being among". Reticulum is not a service you subscribe to - *it is a fabric you inhabit*. There is no "up there". There is only *here* and *there*, and the space between them is peer-to-peer. - -### Decentralization Or Uncentralizability? - -It is common to hear the word "decentralized" thrown around in modern tech circles. But often, this is merely a marketing term for "slightly distributed centralization". A blockchain with a few dominant miners, or a federated protocol with a few giant servers. *In practice*, it's still centralized. It simply has a few centers instead of one. - -Reticulum goes further. It wants **Uncentralizability**. - -This is not a wishful political stance, but a foundational mathematical characteristic of the protocol, onto which everything else has been built. Reticulum assumes that every peer on the network is potentially hostile, and every link is potentially compromised. It is designed with no "privileged" nodes. While some nodes may act as Transport Instances - forwarding traffic for others - they do so *blindly*, and they only know about their immediate surroundings, and nothing more. They route based on cryptographic proofs, not on administrative privilege. They cannot see who is talking to whom, nor can they selectively manipulate traffic without breaking their own ability to route entirely. - -The system is designed to make hierarchy structurally impossible. You cannot hijack an address, because there is no central registry to hijack. You cannot block a user, because there is no central switch to flip. You can offer paths through the network, but you can't force anyone to use them. - -### Death To The Address - -To break free of the center, you must also let go of the concept of the "Address". - -In the IP world, an address is a location. It is a coordinate in a *deeply hierarchical* and static grid. If you move your computer to a different house, your address changes. If your router reboots, your address might change. Your *identity* is bound to your *location*, and therefore, it is fragile, and easily controlled. - -Reticulum abolishes this link between *Identity* and *Location*. - -In Reticulum, an address is not a place; it is a **Hash of an Identity**. It is a cryptographic representation of *who* you are, not *where* you are. Because of this, your address is portable. You can take a laptop from a WiFi cafe in Berlin, to a LoRa mesh in the mountains, to a packet radio link on a boat, and your "address" - your *Destination Hash* - never changes. - -The network does not route to a place; it routes to a *person* (or a machine). When you send a packet, you are not targeting a coordinate in a grid; you are encrypting a message for a specific entity. The network dynamically discovers where that entity currently resides, and it does so in a way where no one really knows where that entity is actually located physically. - -**Consider:** - -- **The Old Way:** *"I am at `192.168.1.5`. Come find me"*. -- **The Zen Way:** *"I am `<327c1b2f87c9353e01769b01090b18f2>`. Wherever I am, my peers can reach me"*. - -Once you stop thinking about servers and start thinking about portable identities, where everyone can always reach everyone else directly, the illusion of the center fades away. You realize there *is* no center holding the network together. No coordinators or bureaucrats required. The network is simply the sum of its peers, communicating directly, sovereignly, and without a master. - - -## II: Physics Of Trust -*Paranoia Is A Great Design Principle* - -If we accept that there is no center - that the network is a chaotic, peer-to-peer mesh - we are forced to confront a terrifying reality: **There is no one guarding the door**. - -In the traditional networking mindset, we rely on the concept of the "trusted core". We assume our local coffee shop WiFi is safe, or that the backbone providers are neutral custodians. We build our security like a castle: strong walls on the outside, soft and trusting on the inside. We use encryption only when we step out into the "wild" internet. - -### Hostile Environments - -The Zen of Reticulum requires you to invert this. You must assume that *every* environment is hostile. This isn't cynicism, just uncaring physics. - -When you transmit information over radio waves, you are shouting into a crowded room. Anyone can listen. When you traverse the internet, your packets pass through routers controlled by strangers, corporations, and state actors. Assuming privacy in this environment without cryptographic protection is not optimism but gross negligence. - -Reticulum is built on the premise that every link is tapped, and every peer is a potential adversary. If your system cannot survive an adversary owning the physical layer, it cannot survive at all. - -But this is the paradox: By assuming the network is hostile, you make it safe. When you accept the dangers for what they are, they become manageable. When you stop trusting the infrastructure and start trusting the math, you eliminate the single point of failure: Human integrity. - -### Encryption Is Not A Feature - -In the world of TCP/IP, encryption is an afterthought. It is a layer we slap on top of the protocol (HTTPS, TLS) to patch the security holes of the original design. It is a "feature" you sometimes *enable* for "sensitive data". This is fundamentally flawed, since all data is sensitive. - -In Reticulum, encryption is **gravity**. - -It is not optional. It is not a plugin. It is the *fundamental force that allows the network to exist*. If you were to strip the encryption from Reticulum, the routing would break. The Transport system uses cryptographic signatures and entropy to verify paths and pass information. If packets were plaintext, intermediate nodes could not prove that a route was valid, nor could endpoints prevent spoofing or tampering. - -In Reticulum, the entropy of the encrypted packet *is* the routing logic. - -To ask for a version of Reticulum without encryption is like asking for a version of the ocean without liquid. You are not asking for a feature change; you're asking for a different physical universe. We design for a universe where information has mass, structure, and integrity. - -### Zero-Trust Architectures - -We must unlearn our reliance on **Institutional Trust**. - -For decades, we have been trained to trust authorities. We trust a website because a chain of Certificate Authorities (companies we don't know) vouches for it. We trust an app because it is in an app store (run by a corporation we don't control). We trust a message because it comes from a phone number assigned by a telecom. Yet, everything in our digital information sphere today is more untrustworthy and risky than a medieval second-hand underwear market. - -Reticulum replaces institutional trust with **Cryptographic Proof**. - -In Reticulum, you do not trust a node because it has a nice hostname or because it is listed in a directory. You trust it because it holds the private key corresponding to the Destination Hash you are communicating with. This trust is binary, mathematical, and **absolute**. Either the signature matches, or it does not. There is no "maybe". - -This shift moves the power from the institution to the individual. You become the ultimate arbiter of your own trust relationships. You decide which keys to accept, which paths to follow, and which identities to recognize. - -**Consider:** - -- **The Old Way:** *"I trust this site because the browser says the lock icon is green"*. -- **The Zen Way:** *"I trust this destination because I have verified its hash fingerprint out-of-band, and the math confirms the signature"*. - -When you internalize the Physics of Trust, you stop looking for protection from firewalls, VPNs, and Terms of Service agreements. You realize that true security comes from the design of the protocol itself. You can stop trusting the cloud, and you start trusting the code - because you can verify it yourself. - - -## III: Merits Of Scarcity -*Every Bit Counts* - -We have grown addicted to abundance. In the modern digital ecosystem, bandwidth is treated as an endless, flat ocean. We stream high-definition video without a thought, we ship entire libraries of code just to render a single button, and we measure performance in gigabits per second. This abundance has hollowed out our craft. When constraints vanish, efficiency dies, and with it, a certain kind of Clarity and Quality. - -Reticulum asks you to step out of the ocean and onto the tightrope. - -### The Bandwidth Fallacy - -The Zen of Reticulum requires the realization that **5 bits per second is a valid speed**. - -To a modern developer, this sounds like paralysis. But there is a profound freedom in limits: When you have a gigabit connection, you can be incredibly sloppy. You can be wasteful. You can push your problems onto the infrastructure. *"It’s slow? Get a faster router"*. - -But on a high-latency, low-bandwidth link (be it a noisy HF radio channel or a tenuous LoRa hop) you cannot push problems anywhere. You must solve them. The network does not negotiate with waste. - -This forces a shift from consumption to interaction. You are no longer, then, consuming a service provided by a fat pipe; you are engaging in a careful negotiation with the physical medium. The medium becomes a partner in the conversation, not just a dumb conduit. You suddenly need to *understand the world to be in it*. - -### Cost Of A Byte - -In a scarce economy, a byte is not just data, but energy, time, and space. - -Every byte you transmit consumes battery life on a solar-powered node. It occupies valuable airtime that could have been used by another peer. It represents a measurable slice of the electromagnetic spectrum. - -When you internalize this, you begin to write code differently. You stop asking, "How much data can I send?" and start asking, "What is the *minimum* amount of information required to convey this intent? How can I best utilize my informational entropy?" - -This is where the elegance of Reticulum shines. The protocol is designed to strip away the non-essential. A link establishment takes three very small packets. A destination hash fits in 16 bytes. The overhead is vanishingly small, leaving almost the entire channel for the message itself. - -**Consider:** - -- **The Old Way:** *"I need to send a status update. I'll send a JSON object with metadata, timestamps, and user profile info (15KB)."* -- **The Zen Way:** *"I need to send a status update. I'll send a single byte representing the state code. The context is already known."* - -This is of course optimization, but more importantly, *it is a form of respect*. Efficiency in a shared medium is an act of stewardship. By taking only what you need from the network, you leave room for others. The network listens to those who speak with purpose. - -### Flow & Time - -Scarcity also teaches us about time. We have become addicted to the *synchronous* now - the instant ping, the real-time stream. But Reticulum embraces *asynchronous* time. - -When links are intermittent and latency is measured in minutes or hours, "real-time" is an illusion. Reticulum doesn't encourage **Store and Forward** as a mere fallback, but as a primary mode of existence. You write a message, it propagates when it can, and it arrives when it arrives. - -This changes the psychological texture of communication. It removes the anxiety of the immediate response. It allows for contemplation. You are not demanding the recipient's attention *right now*; you are placing a gift in their path, to be found when they are ready. - -By designing for delay, you design for resilience. You are no longer building a house of cards that collapses when a single packet drops. You are building a stone arch that distributes the load *over time*. - -### Liberation From Limits - -There is a strange optimism in scarcity. When you are forced to work within strict constraints, you are forced to prioritize. *You* must decide what truly matters. *That* is the real core of agency. - -In the infinite fantasy world of The Cloud, everything is urgent, so nothing is. In the economy of Reticulum, the cost of transmission forces you to weigh the value of your message. Do you really need to send that heart beat? Is that photo essential? - -When you strip away the noise, what remains is *signal*. - -This discipline creates a different kind of developer. It creates a craftsman who understands that the best code is the code you don't have to write. It creates a user who understands that the most powerful message is the one that is *understood*, not the one that is loudest. In the world of Reticulum, you are not a mere consumer of bandwidth; you are an architect of intent. - - -## IV: Sovereignty Through Infrastructure -**Be Your Own Network** - -We live in an era of digital tenancy. We lease our connectivity from ISPs. We rent our storage from cloud providers. We even borrow our identity from social media platforms. We are tenants in a house we did not build, governed by rules we did not write, subject to eviction at the whim of a landlord who has never met us. - -The Zen of Reticulum is the realization that you *can* own the house. - -### A Carrier-Grade Fallacy - -For decades, we have been gaslit into believing that networking is really not just hard, but impossible. It is presented as a dark art reserved for telcos and billionaires, requiring millions of dollars of fiber optics, climate-controlled data centers, and armies of engineers. We are told that building reliable infrastructure is "too complex" for the individual or small organization. - -This is a big, fat lie. - -Physics is simple. A radio wave needs a transmitter and a receiver. A packet needs a path. The "complexity" of the modern internet is largely bureaucratic - a mountain of billing systems, regulatory capture, and legacy cruft designed to keep the gatekeepers in power. - -Reticulum strips away the bureaucracy. It runs on hardware that costs the price of a dinner. It runs on spectrum that is free to use. It demonstrates that a robust, planetary-scale network does not require a Fortune 500 company. It requires only the will to deploy, and the distributed, uncoordinated efforts of many individuals. - -### Personal Infrastructure - -This is where the rubber meets the road. You can read about Reticulum, you can understand the theory, but the insights only arrive when you plug in a radio and run a Transport Node. Suddenly, you are no longer a consumer. You're an operator. - -This shift is subtle but profound. When you run your own infrastructure, the network ceases to be a service that is provided *to* you. It becomes a space that you *inhabit*. You become responsible for the flow of information. You gain an intimate understanding of the medium - the way the weather affects the radio waves, the way the topology changes, the way the packets dance through the ether. - -There is a quiet competence that comes from this. You stop asking "Is the internet down?" and start asking "Is *my* links up?" You stop waiting for a technician and start checking the logs. This is a form of strength. To understand the system that carries your words is to be free from the mystery that keeps you dependent. - -### The Ability To Disconnect - -Why go to the trouble? Why buy the radio, write the config, and leave the Pi running in the corner? - -Because the old, centralized network is fragile. And because most of us doesn't even really want to be there anymore. - -The internet we rely on today is a chain of single points of failure. Cut the undersea cable, and a continent goes dark. Shut down the power grid, and the cloud evaporates. Deprioritize the "wrong" traffic, and the flow of information is strangled. - -Sovereignty is the ability to survive the cut, whether or not that cut was an accident or on purpose. - -When you build your own infrastructure, you build a lifeline. Reticulum is designed to function over media that the traditional internet cannot touch - bare wires, battery-powered radios, ad-hoc WiFi meshes. When the grid fails, or the censors arrive, or the bill goes unpaid, your Reticulum network continues to hum. - -This is not about "dropping out" of society. It is about building a substrate on which an actual *Society* can function. - -**Consider:** - -- **The Old Way:** "My connection is slow. I should call my ISP and complain." -- **The Zen Way:** "The path is noisy. I will adjust the antenna or find a better route." - -By taking ownership of the infrastructure, you take ownership of your voice. You stop shouting into someone else's megaphone and start building your own. The network is no longer something that happens to you; it is something you make happen. - - -# V: Identity and Nomadism -**A Fluid Self** - -In the old world, you are defined by your coordinates. If you are at `34.109.71.5`, you're *here*. If you unplug the cable and walk down the street, you vanish. Your digital self evaporates because it was tethered to the wall. You are a ghost in the endless machinations of gears, levers and transistors, bound to the hardware, and those that own it. - -This creates a subtle, constant anxiety. We are terrified of disconnecting because, in the architecture of the old web, disconnecting is a kind of death. - -The Zen of Reticulum offers a different way to be. - -### Portable Existence - -In Reticulum, your identity is not a location, or a username granted by a service. It is a cryptographic key - a complex, unique mathematical signature that exists independently of the physical world. You can carry it only in your mind, if you want to. - -Think of it less like a street address and more like a name. *A true name*. - -If you travel from Berlin to Tokyo, you do not change your name. You are still you. The people who know you can still recognize you. Reticulum applies this principle to the network layer. Your Destination Hash is **invariant**. It travels with you, stored securely on your device, *immutable as a stone*. - -This changes the relationship between you and the machine. You are not "logged into" the network via a specific gateway. You *are* the endpoint. The network does not connect to a place; *it converges on you*. - -### Roaming Nodes - -This freedom introduces a new concept of time and space: **Nomadism**. - -Because your identity is portable, your connectivity can be fluid. You can be sitting at a desk connected to a fiber backbone one moment, and walking through a field connected only to a long-range LoRa mesh the next. To the rest of the network, nothing has changed. Your friends do not need to update your contact info. The messages they send do not bounce back. The network senses the shift in the medium and reroutes the flow of data automatically. - -You are no longer a stationary node in a fixed grid. You are a wanderer in a fluid medium. - -The interfaces - whether it is WiFi, Ethernet, Packet Radio, or a physical wire - is merely the clothing your node wears. You change it to suit the environment. Underneath, you remain the same. This is the liberation of the protocol. It treats the physical medium as a transient circumstance, not a definition of self. - -**Consider:** - -- **The Old Way:** *"I lost connection. I have to reconnect to the VPN to tell them where I am now."* -- **The Zen Way:** *"I moved. The network subtly bends to accomodate this new reality."* - -### Announcing Presence - -How does the network find a wanderer? It listens. - -In the IP world, we query directories. We ask a server, "Where is Mark?" The server checks its database and gives us a coordinate. This means that someone, somewhere, is keeping track of you. It assumes and *requires* surveillance. - -Reticulum replaces surveillance with **Announces**. - -Instead of asking a central authority where you are, you simply state your presence. You broadcast a cryptographic proof: "I am here, and I am who I say I am". This ripples out through the mesh. Your neighbors hear it, update their path tables, and pass it on. - -This is a quiet, organic process. It is the digital equivalent of lighting lanterns in the dark. You do not need to chase the light; you let the light find you. It respects your autonomy. You choose when to announce, how often to speak, and to whom. You also choose when to disappear - for but a moment or perpetually. - -### Anchor In The Flow - -There is a deep peace in this nomadism. It teaches you that stability does not come from standing still. Stability comes from *internal coherence*. - -By holding your own private key, you hold your own center of gravity. The world around you; the infrastructure, the topography and the availability of links can all shift chaotically. Storms can knock out towers. Cables can be cut. The internet can go down. - -But as long as you possess your key, you possess your identity. The entire infrastructure can be destroyed and rebuilt, and you are still you. Nothing lasts, yet nothing is lost. - -You become a sovereign entity moving through the noise, connected not by the rigidity of cables, but by the fluidity of recognition. The network becomes a place you inhabit, rather than a utility you subscribe to: You are at home in the ether. - - -## VI: Ethics Of The Tool -**Technology With Conscience** - -You have unlearned the center. You have accepted the physics of trust. You have embraced the economy of scarcity and the freedom of unbound nomadism. You are standing in a new space. Now, look at the tool in your hand. - -In the old world, we were taught that technology is neutral. We are told that "guns don't kill people, people do", or that a component is just a component, indifferent to what its combinatorial potential is. This is a convenient lie. It serves only to allow the builders to wash their hands of responsibility. - -But we know better now. We know that **architecture is politics**, and *politics is control*. The way you build a system determines how it will be used. If you build a system optimized for mass surveillance, you *will* get a panopticon. If you build a system optimized for centralized control, you *will* get a dictatorship. If you build a system optimized for extraction, you *will* get a parasite. - -The Zen of Reticulum asserts that a tool is never neutral. - -On the very contrary: A tool is intent, **crystallized**. - -### The Harm Principle - -Why does the Reticulum License forbid the software from being used in systems designed to harm humans? Is it not just a restriction on freedom? - -It is a restriction on *license*, yes, but it is an expansion of *freedom*. - -Building powerful tools without a moral compass is in no way virtuous or commendable, it is plain and simple irresponsibility. - -A tool that can easily be used to oppress is a real danger to the user. If you build a network that can be turned against you by a tyrant, you are not free. You are merely waiting for the leash to tighten. By encoding the "Harm Principle" into the legal DNA of the reference implementation, we are building a safeguard. We are stating, clearly and immutably, that *this tool* is for **life**, not for death. - -This aligns the software with the interests of humanity. It cements that the network cannot be conscripted into a kill-system, a weaponized drone controller, or a torture device without breaking the license and the law. It is a line drawn in the sand - not by a government or external authority, but by the creators of the tool itself. - -**Consider:** - -- **The Old Way:** *"It's just software. How people use it is not my problem."* -- **The Zen Way:** *"This software is a habitat. I will not allow it to be used to build a cage."* - -It is *your* choice whether to align with this - we are not forcing this stance on anyone. If you choose to align with life over death, with creativity over destruction, we grant you an immensely powerful tool, to own and build with as you please. If you do not, we deny it. - -If you do not like this, we most assuredly do not need you here, and you are on your own. - -### Public Domain Protocol - -This leads to a vital distinction: The difference between the *idea* and the *implementation*. - -The protocol - the mathematical rules of how Reticulum works - is dedicated to the Public Domain. It belongs to humanity. **No one can own it**. Anyone can implement it, improve it, or adapt it. This is the core idea of free communication, which itself must be forever free. - -But the functional, deployed *reference implementation* - the Python code, the maintenance, the years of labor - has a conscience. This distinction is the engine of sustainability. It allows the protocol to be universal, while ensuring that the specific labor of the builders is not hijacked to undermine the foundational intent of the project itself. From this document, it should be very clear what this intent is. - -If you want to build a system with Reticulum that manipulates and damages users for profits or targets missiles, you can use the public domain protocol, and start from scratch. But you cannot take our work. You must do your own. This serves as a pillar of accountability. If you want to build a weapon, *you* go and forge the steel yourself, while the world observes. And when the blood is drawn - it is on **your** hands. - -### Preserving Human Agency - -We live in an era of predatory extraction. The open-source commons is being scraped, ingested, and regurgitated by machine learning algorithms, whose corporate owners seek to replace the very humans who built those commons. Our code, our words, and our creativity is being used to train systems that are specifically designed to make us obsolete, without offering anything else in return than serfdom and leashes. - -Reticulum stands against this. - -The license protects the software from being used to feed the beast. It draws a hard line: This tool is for *people*. It is for human-to-human connection. It is not a dataset to be strip-mined for the purpose of building a synthetic overlord, puppeteered by a miniscule conglomerate of controllers. - -This is a radical act of preservation. By protecting the code from AI appropriation, we are protecting space for human agency. We are ensuring that there remains a digital realm where the actors are flesh, blood and soul, where decisions are made by minds, not overlords hiding behind models. - -When you use Reticulum, you are using a tool that respects you. It does not see you as a product to be tracked. It does not see your data as fuel for an algorithm. It sees you as a sovereign, equal peer. - -This changes the foundational premise of using the technology. It restores dignity to the interaction. You are not the user of a service; you are a participant in a mutual covenant. The tool aligns with your autonomy, rather than eroding it. - -In this way, ethics is not a restriction, but a foundation. It is the foundation that helps ensure the network will still belong to you tomorrow. - - -## VII: Design Patterns For Post-IP Systems -**Practical Philosophy for Developers** - -The philosophy is useless if it cannot be hammered into code. The metaphors we have explored - nomadism, scarcity, trust - are not just poetry, but real-world engineering constraints. When you sit down to write software for Reticulum, these concepts must shape the very structure of your application. - -We are now moving from the *why* to the *how*. This is where the abstract becomes concrete, and where you will see the true depth of the patterns we have been weaving. - -### Store & Forward - -The web has trained us to be impatient. We write synchronous code. We fire a request and we wait, blocking the UI, holding our breath. If the response doesn't come in 250 milliseconds, we show a spinner. If it doesn't come in five seconds, we show an error. We treat network connectivity as a binary state: either we are "online" or we are "broken". - -This is brittle. It is a rejection of reality. - -In Reticulum, connectivity is a spectrum, and presence is asynchronous. If at all applicable to your intent, you must design your applications to embrace **Store & Forward**. - -Instead of demanding an immediate answer, your application should act as a patient participant. You create a message for someone or something in the mesh. The network holds it. It carries it from node to node, perhaps over hours or days, waiting for the recipient to appear. When they finally surface, the message is delivered. This requires a shift from "request/response" to "event/handler". How exactly you do this is a challenge for you to solve intelligently within your problem domain, but Reticulum-based systems already exist that does this extremely well, and you can use them for inspiration. - -**Consider:** - -- **The Old Way:** `Connect() -> Send() -> Wait() -> Crash if timeout.` -- **The Zen Way:** `Send() -> Continue living. -> Receive() when it arrives.` - -This changes the user experience profoundly. It removes the anxiety of the loading bar. It creates a sense of continuity. The user is not "waiting for the network"; they are interacting with a persistent log of communication that lives in the network itself. - -### Naming Is Power - -In the IP world, we are slaves to the Domain Name System. We rely on a hierarchy of registrars to map human-readable names to machine-readable addresses. This hierarchy is a choke point. If the registrar revokes your domain, or if the DNS server goes down, you vanish. - -Reticulum dissolves this hierarchy with **Hash-based Identity**. - -In this design pattern, a name is not a string you look up; it is a cryptographic destination you verify. When you design for Reticulum, you stop asking the user for a URL and start asking for a Destination or Identity Hash. - -This feels strange at first. A hash like `<83b7328926fed0d2e6a10a7671f9e237>` looks alien compared to `myfriend.com`. But that alienness is the armor. It **cannot** be spoofed. It **cannot** be censored by a registrar. It is **absolute**. - -Designing for this means shifting your UI metaphors. You are no longer browsing a web of pages; you are managing a ledger of keys. You are building an "Address Book" that is actually a keyring. The names are given by the user, and the power stays with them. That hashes look complex is directly analogous to the strengths of the bonds formed by their use. It forces the user to engage in a moment of verification, an out-of-band handshake, which restores the human element of trust that SSL certificates stripped away. - -### The Interface Is The Medium - -One of the most liberating patterns in Reticulum is **Transport Agnosticism**. - -In traditional networking, your code is often littered with transport logic. "Am I on WiFi? Check bandwidth. Am I on Cellular? Check data plan. Am I on Ethernet?". You are constantly micromanaging the pipe. - -In Reticulum, you write to the API, and the API writes to the medium. You send a packet to a Destination. You do not care if that packet travels over a TCP tunnel, a LoRa radio wave, or a serial wire interface. That is the stack's concern. - -This allows you to write **Universal Applications**. -Imagine a messaging app. You write it once. It works on a laptop connected to fiber. It works on a phone in the city using WiFi. And, without a single line of code changed, it works on a device in the wilderness, talking only to other devices via radio. - -The pattern is simple: **Never code to the hardware. Code to the intent.** - -**Consider:** - -- **The Old Way:** `socket.connect(ip, port)` -- **The Zen Way:** `RNS.Packet(destination, data).send()` - -By abstracting the medium, you make your software immortal to changes in infrastructure. The user might switch from a 4G hotspot to a HF modem tomorrow. Your software doesn't need to know. It simply continues the conversation. - -### Emergent Patterns - -When you combine these patterns - *Store & Forward*, *Hash-based Identity*, and *Transport Agnosticism* - you create software that feels fundamentally different. - -It feels *grounded*. It doesn't flicker when the signal drops. It doesn't panic when the server is down. It has weight. It has persistence. It has *relevance*. - -You are no longer building a "client" that begs a "server" for attention. You are building an autonomous agent that exists within the mesh. It speaks when it needs to, listens when it can, and carries its identity with it wherever it goes. - -This is the culmination of the Zen. The code is not just a set of instructions: It is a behavioral envelope. It is a way of *being* in the network. - - -## VIII: Fabric Of The Independent - -We have stripped away the illusions. We have seen that the center is empty, that trust *must* be hard, that resources are finite, and that we must own our infrastructure. We have seen that tools have ethics and that our identity can move fluidly. - -This is a reclaiming of the commons. For too long, we have allowed the most vital substrate of human society - *our ability to speak to one another* - to be colonized by entities that do not share our interests. We have allowed the architecture of our communication to be designed by accountants rather than architects. - -We are taking it back. Not by petitioning the masters, but by building the new world within, over, under and around the shell of the old. - -### The Work Is Finished - -The heavy lifting is done. - -The protocol is in the public domain, a gift to humanity that can never be taken away. The software is written, tested, and running on devices scattered across the globe. The manual lies open before you. The source code for the reference implementation is now distributed on hundreds of thousands of devices across the planet. No one can delete or destroy it. The hardware is accessible and abundant. - -It was a hard road to get here, but we got here. Now, there is no roadmap committee waiting for approval. There is no venture capital dictating the user experience. There is no CEO to sign off on the next feature release. - -There is only you. - -The barrier to entry is no longer complexity: It is the mere habit of dependency. You were conditioned to wait. Wait for the app update. Wait for the ISP to fix the line. Wait for the platform to allow the post. Wait for the government to change the policies. Wait for the likes. Wait for the revolution to be televised. - -The revolution never was televised. - -It is packetized. - -### Open Sky - -The future of this technology is a construction project. - -It looks like a single node on a windowsill, listening to the static. It looks like a message sent to a neighbor, bypassing the noise of the commercial web. It looks like a community mesh that grows, link by link, hop by hop, carried by hands that care more about connection than profit. - -You have the blueprints. You have the tools. You have the philosophy. The noise of the old world has fallen away, leaving you with the quiet clarity of the open spectrum. - -*Mark, early 2026* \ No newline at end of file diff --git a/docs/example_plugins/windows_location.py b/docs/example_plugins/windows_location.py deleted file mode 100644 index ff0aded..0000000 --- a/docs/example_plugins/windows_location.py +++ /dev/null @@ -1,88 +0,0 @@ -# Windows Location Provider plugin example, provided by @haplo-dev - -import RNS -import time -import threading -import asyncio -from winsdk.windows.devices import geolocation - -class WindowsLocationPlugin(SidebandTelemetryPlugin): - plugin_name = "windows_location" - - def __init__(self, sideband_core): - self.update_interval = 5.0 - self.should_run = False - - self.latitude = None - self.longitude = None - self.altitude = None - self.speed = None - self.bearing = None - self.accuracy = None - self.last_update = None - - super().__init__(sideband_core) - - def start(self): - RNS.log("Starting Windows Location provider plugin...") - - self.should_run = True - update_thread = threading.Thread(target=self.update_job, daemon=True) - update_thread.start() - - super().start() - - def stop(self): - self.should_run = False - super().stop() - - def update_job(self): - while self.should_run: - RNS.log("Updating location from Windows Geolocation...", RNS.LOG_DEBUG) - try: - asyncio.run(self.get_location()) - except Exception as e: - RNS.log(f"Error getting location: {str(e)}", RNS.LOG_ERROR) - - time.sleep(self.update_interval) - - async def get_location(self): - geolocator = geolocation.Geolocator() - position = await geolocator.get_geoposition_async() - - self.last_update = time.time() - self.latitude = position.coordinate.latitude - self.longitude = position.coordinate.longitude - self.altitude = position.coordinate.altitude - self.accuracy = position.coordinate.accuracy - - # Note: Windows Geolocation doesn't provide speed and bearing directly - # You might need to calculate these from successive position updates - self.speed = None - self.bearing = None - - def has_location(self): - return all([self.latitude, self.longitude, self.altitude, self.accuracy]) is not None - - def update_telemetry(self, telemeter): - if self.is_running() and telemeter is not None: - if self.has_location(): - RNS.log("Updating location from Windows Geolocation", RNS.LOG_DEBUG) - if "location" not in telemeter.sensors: - telemeter.synthesize("location") - - telemeter.sensors["location"].latitude = self.latitude - telemeter.sensors["location"].longitude = self.longitude - telemeter.sensors["location"].altitude = self.altitude - telemeter.sensors["location"].speed = self.speed - telemeter.sensors["location"].bearing = self.bearing - telemeter.sensors["location"].accuracy = self.accuracy - telemeter.sensors["location"].stale_time = 5 - telemeter.sensors["location"].set_update_time(self.last_update) - - else: - RNS.log("No location from Windows Geolocation yet", RNS.LOG_DEBUG) - -# Finally, tell Sideband what class in this -# file is the actual plugin class. -plugin_class = WindowsLocationPlugin \ No newline at end of file diff --git a/libs/able/.gitignore b/libs/able/.gitignore deleted file mode 100644 index 56629a1..0000000 --- a/libs/able/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -.buildozer/ -bin/ -docs/_build/ -*~ -*.swp -*.sublime-workspace -*.pyo -*.pyc -*.so - -build/ -dist/ -sdist/ -wheels/ -*.egg-info - diff --git a/libs/able/CHANGELOG.rst b/libs/able/CHANGELOG.rst deleted file mode 100644 index f56f9ad..0000000 --- a/libs/able/CHANGELOG.rst +++ /dev/null @@ -1,103 +0,0 @@ -Changelog -========= - -1.0.16 ------- - -* Added `autoconnect` parameter to connection methods - `#45 `_ - -1.0.15 ------- - -* Changing the wheel name to avoid installing a package from cache - `#40 `_ - -1.0.14 ------- - -* Added event handler for bluetooth adapter state change - `#39 `_ by `robgar2001 `_ -* Removal of deprecated `convert_path` from setup script - -1.0.13 ------- - -* Fixed build failure when pip isolated environment is used `#38 `_ - -1.0.12 ------- - -* Fixed crash on API level 31 (Android 12) `#37 `_ -* Added new optional `BluetoothDispatcher` parameter to specifiy required permissions: `runtime_permissions`. - Runtime permissions that are required by by default: - ACCESS_FINE_LOCATION, BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE -* Changed `able.require_bluetooth_enabled` behavior: first asks for runtime permissions - and if permissions are granted then offers to enable the adapter -* `require_runtime_permissions` decorator deprecated - -1.0.11 ------- - -* Improved logging of reconnection management - `#33 `_ by `robgar2001 `_ - -1.0.10 ------- - -* Fixed build failure after AAB support was added to python-for-android - -1.0.9 ------ - -* Switched from deprecated scanning method `BluetoothAdapter.startLeScan` to `BluetoothLeScanner.startScan` -* Added support for BLE scanning settings: `able.scan_settings` module -* Added support for BLE scanning filters: `able.filters` module - - -1.0.8 ------ - -* Added support to use `able` in Android services -* Added decorators: - - - `able.require_bluetooth_enabled`: to call `BluetoothDispatcher` method when bluetooth adapter becomes ready - - `able.require_runtime_permissions`: to call `BluetoothDispatcher` method when location runtime permission is granted - - -1.0.7 ------ - -* Added `able.advertising`: module to perform BLE advertise operations -* Added property to get and set Bluetoth adapter name - - -1.0.6 ------ - -* Fixed `TypeError` exception on `BluetoothDispatcher.enable_notifications` - - -1.0.5 ------ - -* Added `BluetoothDispatcher.bonded_devices` property: list of paired BLE devices - -1.0.4 ------ - -* Fixed sending string data with `write_characteristic` function - -1.0.3 ------ - -* Changed package version generation: - - - Version is set during the build, from the git tag - - Development (git master) version is always "0.0.0" -* Added ATT MTU changing method and callback -* Added MTU changing example -* Fixed: - - - set `BluetoothDispatcher.gatt` attribute in GATT connection handler, - to avoid possible `on_connection_state_change()` call before the `gatt` attribute is set diff --git a/libs/able/LICENSE b/libs/able/LICENSE deleted file mode 100644 index 53601b8..0000000 --- a/libs/able/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2017 b3b - -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 FOR 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. - diff --git a/libs/able/MANIFEST.in b/libs/able/MANIFEST.in deleted file mode 100644 index 571bea0..0000000 --- a/libs/able/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include LICENSE -include README.rst -include CHANGELOG.rst -include able/src/org/able/*.java diff --git a/libs/able/able/__init__.py b/libs/able/able/__init__.py deleted file mode 100644 index 2ebad68..0000000 --- a/libs/able/able/__init__.py +++ /dev/null @@ -1,84 +0,0 @@ -from enum import IntEnum - -from able.structures import Advertisement, Services -from able.version import __version__ # noqa -from kivy.utils import platform - -__all__ = ( - "Advertisement", - "BluetoothDispatcher", - "Services", -) - -# constants -GATT_SUCCESS = 0 #: GATT operation completed successfully -STATE_CONNECTED = 2 #: The profile is in connected state -STATE_DISCONNECTED = 0 #: The profile is in disconnected state - - -class AdapterState(IntEnum): - """Bluetooth adapter state constants. - https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#STATE_OFF - """ - - OFF = 10 #: Adapter is off - TURNING_ON = 11 #: Adapter is turning on - ON = 12 #: Adapter is on - TURNING_OFF = 13 #: Adapter is turning off - - -class WriteType(IntEnum): - """GATT characteristic write types constants.""" - - DEFAULT = ( - 2 #: Write characteristic, requesting acknowledgement by the remote device - ) - NO_RESPONSE = ( - 1 #: Write characteristic without requiring a response by the remote device - ) - SIGNED = 4 #: Write characteristic including authentication signature - - -if platform == "android": - from able.android.dispatcher import BluetoothDispatcher -else: - - # mock android and PyJNIus modules usage - import sys - from unittest.mock import Mock - - sys.modules["android"] = Mock() - sys.modules["android.permissions"] = Mock() - jnius = Mock() - - class mocked_autoclass(Mock): - def __call__(self, *args, **kwargs): - mock = Mock() - mock.__repr__ = lambda s: f"jnius.autoclass('{args[0]}')" - mock.SDK_INT = 255 - return mock - - jnius.autoclass = mocked_autoclass() - sys.modules["jnius"] = jnius - - from able.dispatcher import BluetoothDispatcherBase - - class BluetoothDispatcher(BluetoothDispatcherBase): - """Bluetooth Low Energy interface - - :param queue_timeout: BLE operations queue timeout - :param enable_ble_code: request code to identify activity that alows - user to turn on Bluetooth adapter - :param runtime_permissions: overridden list of - :py:mod:`permissions ` - to be requested on runtime. - """ - - -from able.adapter import require_bluetooth_enabled -from able.permissions import Permission - - -def require_runtime_permissions(method): - """Deprecated decorator, left for backwards compatibility.""" - return method diff --git a/libs/able/able/adapter.py b/libs/able/able/adapter.py deleted file mode 100644 index ae5f378..0000000 --- a/libs/able/able/adapter.py +++ /dev/null @@ -1,179 +0,0 @@ -from dataclasses import dataclass, field -from functools import partial, wraps -from typing import Optional - -from android import activity -from android.permissions import ( - check_permission, - request_permissions, -) -from jnius import autoclass -from kivy.logger import Logger - - -Activity = autoclass("android.app.Activity") - - -def require_bluetooth_enabled(method): - """Decorator to call `BluetoothDispatcher` method - when runtime permissions are granted - and Bluetooth adapter becomes ready. - - Decorator launches system activities that allows the user - to grant runtime permissions and turn on Bluetooth, - if Bluetooth is not enabled. - """ - - @wraps(method) - def wrapper(self, *args, **kwargs): - manager = AdapterManager.get_attached_manager(self) - if manager: - return manager.execute(partial(method, self, *args, **kwargs)) - return None - - return wrapper - - -def set_adapter_failure_rollback(handler): - """Decorator to launch handler - if permissions are not granted or adapter is not enabled.""" - - def inner(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - manager = AdapterManager.get_attached_manager(self) - if manager: - manager.rollback_handlers.append(partial(handler, self)) - return func(self, *args, **kwargs) - return None - - return wrapper - - return inner - - -@dataclass -class AdapterManager: - """ - Class for managing the execution of operations - that require the BLE adapter. - Operations are deferred until runtime permissions are granted - and the BLE adapter is enabled. - """ - - ble: "org.able.BLE" - enable_ble_code: str - runtime_permissions: list - operations: list = field(default_factory=list) - rollback_handlers: list = field(default_factory=list) - is_permissions_granted: bool = False - is_permissions_requested: bool = False - is_adapter_requested: bool = False - - @property - def adapter(self) -> Optional["android.bluetooth.BluetoothAdapter"]: - if self.has_permissions: - adapter = self.ble.mBluetoothAdapter - if adapter and adapter.isEnabled(): - return adapter - return None - - @property - def has_permissions(self): - if not self.is_permissions_granted: - self.is_permissions_granted = self.check_permissions() - return self.is_permissions_granted - - @property - def is_service_context(self): - return not activity._activity - - def __post_init__(self): - if self.is_service_context: - self.is_permissions_granted = True - else: - activity.bind(on_activity_result=self.on_activity_result) - - @classmethod - def get_attached_manager(cls, instance): - manager = getattr(instance, "_adapter_manager", None) - if not manager: - Logger.error("BLE adapter manager is not installed") - return manager - - def install(self, instance): - setattr(instance, "_adapter_manager", self) - - def check_permissions(self): - return all( - [check_permission(permission) for permission in self.runtime_permissions] - ) - - def request_permissions(self): - if self.is_permissions_requested: - return - self.is_permissions_requested = True - if not self.is_service_context: - Logger.debug("Request runtime permissions") - request_permissions( - self.runtime_permissions, - self.on_runtime_permissions, - ) - else: - Logger.error("Required permissions are not granted for service") - - def request_adapter(self): - if self.is_adapter_requested: - return - self.is_adapter_requested = True - self.ble.getAdapter(self.enable_ble_code) - - def rollback(self): - self._execute_operations(self.rollback_handlers) - - def execute(self, operation): - if self.adapter: - # execute immediately, if adapter is enabled - return operation() - self.operations.append(operation) - self.execute_operations() - - def execute_operations(self): - if self.has_permissions: - if self.adapter: - self._execute_operations(self.operations) - else: - self.request_adapter() - else: - self.request_permissions() - - def _execute_operations(self, operations): - self.operations = [] - self.rollback_handlers = [] - for operation in operations: - try: - operation() - except Exception as exc: - Logger.exception(exc) - - def on_runtime_permissions(self, permissions, grant_results): - granted = all(grant_results) - self.is_permissions_granted = granted - self.is_permissions_requested = False # allow future invocations - if granted: - Logger.debug("Required permissions are granted") - self.execute_operations() - else: - Logger.error("Required permissions are not granted") - self.rollback() - - def on_activity_result(self, requestCode, resultCode, intent): - if requestCode == self.enable_ble_code: - enabled = resultCode == Activity.RESULT_OK - self.is_adapter_requested = False # allow future invocations - if enabled: - Logger.debug("BLE adapter is enabled") - self.execute_operations() - else: - Logger.error("BLE adapter is not enabled") - self.rollback() diff --git a/libs/able/able/advertising.py b/libs/able/able/advertising.py deleted file mode 100644 index 1c61762..0000000 --- a/libs/able/able/advertising.py +++ /dev/null @@ -1,330 +0,0 @@ -"""BLE advertise operations.""" -from abc import abstractmethod -from dataclasses import dataclass -from enum import IntEnum -from typing import List, Optional, Union - -from jnius import JavaException, autoclass -from kivy.event import EventDispatcher - -from able.android.dispatcher import BluetoothDispatcher -from able.android.jni import PythonBluetoothAdvertiser -from able.utils import force_convertible_to_java_array - - -AdvertiseDataBuilder = autoclass('android.bluetooth.le.AdvertiseData$Builder') -AdvertisingSet = autoclass('android.bluetooth.le.AdvertisingSet') -AdvertisingSetParametersBuilder = autoclass('android.bluetooth.le.AdvertisingSetParameters$Builder') -AndroidAdvertiseData = autoclass('android.bluetooth.le.AdvertiseData') -BluetoothLeAdvertiser = autoclass('android.bluetooth.le.BluetoothLeAdvertiser') -ParcelUuid = autoclass('android.os.ParcelUuid') - -BLEAdvertiser = autoclass('org.able.BLEAdvertiser') - - -class Interval(IntEnum): - """Advertising interval constants. - https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters#INTERVAL_HIGH - """ - MIN = 160 #: Minimum value for advertising interval, around every 100ms - MEDIUM = 400 #: Advertise on medium frequency, around every 250ms - HIGH = 1600 #: Advertise on low frequency, around every 1000ms - MAX = 16777215 #: Maximum value for advertising interval - - -class TXPower(IntEnum): - """Advertising transmission (TX) power level constants. - https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters#TX_POWER_HIGH - """ - MIN = -127 #: Minimum value for TX power - ULTRA_LOW = -21 #: Advertise using the lowest TX power level - LOW = -15 #: Advertise using the low TX power level - MEDIUM = -7 #: Advertise using the medium TX power level - MAX = 1 #: Maximum value for TX power - - -class Status: - """Advertising operation status constants. - https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetCallback#constants - """ - SUCCESS = 0 - DATA_TOO_LARGE = 1 - TOO_MANY_ADVERTISERS = 2 - ALREADY_STARTED = 3 - INTERNAL_ERROR = 4 - FEATURE_UNSUPPORTED = 5 - - -@dataclass -class ADStructure: - - @abstractmethod - def add_payload(self, builder: AdvertiseDataBuilder): - pass - - -class DeviceName(ADStructure): - """Include device name (complete local name) in advertise packet.""" - - def add_payload(self, builder): - builder.setIncludeDeviceName(True) - - -class TXPowerLevel(ADStructure): - """Include transmission power level in the advertise packet.""" - - def add_payload(self, builder): - builder.setIncludeTxPowerLevel(True) - - -@dataclass -class ServiceUUID(ADStructure): - """Service UUID to advertise. - - :param uid: UUID to be advertised - """ - uid: str - - def add_payload(self, builder): - builder.addServiceUuid( - ParcelUuid.fromString(self.uid) - ) - - -@dataclass -class ServiceData(ADStructure): - """Service data to advertise. - - :param uid: UUID of the service the data is associated with - :param data: Service data - """ - - uid: str - data: Union[list, tuple, bytes, bytearray] - - def add_payload(self, builder): - builder.addServiceData( - ParcelUuid.fromString(self.uid), - force_convertible_to_java_array(self.data) - ) - - -@dataclass -class ManufacturerData(ADStructure): - """Manufacturer specific data to advertise. - - :param id: Manufacturer ID - :param data: Manufacturer specific data - """ - id: int - data: Union[list, tuple, bytes, bytearray] - - def add_payload(self, builder): - builder.addManufacturerData( - self.id, - force_convertible_to_java_array(self.data) - ) - - -class AdvertiseData: - """Builder for data payload to be advertised. - - :param payload: List of AD structures to include in advertisement - - >>> AdvertiseData(DeviceName(), ManufacturerData(10, b'specific data')) - [DeviceName(), ManufacturerData(id=10, data=b'specific data')] - """ - - def __init__(self, *payload: List[ADStructure]): - self.payload = payload - self.data = self.build() - - def __repr__(self): - sections = ", ".join(repr(ad) for ad in self.payload) - return f"[{sections}]" - - def build(self) -> AndroidAdvertiseData: - builder = AdvertiseDataBuilder() - for ad in self.payload: - ad.add_payload(builder) - return builder.build() - - -class Advertiser(EventDispatcher): - """Base class for BLE advertise operations. - - :param ble: BLE interface instance - :param data: Advertisement data to be broadcasted - :param scan_data: Scan response associated with the advertisement data - :param interval: Advertising interval - ``_ - :param tx_power: Transmission power level - ``_ - - >>> Advertiser( - ... ble=BluetoothDispatcher(), - ... data=AdvertiseData(DeviceName()), - ... scan_data=AdvertiseData(TXPowerLevel()), - ... interval=Interval.MIN, - ... tx_power=TXPower.MAX - ... ) #doctest: +ELLIPSIS - - """ - - __events__ = ( - 'on_advertising_started', - 'on_advertising_stopped', - 'on_advertising_enabled', - 'on_advertising_data_set', - 'on_scan_response_data_set', - 'on_advertising_parameters_updated', - 'on_advertising_set_changed', - ) - - def __init__( - self, - ble: BluetoothDispatcher, - data: AdvertiseData = None, - scan_data: AdvertiseData = None, - interval: int = Interval.HIGH, - tx_power: int = TXPower.MEDIUM, - ): - self._ble = ble - self._data = data - self._scan_data = scan_data - self._interval = interval - self._tx_power = tx_power - - self._events_interface = PythonBluetoothAdvertiser(self) - self._advertiser = BLEAdvertiser(self._events_interface) - self._callback_set = self._advertiser.mCallbackSet - self._advertising_set = None - - @property - def data(self): - """ - :setter: Update advertising data - :type: Optional[AdvertiseData] - """ - return self._data - - @data.setter - def data(self, value): - self._data = value - self._update_advertising_set() - - @property - def scan_data(self): - """ - :setter: Update the scan response - :type: Optional[AdvertiseData] - """ - return self._scan_data - - @scan_data.setter - def scan_data(self, value): - self._scan_data = value - self._update_advertising_set() - - @property - def interval(self): - """ - :setter: Update the advertising interval - :type: int - """ - return self._interval - - @interval.setter - def interval(self, value): - self._interval = value - self._update_advertising_set() - - @property - def tx_power(self): - """ - :setter: Update the transmission power level - :type: int - """ - return self._tx_power - - @tx_power.setter - def tx_power(self, value): - self._tx_power = value - self._update_advertising_set() - - @property - def bluetooth_le_advertiser(self) -> Optional[BluetoothLeAdvertiser]: - adapter = self._ble.adapter - return adapter and adapter.getBluetoothLeAdvertiser() - - @property - def parameters(self) -> AdvertisingSetParametersBuilder: - builder = AdvertisingSetParametersBuilder() - builder.setLegacyMode(True) \ - .setConnectable(False) \ - .setScannable(True) \ - .setInterval(self._interval) \ - .setTxPowerLevel(self._tx_power) - return builder.build() - - def start(self): - """Start advertising. - - Start a system activity that allows the user to turn on Bluetooth if Bluetooth is not enabled. - """ - if not self._advertising_set: - self._ble._start_advertising(self) - - def stop(self): - """Stop advertising.""" - advertiser = self.bluetooth_le_advertiser - if advertiser: - advertiser.stopAdvertisingSet(self._callback_set) - - def on_advertising_started(self, advertising_set: AdvertisingSet, tx_power: int, status: Status): - """Handler for advertising start operation (onAdvertisingSetStarted). - """ - - def on_advertising_stopped(self, advertising_set: AdvertisingSet): - """Handler for advertising stop operation (onAdvertisingSetStopped).""" - - def on_advertising_enabled(self, advertising_set: AdvertisingSet, enable: bool, status: Status): - """Handler for advertising enable/disable operation (onAdvertisingEnabled).""" - - def on_advertising_data_set(self, advertising_set: AdvertisingSet, status: Status): - """Handler for data set operation (onAdvertisingDataSet).""" - - def on_scan_response_data_set(self, advertising_set: AdvertisingSet, status: Status): - """Handler for scan response data set operation (onScanResponseDataSet).""" - - def on_advertising_parameters_updated(self, advertising_set: AdvertisingSet, tx_power: int, status: Status): - """Handler for parameters set operation (onAdvertisingParametersUpdated).""" - - def on_advertising_set_changed(self, advertising_set): - self._advertising_set = advertising_set - - def _start(self): - advertiser = self.bluetooth_le_advertiser - if advertiser: - self._callback_set = self._advertiser.createCallback() - try: - advertiser.startAdvertisingSet( - self.parameters, - self._data and self._data.data, - self._scan_data and self._scan_data.data, - None, # periodicParameters - None, # periodicData - self._callback_set - ) - except JavaException as exc: - if exc.classname == 'java.lang.IllegalArgumentException' and \ - exc.innermessage.endswith('data too big'): - self.dispatch('on_advertising_started', None, 0, Status.DATA_TOO_LARGE) - raise - - def _update_advertising_set(self): - advertising_set = self._advertising_set - if advertising_set: - advertising_set.setAdvertisingParameters(self.parameters) - advertising_set.setScanResponseData(self._scan_data and self._scan_data.data) - advertising_set.setAdvertisingData(self._data and self._data.data) diff --git a/libs/able/able/android/dispatcher.py b/libs/able/able/android/dispatcher.py deleted file mode 100644 index ea90759..0000000 --- a/libs/able/able/android/dispatcher.py +++ /dev/null @@ -1,105 +0,0 @@ -from jnius import JavaException, autoclass -from kivy.logger import Logger - -from able.adapter import ( - AdapterManager, - require_bluetooth_enabled, - set_adapter_failure_rollback, -) -from able.android.jni import PythonBluetooth -from able.dispatcher import BluetoothDispatcherBase -from able.scan_settings import ScanSettingsBuilder - -ArrayList = autoclass("java.util.ArrayList") - -try: - BLE = autoclass("org.able.BLE") -except: - Logger.error( - "able_recipe: Failed to load Java class org.able.BLE. Possible build error." - ) - raise -else: - Logger.info("able_recipe: org.able.BLE Java class loaded") - -BluetoothAdapter = autoclass("android.bluetooth.BluetoothAdapter") -BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice") -BluetoothGattDescriptor = autoclass("android.bluetooth.BluetoothGattDescriptor") - -ENABLE_NOTIFICATION_VALUE = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE -ENABLE_INDICATION_VALUE = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE -DISABLE_NOTIFICATION_VALUE = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE - - -class BluetoothDispatcher(BluetoothDispatcherBase): - @property - @require_bluetooth_enabled - def adapter(self): - return AdapterManager.get_attached_manager(self).adapter - - @property - def bonded_devices(self): - ble_types = (BluetoothDevice.DEVICE_TYPE_LE, BluetoothDevice.DEVICE_TYPE_DUAL) - adapter = self.adapter - devices = adapter.getBondedDevices().toArray() if adapter else [] - return [dev for dev in devices if dev.getType() in ble_types] - - def _set_ble_interface(self): - self._events_interface = PythonBluetooth(self) - self._ble = BLE(self._events_interface) - - @set_adapter_failure_rollback( - lambda self: self.dispatch("on_scan_started", success=False) - ) - @require_bluetooth_enabled - def start_scan(self, filters=None, settings=None): - filters_array = ArrayList() - for f in filters or []: - filters_array.add(f.build()) - if not settings: - settings = ScanSettingsBuilder() - try: - settings = settings.build() - except AttributeError: - pass - self._ble.startScan(self.enable_ble_code, filters_array, settings) - - def stop_scan(self): - self._ble.stopScan() - - @require_bluetooth_enabled - def connect_by_device_address(self, address: str, autoconnect: bool = False): - address = address.upper() - if not BluetoothAdapter.checkBluetoothAddress(address): - raise ValueError(f"{address} is not a valid Bluetooth address") - adapter = self.adapter - if adapter: - self.connect_gatt(adapter.getRemoteDevice(address), autoconnect) - - @require_bluetooth_enabled - def enable_notifications(self, characteristic, enable=True, indication=False): - if not self.gatt.setCharacteristicNotification(characteristic, enable): - return False - - if not enable: - # DISABLE_NOTIFICAITON_VALUE is for disabling - # both notifications and indications - descriptor_value = DISABLE_NOTIFICATION_VALUE - elif indication: - descriptor_value = ENABLE_INDICATION_VALUE - else: - descriptor_value = ENABLE_NOTIFICATION_VALUE - - for descriptor in characteristic.getDescriptors().toArray(): - self.write_descriptor(descriptor, descriptor_value) - return True - - @require_bluetooth_enabled - def _start_advertising(self, advertiser): - advertiser._start() - - @require_bluetooth_enabled - def _set_name(self, value): - adapter = self.adapter - if adapter: - self.adapter.setName(value) diff --git a/libs/able/able/android/jni.py b/libs/able/able/android/jni.py deleted file mode 100644 index bac7c50..0000000 --- a/libs/able/able/android/jni.py +++ /dev/null @@ -1,151 +0,0 @@ -from able import GATT_SUCCESS -from able.structures import Advertisement, Services -from jnius import PythonJavaClass, java_method -from kivy.logger import Logger - - -class PythonBluetooth(PythonJavaClass): - __javainterfaces__ = ['org.able.PythonBluetooth'] - __javacontext__ = 'app' - - def __init__(self, dispatcher): - super(PythonBluetooth, self).__init__() - self.dispatcher = dispatcher - - @java_method('(Ljava/lang/String;)V') - def on_error(self, msg): - Logger.debug("on_error") - self.dispatcher.dispatch('on_error', msg) - - @java_method('(Landroid/bluetooth/le/ScanResult;)V') - def on_scan_result(self, result): - device = result.getDevice() # type: android.bluetooth.BluetoothDevice - record = result.getScanRecord() # type: android.bluetooth.le.ScanRecord - if record: - self.dispatcher.dispatch( - 'on_device', - device, - result.getRssi(), - Advertisement(record.getBytes()) - ) - else: - Logger.warning( - "Scan result for device without the scan record: %s", - device - ) - - @java_method('(Z)V') - def on_scan_started(self, success): - Logger.debug("on_scan_started") - self.dispatcher.dispatch('on_scan_started', success) - - @java_method('()V') - def on_scan_completed(self): - Logger.debug("on_scan_completed") - self.dispatcher.dispatch('on_scan_completed') - - @java_method('(II)V') - def on_connection_state_change(self, status, state): - Logger.debug("on_connection_state_change status={} state: {}".format( - status, state)) - self.dispatcher.dispatch('on_connection_state_change', status, state) - - @java_method('(I)V') - def on_bluetooth_adapter_state_change(self, state): - Logger.debug("on_bluetooth_adapter_state_change state: {}".format(state)) - self.dispatcher.dispatch('on_bluetooth_adapter_state_change', state) - - @java_method('(ILjava/util/List;)V') - def on_services(self, status, services): - services_dict = Services() - if status == GATT_SUCCESS: - for service in services.toArray(): - service_uuid = service.getUuid().toString() - Logger.debug("Service discovered: {}".format(service_uuid)) - services_dict[service_uuid] = {} - for c in service.getCharacteristics().toArray(): - characteristic_uuid = c.getUuid().toString() - Logger.debug("Characteristic discovered: {}".format( - characteristic_uuid)) - services_dict[service_uuid][characteristic_uuid] = c - self.dispatcher.dispatch('on_services', status, services_dict) - - @java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;)V') - def on_characteristic_changed(self, characteristic): - # uuid = characteristic.getUuid().toString() - self.dispatcher.dispatch('on_characteristic_changed', characteristic) - - @java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;I)V') - def on_characteristic_read(self, characteristic, status): - self.dispatcher.dispatch('on_gatt_release') - # uuid = characteristic.getUuid().toString() - self.dispatcher.dispatch('on_characteristic_read', - characteristic, - status) - - @java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;I)V') - def on_characteristic_write(self, characteristic, status): - self.dispatcher.dispatch('on_gatt_release') - # uuid = characteristic.getUuid().toString() - self.dispatcher.dispatch('on_characteristic_write', - characteristic, - status) - - @java_method('(Landroid/bluetooth/BluetoothGattDescriptor;I)V') - def on_descriptor_read(self, descriptor, status): - self.dispatcher.dispatch('on_gatt_release') - # characteristic = descriptor.getCharacteristic() - # uuid = characteristic.getUuid().toString() - self.dispatcher.dispatch('on_descriptor_read', descriptor, status) - - @java_method('(Landroid/bluetooth/BluetoothGattDescriptor;I)V') - def on_descriptor_write(self, descriptor, status): - self.dispatcher.dispatch('on_gatt_release') - # characteristic = descriptor.getCharacteristic() - # uuid = characteristic.getUuid().toString() - self.dispatcher.dispatch('on_descriptor_write', descriptor, status) - - @java_method('(II)V') - def on_rssi_updated(self, rssi, status): - self.dispatcher.dispatch('on_gatt_release') - self.dispatcher.dispatch('on_rssi_updated', rssi, status) - - @java_method('(II)V') - def on_mtu_changed(self, mtu, status): - self.dispatcher.dispatch('on_gatt_release') - self.dispatcher.dispatch('on_mtu_changed', mtu, status) - - -class PythonBluetoothAdvertiser(PythonJavaClass): - __javainterfaces__ = ['org.able.PythonBluetoothAdvertiser'] - __javacontext__ = 'app' - - def __init__(self, dispatcher): - super().__init__() - self.dispatcher = dispatcher - - @java_method('(Landroid/bluetooth/le/AdvertisingSet;II)V') - def on_advertising_started(self, advertising_set, tx_power, status): - self.dispatcher.dispatch('on_advertising_set_changed', advertising_set) - self.dispatcher.dispatch('on_advertising_started', advertising_set, tx_power, status) - - @java_method('(Landroid/bluetooth/le/AdvertisingSet;)V') - def on_advertising_stopped(self, advertising_set): - self.dispatcher.dispatch('on_advertising_set_changed', None) - self.dispatcher.dispatch('on_advertising_stopped', advertising_set) - - @java_method('(Landroid/bluetooth/le/AdvertisingSet;BI)V') - def on_advertising_enabled(self, advertising_set, enable, status): - self.dispatcher.dispatch('on_advertising_enabled', advertising_set, enable, status) - - @java_method('(Landroid/bluetooth/le/AdvertisingSet;I)V') - def on_advertising_data_set(self, advertising_set, status): - self.dispatcher.dispatch('on_advertising_data_set', advertising_set, status) - - @java_method('(Landroid/bluetooth/le/AdvertisingSet;I)V') - def on_scan_response_data_set(self, advertising_set, status): - self.dispatcher.dispatch('on_scan_response_data_set', advertising_set, status) - - @java_method('(Landroid/bluetooth/le/AdvertisingSet;II)V') - def on_advertising_parameters_updated(self, advertising_set, tx_power, status): - self.dispatcher.dispatch('on_advertising_parameters_updated', advertising_set, tx_power, status) diff --git a/libs/able/able/dispatcher.py b/libs/able/able/dispatcher.py deleted file mode 100644 index 7efff78..0000000 --- a/libs/able/able/dispatcher.py +++ /dev/null @@ -1,371 +0,0 @@ -from typing import List, Optional - -from kivy.event import EventDispatcher -from kivy.logger import Logger - -from able import WriteType -from able.adapter import AdapterManager -from able.filters import Filter -from able.permissions import DEFAULT_RUNTIME_PERMISSIONS -from able.queue import BLEQueue, ble_task, ble_task_done -from able.scan_settings import ScanSettingsBuilder -from able.utils import force_convertible_to_java_array - - -class BLEError: - """Raise Exception on attribute access""" - - def __init__(self, msg): - self.msg = msg - - def __getattr__(self, name): - raise Exception(self.msg) - - -class BluetoothDispatcherBase(EventDispatcher): - __events__ = ( - "on_device", - "on_scan_started", - "on_scan_completed", - "on_services", - "on_connection_state_change", - "on_bluetooth_adapter_state_change", - "on_characteristic_changed", - "on_characteristic_read", - "on_characteristic_write", - "on_descriptor_read", - "on_descriptor_write", - "on_gatt_release", - "on_error", - "on_rssi_updated", - "on_mtu_changed", - ) - queue_class = BLEQueue - - def __init__( - self, - queue_timeout: float = 0.5, - enable_ble_code: int = 0xAB1E, - runtime_permissions: Optional[list[str]] = None, # DEFAULT_RUNTIME_PERMISSIONS - ): - super(BluetoothDispatcherBase, self).__init__() - self.queue_timeout = queue_timeout - self.enable_ble_code = enable_ble_code - self.runtime_permissions = [ - str(permission) - for permission in ( - runtime_permissions - if runtime_permissions is not None - else DEFAULT_RUNTIME_PERMISSIONS - ) - ] - self._remote_device_address = None - self._set_ble_interface() - self._set_queue() - self._set_adapter_manager() - - def _set_ble_interface(self): - self._ble = BLEError("BLE is not implemented for platform") - - def _set_queue(self): - self.queue = self.queue_class(timeout=self.queue_timeout) - - def _set_adapter_manager(self): - AdapterManager( - ble=self._ble, - enable_ble_code=self.enable_ble_code, - runtime_permissions=self.runtime_permissions, - ).install(self) - - def _check_runtime_permissions(self): - return True - - def _request_runtime_permissions(self): - pass - - @property - def adapter(self) -> Optional["android.bluetooth.BluetoothAdapter"]: - """Local device Bluetooth adapter. - Could be `None` if adapter is not enabled or access to the adapter is not granted yet. - - :type: `BluetoothAdapter `_ - `Java object `_ - """ - - @property - def gatt(self): - """GATT profile of the connected device - - :type: BluetoothGatt Java object - """ - return self._ble.getGatt() - - @property - def bonded_devices(self): - """List of Java `android.bluetooth.BluetoothDevice` objects of paired BLE devices. - - :type: List[BluetoothDevice] - """ - return [] - - @property - def name(self): - """Name of the Bluetooth adapter. - - :setter: Set name of the Bluetooth adapter - :type: Optional[str] - """ - adapter = self.adapter - return adapter and adapter.getName() - - @name.setter - def name(self, value): - self._set_name(value) - - def _set_name(self, value): - pass - - def set_queue_timeout(self, timeout): - """Change the BLE operations queue timeout""" - self.queue_timeout = timeout - self.queue.set_timeout(timeout) - - def start_scan( - self, - filters: Optional[List[Filter]] = None, - settings: Optional[ScanSettingsBuilder] = None, - ): - """Start a scan for devices. - The status of the scan start are reported with - :func:`scan_started ` event. - - :param filters: list of filters to restrict scan results. - Advertising record is considered matching the filters - if it matches any of the :class:`able.filters.Filter` in the list. - :param settings: scan settings - """ - pass - - def stop_scan(self): - """Stop the ongoing scan for devices.""" - pass - - def connect_by_device_address(self, address: str, autoconnect: bool = False): - """Connect to GATT Server of the device with a given Bluetooth hardware address, without scanning. - - :param address: Bluetooth hardware address string in "XX:XX:XX:XX:XX:XX" format - :param autoconnect: If True, automatically reconnects when available. - False = direct connect (default). - :raises: - ValueError: if `address` is not a valid Bluetooth address - """ - pass - - def connect_gatt(self, device, autoconnect: bool = False): - """Connect to GATT Server hosted by device - - :param device: BluetoothDevice Java object - :param autoconnect: If True, automatically reconnects when available. - False = direct connect (default). - """ - self._ble.connectGatt(device, autoconnect) - - def close_gatt(self): - """Close current GATT client""" - self._ble.closeGatt() - - def discover_services(self): - """Discovers services offered by a remote device. - The status of the discovery reported with - :func:`services ` event. - - :return: true, if the remote services discovery has been started - """ - return self.gatt.discoverServices() - - def enable_notifications(self, characteristic, enable=True, indication=False): - """Enable/disable notifications or indications for a given characteristic - - :param characteristic: BluetoothGattCharacteristic Java object - :param enable: enable notifications if True, else disable notifications - :param indication: handle indications instead of notifications - :return: True, if the operation was initiated successfully - """ - return True - - @ble_task - def write_descriptor(self, descriptor, value): - """Set and write the value of a given descriptor to the associated - remote device - - :param descriptor: BluetoothGattDescriptor Java object - :param value: value to write - """ - if not descriptor.setValue(force_convertible_to_java_array(value)): - Logger.error("Error on set descriptor value") - return - if not self.gatt.writeDescriptor(descriptor): - Logger.error("Error on descriptor write") - - @ble_task - def write_characteristic( - self, characteristic, value, write_type: Optional[WriteType] = None - ): - """Write a given characteristic value to the associated remote device - - :param characteristic: BluetoothGattCharacteristic Java object - :param value: value to write - :param write_type: specific write type to set for the characteristic - """ - self._ble.writeCharacteristic( - characteristic, force_convertible_to_java_array(value), int(write_type or 0) - ) - - @ble_task - def read_characteristic(self, characteristic): - """Read a given characteristic from the associated remote device - - :param characteristic: BluetoothGattCharacteristic Java object - """ - self._ble.readCharacteristic(characteristic) - - @ble_task - def update_rssi(self): - """Triggers an update for the RSSI from the associated remote device""" - self._ble.readRemoteRssi() - - @ble_task - def request_mtu(self, mtu: int): - """Request to change the ATT Maximum Transmission Unit value - - :param value: new MTU size - """ - self.gatt.requestMtu(mtu) - - def on_error(self, msg): - """Error handler - - :param msg: error message - """ - self._ble = BLEError(msg) # Exception for calls from another threads - raise Exception(msg) - - @ble_task_done - def on_gatt_release(self): - """`gatt_release` event handler. - Event is dispatched at every read/write completed operation - """ - pass - - def on_scan_started(self, success): - """`scan_started` event handler - - :param success: true, if scan was started successfully - """ - pass - - def on_scan_completed(self): - """`scan_completed` event handler""" - pass - - def on_device(self, device, rssi, advertisement): - """`device` event handler. - Event is dispatched when device is found during a scan. - - :param device: BluetoothDevice Java object - :param rssi: the RSSI value for the remote device - :param advertisement: :class:`Advertisement` data record - """ - pass - - def on_connection_state_change(self, status, state): - """`connection_state_change` event handler - - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - :param state: STATE_CONNECTED or STATE_DISCONNECTED - """ - pass - - def on_bluetooth_adapter_state_change(self, state): - """`bluetooth_adapter_state_change` event handler - Allows the user to detect when bluetooth adapter is turned on/off. - - :param state: STATE_OFF, STATE_TURNING_OFF, STATE_ON, STATE_TURNING_ON - """ - - def on_services(self, services, status): - """`services` event handler - - :param services: :class:`Services` dict filled with discovered - characteristics - (BluetoothGattCharacteristic Java objects) - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - """ - pass - - def on_characteristic_changed(self, characteristic): - """`characteristic_changed` event handler - - :param characteristic: BluetoothGattCharacteristic Java object - """ - pass - - def on_characteristic_read(self, characteristic, status): - """`characteristic_read` event handler - - :param characteristic: BluetoothGattCharacteristic Java object - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - """ - pass - - def on_characteristic_write(self, characteristic, status): - """`characteristic_write` event handler - - :param characteristic: BluetoothGattCharacteristic Java object - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - """ - pass - - def on_descriptor_read(self, descriptor, status): - """`descriptor_read` event handler - - :param descriptor: BluetoothGattDescriptor Java object - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - """ - pass - - def on_descriptor_write(self, descriptor, status): - """`descriptor_write` event handler - - :param descriptor: BluetoothGattDescriptor Java object - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - """ - pass - - def on_rssi_updated(self, rssi, status): - """`onReadRemoteRssi` event handler. - Event is dispatched at every RSSI update completed operation, - reporting a RSSI value for a remote device connection. - - :param rssi: integer containing RSSI value in dBm - :param status: status of the operation, - `GATT_SUCCESS` if the operation succeeds - """ - pass - - def on_mtu_changed(self, mtu, status): - """`onMtuChanged` event handler - Event is dispatched when MTU for a remote device has changed, - reporting a new MTU size. - - :param mtu: integer containing the new MTU size - :param status: status of the operation, - `GATT_SUCCESS` if the MTU has been changed successfully - """ - pass diff --git a/libs/able/able/filters.py b/libs/able/able/filters.py deleted file mode 100644 index 0b362f3..0000000 --- a/libs/able/able/filters.py +++ /dev/null @@ -1,237 +0,0 @@ -"""BLE scanning filters, -wrappers for Java class `android.bluetooth.le.ScanFilter.Builder` -https://developer.android.com/reference/android/bluetooth/le/ScanFilter.Builder -""" -from abc import abstractmethod -from dataclasses import dataclass, field -from typing import List, Union -import uuid - -from jnius import autoclass - -ParcelUuid = autoclass('android.os.ParcelUuid') -BluetoothAdapter = autoclass('android.bluetooth.BluetoothAdapter') -ScanFilter = autoclass('android.bluetooth.le.ScanFilter') -ScanFilterBuilder = autoclass('android.bluetooth.le.ScanFilter$Builder') - - -@dataclass -class Filter: - """Base class for BLE scanning fiters. - - >>> # Filters of different kinds could be ANDed to set multiple conditions. - >>> # Both device name and address required: - >>> combined_filter = DeviceNameFilter("Example") & DeviceAddressFilter("01:02:03:AB:CD:EF") - - >>> DeviceNameFilter("Example1") & DeviceNameFilter("Example2") - Traceback (most recent call last): - ValueError: cannot combine filters of the same type - """ - - def __post_init__(self): - self.filters = [self] - - def __and__(self, other): - if type(self) in (type(f) for f in other.filters): - raise ValueError('cannot combine filters of the same type') - self.filters.extend(other.filters) - return self - - def build(self): - builder = ScanFilterBuilder() - for scan_filter in self.filters: - scan_filter.filter(builder) - return builder.build() - - @abstractmethod - def filter(self, builder): - pass - - -class EmptyFilter(Filter): - """Filter with no restrictions.""" - - def filter(self, builder): - pass - - -@dataclass -class DeviceAddressFilter(Filter): - """Set filter on device address. - Uses Java method `ScanFilter.Builder.setDeviceAddress`. - - :param address: Address in the format of "01:02:03:AB:CD:EF" - - >>> DeviceAddressFilter("01:02:03:AB:CD:EF") - DeviceAddressFilter(address='01:02:03:AB:CD:EF') - """ - address: str - - def __post_init__(self): - super().__post_init__() - if not BluetoothAdapter.checkBluetoothAddress(str(self.address)): - raise ValueError(f"{self.address} is not a valid Bluetooth address") - - def filter(self, builder): - builder.setDeviceAddress(str(self.address)) - - -@dataclass -class DeviceNameFilter(Filter): - """Set filter on device name. - Uses Java method `ScanFilter.Builder.setDeviceName`. - - :param name: Device name - """ - name: str - - def filter(self, builder): - builder.setDeviceName(str(self.name)) - - -@dataclass -class ManufacturerDataFilter(Filter): - """Set filter on manufacture data. - Uses Java method `ScanFilter.Builder.setManufacturerData`. - - :param id: Manufacturer ID - :param data: Manufacturer specific data - :param mask: bit mask for partial filtration of the `data`. For any bit in the mask, - set it to 1 if it needs to match the one in manufacturer data, - otherwise set it to 0 to ignore that bit. - - - >>> # Filter by just ID, ignoring the data: - >>> ManufacturerDataFilter(0x0AD0, []) - ManufacturerDataFilter(id=2768, data=[], mask=None) - - >>> ManufacturerDataFilter(0x0AD0, [0x2, 0x15, 0x8d]) - ManufacturerDataFilter(id=2768, data=[2, 21, 141], mask=None) - - >>> # With mask set to ignore the second data byte: - >>> ManufacturerDataFilter(0x0AD0, [0x2, 0, 0x8d], [0xff, 0, 0xff]) - ManufacturerDataFilter(id=2768, data=[2, 0, 141], mask=[255, 0, 255]) - - >>> ManufacturerDataFilter(0x0AD0, [0x2, 21, 0x8d], [0xff]) - Traceback (most recent call last): - ValueError: mask is shorter than the data - """ - id: int - data: Union[list, tuple, bytes, bytearray] - mask: List[int] = field(default_factory=lambda: None) - - def __post_init__(self): - super().__post_init__() - if self.mask and len(self.mask) < len(self.data): - raise ValueError('mask is shorter than the data') - - def filter(self, builder): - if self.mask: - builder.setManufacturerData(self.id, self.data, self.mask) - else: - builder.setManufacturerData(self.id, self.data) - - -@dataclass -class ServiceDataFilter(Filter): - """Set filter on service data. - Uses Java method `ScanFilter.Builder.setServiceData`. - - :param uid: UUID of the service in the format of - "0000180f-0000-1000-8000-00805f9b34fb" - :param data: service data - :param mask: bit mask for partial filtration of the `data`. For any bit in the mask, - set it to 1 if it needs to match the one in service data, - otherwise set it to 0 to ignore that bit. - - >>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", []) - ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[], mask=None) - - >>> # With mask set to ignore the first data byte: - >>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0, 0x11], [0, 0xff]) - ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[0, 17], mask=[0, 255]) - - >>> ServiceDataFilter("0000180f", []) - Traceback (most recent call last): - ValueError: badly formed hexadecimal UUID string - - >>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x12, 0x34], [0xff]) - Traceback (most recent call last): - ValueError: mask is shorter than the data - """ - uid: str - data: Union[list, tuple, bytes, bytearray] - mask: List[int] = field(default_factory=lambda: None) - - def __post_init__(self): - super().__post_init__() - # validate UUID value - uuid.UUID(self.uid) - if self.mask and len(self.mask) < len(self.data): - raise ValueError('mask is shorter than the data') - - def filter(self, builder): - uid = ParcelUuid.fromString(self.uid) - if self.mask: - builder.setServiceData(uid, self.data, self.mask) - else: - builder.setServiceData(uid, self.data) - - -@dataclass -class ServiceSolicitationFilter(Filter): - """Set filter on service solicitation uuid. - Uses Java method `ScanFilter.Builder.setServiceSolicitation`. - - :param uid: UUID of the service in the format of - "0000180f-0000-1000-8000-00805f9b34fb" - """ - uid: str - - def filter(self, builder): - uid = ParcelUuid.fromString(self.uid) - builder.setServiceSolicitation(uid) - - -@dataclass -class ServiceUUIDFilter(Filter): - """Set filter on service uuid. - Uses Java method `ScanFilter.Builder.setServiceUuid`. - - :param uid: UUID of the service in the format of - "0000180f-0000-1000-8000-00805f9b34fb" - :mask: bit mask for partial filtration of the UUID, in the format of - "ffffffff-0000-0000-0000-ffffffffffff". Set any bit in the mask - to 1 to indicate a match is needed for the bit in `uid`, - and 0 to ignore that bit. - - >>> ServiceUUIDFilter('16fe0d00-c111-11e3-b8c8-0002a5d5c51b') - ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51b', mask=None) - - >>> ServiceUUIDFilter( - ... '16fe0d00-c111-11e3-b8c8-0002a5d5c51b', - ... 'ffffffff-0000-0000-0000-000000000000' - ... ) #doctest: +ELLIPSIS - ServiceUUIDFilter(uid='16fe0d00-...', mask='ffffffff-...') - - >>> ServiceUUIDFilter('123') - Traceback (most recent call last): - ValueError: badly formed hexadecimal UUID string - """ - uid: str - mask: str = None - - def __post_init__(self): - super().__post_init__() - # validate UUID values - uuid.UUID(self.uid) - if self.mask: - uuid.UUID(self.mask) - - def filter(self, builder): - uid = ParcelUuid.fromString(self.uid) - if self.mask: - mask = ParcelUuid.fromString(self.mask) - builder.setServiceUuid(uid, mask) - else: - builder.setServiceUuid(uid) diff --git a/libs/able/able/permissions.py b/libs/able/able/permissions.py deleted file mode 100644 index 862e79e..0000000 --- a/libs/able/able/permissions.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Before executing, all :class:`BluetoothDispatcher ` methods that requires Bluetooth adapter -(`start_scan`, `connect_by_device_address`, `enable_notifications`, `adapter` property ...), -are asking the user to: - -#. grant runtime permissions, -#. turn on Bluetooth adapter. - -The list of requested runtime permissions varies depending on the level of the target Android API level: - -* target API level <=30: ACCESS_FINE_LOCATION - to obtain BLE scan results -* target API level >= 31: - - * BLUETOOTH_CONNECT - to enable adapter and to connect to devices - * BLUETOOTH_SCAN - to start the scan - * ACCESS_FINE_LOCATION - to detect beacons during the scan - * BLUETOOTH_ADVERTISE - to be able to advertise to nearby Bluetooth devices - -Requested permissions list can be changed with the `BluetoothDispatcher.runtime_permissions` parameter. -""" -from jnius import autoclass - -SDK_INT = int(autoclass("android.os.Build$VERSION").SDK_INT) - - -class Permission: - """ - String constants values for BLE-related permissions. - https://developer.android.com/reference/android/Manifest.permission - """ - - ACCESS_BACKGROUND_LOCATION = "android.permission.ACCESS_BACKGROUND_LOCATION" - ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION" - BLUETOOTH_ADVERTISE = "android.permission.BLUETOOTH_ADVERTISE" - BLUETOOTH_CONNECT = "android.permission.BLUETOOTH_CONNECT" - BLUETOOTH_SCAN = "android.permission.BLUETOOTH_SCAN" - - -if SDK_INT >= 31: - # API level 31 (Android 12) introduces new permissions - DEFAULT_RUNTIME_PERMISSIONS = [ - Permission.BLUETOOTH_ADVERTISE, - Permission.BLUETOOTH_CONNECT, - Permission.BLUETOOTH_SCAN, - # ACCESS_FINE_LOCATION is not mandatory for scan, - # but required to discover beacons - Permission.ACCESS_FINE_LOCATION, - ] -else: - # For API levels 29-30, - # ACCESS_FINE_LOCATION permission is needed to obtain BLE scan results - DEFAULT_RUNTIME_PERMISSIONS = [ - Permission.ACCESS_FINE_LOCATION, - ] diff --git a/libs/able/able/queue.py b/libs/able/able/queue.py deleted file mode 100644 index 8d30124..0000000 --- a/libs/able/able/queue.py +++ /dev/null @@ -1,90 +0,0 @@ -import threading -from functools import wraps, partial -try: - from queue import Empty, Queue -except ImportError: - from Queue import Empty, Queue -from kivy.clock import Clock -from kivy.logger import Logger - - -def ble_task(method): - """ - Enque method - """ - @wraps(method) - def wrapper(obj, *args, **kwargs): - task = partial(method, obj, *args, **kwargs) - obj.queue.enque(task) - return wrapper - - -def ble_task_done(method): - @wraps(method) - def wrapper(obj, *args, **kwargs): - obj.queue.done(*args, **kwargs) - method(obj, *args, **kwargs) - return wrapper - - -def with_lock(method): - @wraps(method) - def wrapped(obj, *args, **kwargs): - locked = obj.lock.acquire(False) - if locked: - try: - return method(obj, *args, **kwargs) - finally: - obj.lock.release() - return wrapped - - -class BLEQueue(object): - - def __init__(self, timeout=0): - self.lock = threading.Lock() - self.ready = True - self.queue = Queue() - self.set_timeout(timeout) - - def set_timeout(self, timeout): - Logger.debug("set queue timeout to {}".format(timeout)) - self.timeout = timeout - self.timeout_event = Clock.schedule_once( - self.on_timeout, self.timeout or 0) - self.timeout_event.cancel() - - def enque(self, task): - queue = self.queue - if self.timeout == 0: - self.execute_task(task) - else: - queue.put_nowait(task) - self.execute_next() - - @with_lock - def execute_next(self, ready=False): - if ready: - self.ready = True - elif not self.ready: - return - try: - task = self.queue.get_nowait() - except Empty: - return - self.ready = False - if task is not None: - self.execute_task(task) - - def done(self, *args, **kwargs): - self.timeout_event.cancel() - self.ready = True - self.execute_next() - - def on_timeout(self, *args, **kwargs): - self.done() - - def execute_task(self, task): - if self.timeout and self.timeout_event: - self.timeout_event() - task() diff --git a/libs/able/able/scan_settings.py b/libs/able/able/scan_settings.py deleted file mode 100644 index e5b1201..0000000 --- a/libs/able/able/scan_settings.py +++ /dev/null @@ -1,20 +0,0 @@ -"""BLE scanning settings. -""" -from jnius import autoclass -from kivy.utils import platform - - -if platform != 'android': - class ScanSettings: - """PyJNIus wrapper for Java class `android.bluetooth.le.ScanSettings`. - https://developer.android.com/reference/android/bluetooth/le/ScanSettings - """ - - class ScanSettingsBuilder: - """PyJNIus wrapper for Java class `android.bluetooth.le.ScanSettings.Builder`. - https://developer.android.com/reference/android/bluetooth/le/ScanSettings.Builder - """ - -else: - ScanSettings = autoclass('android.bluetooth.le.ScanSettings') - ScanSettingsBuilder = autoclass('android.bluetooth.le.ScanSettings$Builder') diff --git a/libs/able/able/src/org/able/BLE.java b/libs/able/able/src/org/able/BLE.java deleted file mode 100644 index 25771bb..0000000 --- a/libs/able/able/src/org/able/BLE.java +++ /dev/null @@ -1,283 +0,0 @@ -package org.able; - -import android.app.Activity; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.pm.PackageManager; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothManager; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothProfile; -import android.bluetooth.BluetoothGatt; -import android.bluetooth.BluetoothGattCallback; -import android.bluetooth.BluetoothGattCharacteristic; -import android.bluetooth.BluetoothGattDescriptor; -import android.bluetooth.BluetoothGattService; -import android.bluetooth.le.BluetoothLeScanner; -import android.bluetooth.le.ScanCallback; -import android.bluetooth.le.ScanResult; - -import android.bluetooth.le.ScanFilter; -import android.bluetooth.le.ScanSettings; - -import android.os.Handler; -import android.util.Log; -import java.util.List; -import org.kivy.android.PythonActivity; -import org.kivy.android.PythonService; -import org.able.PythonBluetooth; - - -public class BLE { - private String TAG = "BLE-python"; - private PythonBluetooth mPython; - private Context mContext; - private BluetoothAdapter mBluetoothAdapter; - private BluetoothLeScanner mBluetoothLeScanner; - private BluetoothGatt mBluetoothGatt; - private List mBluetoothGattServices; - private boolean mScanning; - private boolean mIsServiceContext = false; - - public void showError(final String msg) { - Log.e(TAG, msg); - if (!mIsServiceContext) { PythonActivity.mActivity.toastError(TAG + " error. " + msg); } - mPython.on_error(msg); - } - - public BLE(PythonBluetooth python) { - mPython = python; - mContext = (Context) PythonActivity.mActivity; - mBluetoothGatt = null; - - if (mContext == null) { - Log.d(TAG, "Service context detected"); - mIsServiceContext = true; - mContext = (Context) PythonService.mService; - } - - if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { - showError("Device does not support Bluetooth Low Energy."); - return; - } - - final BluetoothManager bluetoothManager = - (BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE); - mBluetoothAdapter = bluetoothManager.getAdapter(); - mContext.registerReceiver(mReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)); - } - - public BluetoothAdapter getAdapter(int EnableBtCode) { - if (mBluetoothAdapter == null) { - showError("Device do not support Bluetooth Low Energy."); - return null; - } - if (!mBluetoothAdapter.isEnabled()) { - if (mIsServiceContext) { - showError("BLE adapter is not enabled"); - } else { - Log.d(TAG, "BLE adapter is not enabled"); - Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); - PythonActivity.mActivity.startActivityForResult(enableBtIntent, EnableBtCode); - } - return null; - } - return mBluetoothAdapter; - } - - public BluetoothGatt getGatt() { - return mBluetoothGatt; - } - - public void startScan(int EnableBtCode, - List filters, - ScanSettings settings) { - Log.d(TAG, "startScan"); - BluetoothAdapter adapter = getAdapter(EnableBtCode); - if (adapter != null) { - Log.d(TAG, "BLE adapter is ready for scan"); - if (mBluetoothLeScanner == null) { - mBluetoothLeScanner = adapter.getBluetoothLeScanner(); - } - if (mBluetoothLeScanner != null) { - mScanning = false; - mBluetoothLeScanner.startScan(filters, settings, mScanCallback); - } else { - showError("Could not get BLE Scanner object."); - mPython.on_scan_started(false); - } - } - } - - public void stopScan() { - if (mBluetoothLeScanner != null) { - Log.d(TAG, "stopScan"); - mBluetoothLeScanner.stopScan(mScanCallback); - if (mScanning) { - mScanning = false; - mPython.on_scan_completed(); - } - } - } - - private final ScanCallback mScanCallback = - new ScanCallback() { - @Override - public void onScanResult(final int callbackType, final ScanResult result) { - if (!mScanning) { - mScanning = true; - Log.d(TAG, "BLE scan started successfully"); - mPython.on_scan_started(true); - } - if (mIsServiceContext) { - mPython.on_scan_result(result); - return; - } - PythonActivity.mActivity.runOnUiThread(new Runnable() { - @Override - public void run() { - mPython.on_scan_result(result); - } - }); - } - - @Override - public void onBatchScanResults(List results) { - Log.d(TAG, "onBatchScanResults"); - } - - @Override - public void onScanFailed(int errorCode) { - Log.e(TAG, "BLE Scan failed, error code:" + errorCode); - mPython.on_scan_started(false); - } - }; - - public void connectGatt(BluetoothDevice device) { - connectGatt(device, false); - } - - public void connectGatt(BluetoothDevice device, boolean autoConnect) { - Log.d(TAG, "connectGatt"); - if (mBluetoothGatt == null) { - mBluetoothGatt = device.connectGatt(mContext, autoConnect, mGattCallback, BluetoothDevice.TRANSPORT_LE); - } else { - Log.d(TAG, "BluetoothGatt object exists, use either closeGatt() to close Gatt or BluetoothGatt.connect() to re-connect"); - } - } - - public void closeGatt() { - Log.d(TAG, "closeGatt"); - if (mBluetoothGatt != null) { - mBluetoothGatt.close(); - mBluetoothGatt = null; - } - } - - private final BroadcastReceiver mReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) { - Log.d(TAG, "onReceive - BluetoothAdapter state changed"); - int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1); - mPython.on_bluetooth_adapter_state_change(state); - } - } - }; - - private final BluetoothGattCallback mGattCallback = - new BluetoothGattCallback() { - @Override - public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { - if (newState == BluetoothProfile.STATE_CONNECTED) { - Log.d(TAG, "Connected to GATT server, status:" + status); - } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { - Log.d(TAG, "Disconnected from GATT server, status:" + status); - } - if (mBluetoothGatt == null) { - mBluetoothGatt = gatt; - } - mPython.on_connection_state_change(status, newState); - } - - @Override - public void onServicesDiscovered(BluetoothGatt gatt, int status) { - if (status == BluetoothGatt.GATT_SUCCESS) { - Log.d(TAG, "onServicesDiscovered - success"); - mBluetoothGattServices = mBluetoothGatt.getServices(); - } else { - showError("onServicesDiscovered status:" + status); - mBluetoothGattServices = null; - } - mPython.on_services(status, mBluetoothGattServices); - } - - @Override - public void onCharacteristicChanged(BluetoothGatt gatt, - BluetoothGattCharacteristic characteristic) { - mPython.on_characteristic_changed(characteristic); - } - - @Override - public void onCharacteristicRead(BluetoothGatt gatt, - BluetoothGattCharacteristic characteristic, - int status) { - mPython.on_characteristic_read(characteristic, status); - } - - @Override - public void onCharacteristicWrite(BluetoothGatt gatt, - BluetoothGattCharacteristic characteristic, - int status) { - mPython.on_characteristic_write(characteristic, status); - } - - @Override - public void onDescriptorRead(BluetoothGatt gatt, - BluetoothGattDescriptor descriptor, - int status) { - mPython.on_descriptor_read(descriptor, status); - } - - @Override - public void onDescriptorWrite(BluetoothGatt gatt, - BluetoothGattDescriptor descriptor, - int status) { - mPython.on_descriptor_write(descriptor, status); - } - - @Override - public void onReadRemoteRssi(BluetoothGatt gatt, - int rssi, int status) { - mPython.on_rssi_updated(rssi, status); - } - - @Override - public void onMtuChanged(BluetoothGatt gatt, - int mtu, int status) { - Log.d(TAG, String.format("onMtuChanged mtu=%d status=%d", mtu, status)); - mPython.on_mtu_changed(mtu, status); - } - }; - - public boolean writeCharacteristic(BluetoothGattCharacteristic characteristic, byte[] data, int writeType) { - if (characteristic.setValue(data)) { - if (writeType != 0) { - characteristic.setWriteType(writeType); - } - return mBluetoothGatt.writeCharacteristic(characteristic); - } - return false; - } - - public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) { - return mBluetoothGatt.readCharacteristic(characteristic); - } - - public boolean readRemoteRssi() { - return mBluetoothGatt.readRemoteRssi(); - } -} diff --git a/libs/able/able/src/org/able/BLEAdvertiser.java b/libs/able/able/src/org/able/BLEAdvertiser.java deleted file mode 100644 index fcff023..0000000 --- a/libs/able/able/src/org/able/BLEAdvertiser.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.able; - -import android.bluetooth.le.AdvertisingSet; -import android.bluetooth.le.AdvertisingSetCallback; -import org.able.PythonBluetoothAdvertiser; -import android.util.Log; - - -public class BLEAdvertiser { - private String TAG = "BLE-python"; - private PythonBluetoothAdvertiser mPython; - public AdvertisingSetCallback mCallbackSet; - - public BLEAdvertiser(PythonBluetoothAdvertiser python) { - mPython = python; - } - - public AdvertisingSetCallback createCallback() { - mCallbackSet = new AdvertisingSetCallback() { - @Override - public void onAdvertisingSetStarted(AdvertisingSet advertisingSet, int txPower, int status) { - Log.d(TAG, "onAdvertisingSetStarted, status:" + status); - mPython.on_advertising_started(advertisingSet, txPower, status); - } - - @Override - public void onAdvertisingSetStopped(AdvertisingSet advertisingSet) { - Log.d(TAG, "onAdvertisingSetStopped"); - mCallbackSet = null; - mPython.on_advertising_stopped(advertisingSet); - } - - - @Override - public void onAdvertisingEnabled(AdvertisingSet advertisingSet, boolean enable, int status) { - Log.d(TAG, "onAdvertisingEnabled, enable:" + enable + "status:" + status); - mPython.on_advertising_enabled(advertisingSet, enable, status); - } - - - @Override - public void onAdvertisingDataSet(AdvertisingSet advertisingSet, int status) { - Log.d(TAG, "onAdvertisingDataSet, status:" + status); - mPython.on_advertising_data_set(advertisingSet, status); - } - - @Override - public void onScanResponseDataSet(AdvertisingSet advertisingSet, int status) { - Log.d(TAG, "onScanResponseDataSet, status:" + status); - mPython.on_scan_response_data_set(advertisingSet, status); - } - - @Override - public void onAdvertisingParametersUpdated(AdvertisingSet advertisingSet, int txPower, int status) { - Log.d(TAG, "onAdvertisingParametersUpdated, status:" + status); - mPython.on_advertising_parameters_updated(advertisingSet, txPower, status); - } - }; - return mCallbackSet; - } -} diff --git a/libs/able/able/src/org/able/PythonBluetooth.java b/libs/able/able/src/org/able/PythonBluetooth.java deleted file mode 100644 index 8179ca2..0000000 --- a/libs/able/able/src/org/able/PythonBluetooth.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.able; - -import java.util.List; -import android.bluetooth.BluetoothGattService; -import android.bluetooth.BluetoothGattCharacteristic; -import android.bluetooth.BluetoothGattDescriptor; -import android.bluetooth.le.ScanResult; - -interface PythonBluetooth -{ - public void on_error(String msg); - public void on_scan_started(boolean success); - public void on_scan_result(ScanResult result); - public void on_scan_completed(); - public void on_services(int status, List services); - public void on_characteristic_changed(BluetoothGattCharacteristic characteristic); - public void on_characteristic_read(BluetoothGattCharacteristic characteristic, int status); - public void on_characteristic_write(BluetoothGattCharacteristic characteristic, int status); - public void on_descriptor_read(BluetoothGattDescriptor descriptor, int status); - public void on_descriptor_write(BluetoothGattDescriptor descriptor, int status); - public void on_connection_state_change(int status, int state); - public void on_bluetooth_adapter_state_change(int state); - public void on_rssi_updated(int rssi, int status); - public void on_mtu_changed (int mtu, int status); -} diff --git a/libs/able/able/src/org/able/PythonBluetoothAdvertiser.java b/libs/able/able/src/org/able/PythonBluetoothAdvertiser.java deleted file mode 100644 index d2c6d38..0000000 --- a/libs/able/able/src/org/able/PythonBluetoothAdvertiser.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.able; - -import android.bluetooth.le.AdvertisingSet; - -interface PythonBluetoothAdvertiser -{ - public void on_advertising_started(AdvertisingSet advertisingSet, int txPower, int status); - public void on_advertising_stopped(AdvertisingSet advertisingSet); - public void on_advertising_enabled(AdvertisingSet advertisingSet, boolean enable, int status); - public void on_advertising_data_set(AdvertisingSet advertisingSet, int status); - public void on_scan_response_data_set(AdvertisingSet advertisingSet, int status); - public void on_advertising_parameters_updated(AdvertisingSet advertisingSet, int txPower, int status); -} diff --git a/libs/able/able/structures.py b/libs/able/able/structures.py deleted file mode 100644 index 2bd3e93..0000000 --- a/libs/able/able/structures.py +++ /dev/null @@ -1,81 +0,0 @@ -import re - -from collections import namedtuple - - -class Advertisement(object): - """Advertisement data record parser - - >>> ad = Advertisement([2, 1, 0x6, 6, 255, 82, 83, 95, 82, 48]) - >>> for data in ad: - ... data - AD(ad_type=1, data=bytearray(b'\\x06')) - AD(ad_type=255, data=bytearray(b'RS_R0')) - >>> list(ad)[0].ad_type == Advertisement.ad_types.flags - True - """ - - AD = namedtuple("AD", ['ad_type', 'data']) - - class ad_types: - """ - Assigned numbers for some of `advertisement data types - `_. - - flags : "Flags" (0x01) - - complete_local_name : "Complete Local Name" (0x09) - - service_data : "Service Data" (0x16) - - manufacturer_specific_data : "Manufacturer Specific Data" (0xff) - """ - flags = 0x01 - complete_local_name = 0x09 - service_data = 0x16 - manufacturer_specific_data = 0xff - - def __init__(self, data): - self.data = data - - def __iter__(self): - return Advertisement.parse(self.data) - - @classmethod - def parse(cls, data): - pos = 0 - while pos < len(data): - length = data[pos] - if length < 2: - return - try: - ad_type = data[pos + 1] - except IndexError: - return - next_pos = pos + length + 1 - if ad_type: - segment = slice(pos + 2, next_pos) - yield Advertisement.AD(ad_type, bytearray(data[segment])) - pos = next_pos - - -class Services(dict): - """Services dict - - >>> services = Services({'service0': {'c1-aa': 0, 'aa-c2-aa': 1}, - ... 'service1': {'bb-c3-bb': 2}}) - >>> services.search('c3') - 2 - >>> services.search('c4') - """ - - def search(self, pattern, flags=re.IGNORECASE): - """Search for characteristic by pattern - - :param pattern: regexp pattern - :param flags: regexp flags, re.IGNORECASE by default - """ - for characteristics in self.values(): - for uuid, characteristic in characteristics.items(): - if re.search(pattern, uuid, flags): - return characteristic diff --git a/libs/able/able/utils.py b/libs/able/able/utils.py deleted file mode 100644 index 3108372..0000000 --- a/libs/able/able/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Any, Union - - -def force_convertible_to_java_array( - value: Any -) -> Union[list, tuple, bytes, bytearray]: - """Construct a value that is convertible to a Java array. - - >>> force_convertible_to_java_array([3, 1, 4]) - [3, 1, 4] - >>> force_convertible_to_java_array(['314']) - ['314'] - >>> force_convertible_to_java_array('314') - b'314' - >>> force_convertible_to_java_array(314) - [314] - >>> force_convertible_to_java_array(0) - [0] - >>> force_convertible_to_java_array('') - [] - >>> force_convertible_to_java_array(None) - [] - >>> force_convertible_to_java_array({}) - [] - """ - if isinstance(value, (list, tuple, bytes, bytearray)): - return value - - try: - return value.encode() or [] - except AttributeError: - pass - - try: - return list(value) - except TypeError: - pass - - if value is None: - return [] - - return [value] diff --git a/libs/able/able/version.py b/libs/able/able/version.py deleted file mode 100644 index 4ec92f4..0000000 --- a/libs/able/able/version.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Package version. -This file is filled with actual value during the PyPI package build. -Development version is always "0.0.0". -""" -__version__ = '0.0.0' diff --git a/libs/able/docs/Makefile b/libs/able/docs/Makefile deleted file mode 100644 index 4a5e9a7..0000000 --- a/libs/able/docs/Makefile +++ /dev/null @@ -1,177 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/ABLE.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/ABLE.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/ABLE" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/ABLE" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/libs/able/docs/api.rst b/libs/able/docs/api.rst deleted file mode 100644 index 5226cae..0000000 --- a/libs/able/docs/api.rst +++ /dev/null @@ -1,154 +0,0 @@ -API ---- - -.. automodule:: able - -Client -^^^^^^ - -BluetoothDispatcher -""""""""""""""""""" - -.. autoclass:: BluetoothDispatcher - :members: adapter, - gatt, - bonded_devices, - name, - set_queue_timeout, - start_scan, - stop_scan, - connect_by_device_address, - connect_gatt, - close_gatt, - discover_services, - enable_notifications, - write_descriptor, - write_characteristic, - read_characteristic, - update_rssi, - request_mtu, - on_error, - on_gatt_release, - on_scan_started, - on_scan_completed, - on_device, - on_bluetooth_adapter_state_changeable, - on_connection_state_change, - on_services, - on_characteristic_changed, - on_characteristic_read, - on_characteristic_write, - on_descriptor_read, - on_descriptor_write, - on_rssi_updated, - on_mtu_changed, - -Decorators -"""""""""" - -.. autofunction:: require_bluetooth_enabled - - -Advertisement -""""""""""""" - -.. autoclass:: Advertisement - - .. autoclass:: able::Advertisement.ad_types - -Services -"""""""" - -.. autoclass:: Services - :members: - -Constants -""""""""" - -.. autodata:: GATT_SUCCESS -.. autodata:: STATE_CONNECTED -.. autodata:: STATE_DISCONNECTED -.. autoclass:: AdapterState - :members: - :member-order: bysource -.. autoclass:: WriteType - :members: - -Permissions -^^^^^^^^^^^ - -.. automodule:: able.permissions -.. automodule:: able -.. autoclass:: Permission - :members: - :undoc-members: - :member-order: bysource - - -Scan settings -^^^^^^^^^^^^^ - -.. automodule:: able.scan_settings -.. autoclass:: ScanSettingsBuilder -.. autoclass:: ScanSettings - - ->>> settings = ScanSettingsBuilder() \ -... .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) \ -... .setCallbackType( -... ScanSettings.CALLBACK_TYPE_FIRST_MATCH | -... ScanSettings.CALLBACK_TYPE_MATCH_LOST -... ) - - -Scan filters -^^^^^^^^^^^^ - -.. automodule:: able.filters - :members: - :member-order: bysource - :show-inheritance: - - -Advertising -^^^^^^^^^^^ - -.. automodule:: able.advertising - -Advertiser -"""""""""" -.. autoclass:: Advertiser - :members: - :member-order: bysource - - -Payload -""""""" -.. autoclass:: AdvertiseData - -.. autoclass:: DeviceName - :show-inheritance: -.. autoclass:: TXPowerLevel - :show-inheritance: -.. autoclass:: ServiceUUID - :show-inheritance: -.. autoclass:: ServiceData - :show-inheritance: -.. autoclass:: ManufacturerData - :show-inheritance: - -Constants -""""""""" - -.. autoclass:: Interval - :members: - :member-order: bysource - -.. autoclass:: TXPower - :members: - :member-order: bysource - -.. autoclass:: Status - :members: - :undoc-members: - :member-order: bysource diff --git a/libs/able/docs/conf.py b/libs/able/docs/conf.py deleted file mode 100644 index 28029ad..0000000 --- a/libs/able/docs/conf.py +++ /dev/null @@ -1,295 +0,0 @@ -# -*- coding: utf-8 -*- -# -# ABLE documentation build configuration file, created by -# sphinx-quickstart on Sun Apr 16 23:19:55 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, os.path.abspath('..')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'ABLE' -copyright = u'2017, b3b' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.1' -# The full version, including alpha/beta/rc tags. -release = '0.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'default' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'ABLEdoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ('index', 'ABLE.tex', u'ABLE Documentation', - u'b3b', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'able', u'ABLE Documentation', - [u'b3b'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'ABLE', u'ABLE Documentation', - u'b3b', 'ABLE', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False - - -# http://stackoverflow.com/questions/28366818/preserve-default-arguments-of-wrapped-decorated-python-function-in-sphinx-document -# Monkey-patch functools.wraps -import functools - -def no_op_wraps(func): - """Replaces functools.wraps in order to undo wrapping. - - Can be used to preserve the decorated function's signature - in the documentation generated by Sphinx. - - """ - def wrapper(decorator): - return func - return wrapper - -functools.wraps = no_op_wraps - - -# http://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules -# I get import errors on libraries that depend on C modules -from mock import MagicMock - -class Mock(MagicMock): - @classmethod - def __getattr__(cls, name): - return MagicMock() - -MOCK_MODULES = ['kivy', 'kivy.utils', 'kivy.clock', 'kivy.logger', - 'kivy.event'] -sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) -sys.modules['kivy.event'].EventDispatcher = object diff --git a/libs/able/docs/example.rst b/libs/able/docs/example.rst deleted file mode 100644 index a408c21..0000000 --- a/libs/able/docs/example.rst +++ /dev/null @@ -1,192 +0,0 @@ -Usage Examples -============== - -Alert ------ - -.. literalinclude:: ./examples/alert.py - :language: python - -Full example code: `alert `_ - - -Change MTU ----------- -.. literalinclude:: ./examples/mtu.py - :language: python - - -Scan settings -------------- - -.. code-block:: python - - from able import BluetoothDispatcher - from able.scan_settings import ScanSettingsBuilder, ScanSettings - - # Use faster detection (more power usage) mode - settings = ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - BluetoothDispatcher().start_scan(settings=settings) - - -Scan filters ------------- - -.. code-block:: python - - from able import BluetoothDispatcher - from able.filters import ( - DeviceAddressFilter, - DeviceNameFilter, - ManufacturerDataFilter, - ServiceDataFilter, - ServiceUUIDFilter - ) - - ble = BluetoothDispatcher() - - # Start scanning with the condition that device has one of names: "Device1" or "Device2" - ble.start_scan(filters=[DeviceNameFilter("Device1"), DeviceNameFilter("Device2")]) - ble.stop_scan() - - # Start scanning with the condition that - # device advertises "180f" service and one of names: "Device1" or "Device2" - ble.start_scan(filters=[ - ServiceUUIDFilter('0000180f-0000-1000-8000-00805f9b34fb') & DeviceNameFilter("Device1"), - ServiceUUIDFilter('0000180f-0000-1000-8000-00805f9b34fb') & DeviceNameFilter("Device2") - ]) - - -Adapter state -------------- - -.. literalinclude:: ./examples/adapter_state_change.py - :language: python - - -Advertising ------------ - -Advertise with data and additional (scannable) data -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. code-block:: python - - from able import BluetoothDispatcher - from able.advertising import ( - Advertiser, - AdvertiseData, - ManufacturerData, - Interval, - ServiceUUID, - ServiceData, - TXPower, - ) - - advertiser = Advertiser( - ble=BluetoothDispatcher(), - data=AdvertiseData(ServiceUUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), - scan_data=AdvertiseData(ManufacturerData(id=0xAABB, data=b"some data")), - interval=Interval.MEDIUM, - tx_power=TXPower.MEDIUM, - ) - - advertiser.start() - - -Set and advertise device name -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from able import BluetoothDispatcher - from able.advertising import Advertiser, AdvertiseData, DeviceName - - ble = BluetoothDispatcher() - ble.name = "New test device name" - - # There must be a wait and check, it takes time for new name to take effect - print(f"New device name is set: {ble.name}") - - Advertiser( - ble=ble, - data=AdvertiseData(DeviceName()) - ) - - -Battery service data -^^^^^^^^^^^^^^^^^^^^ - -.. literalinclude:: ./examples/advertising_battery.py - :language: python - - -Use iBeacon advertising format -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import uuid - from able import BluetoothDispatcher - from able.advertising import Advertiser, AdvertiseData, ManufacturerData - - - data = AdvertiseData( - ManufacturerData( - 0x4C, # Apple Manufacturer ID - bytes([ - 0x2, # SubType: Custom Manufacturer Data - 0x15 # Subtype lenth - ]) + - uuid.uuid4().bytes + # UUID of beacon - bytes([ - 0, 15, # Major value - 0, 1, # Minor value - 10 # RSSI, dBm at 1m - ])) - ) - - Advertiser(BluetoothDispatcher(), data).start() - - -Android Services ----------------- - -BLE devices scanning service -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**main.py** - -.. literalinclude:: ./examples/service_scan_main.py - :language: python - -**service.py** - -.. literalinclude:: ./examples/service_scan_service.py - :language: python - -Full example code: `service_scan `_ - - -Advertising service -^^^^^^^^^^^^^^^^^^^ - -**main.py** - -.. literalinclude:: ./examples/service_advertise_main.py - :language: python - -**service.py** - -.. literalinclude:: ./examples/service_advertise_service.py - :language: python - -Full example code: `service_advertise `_ - - -Connect to multiple devices ---------------------------- - -.. literalinclude:: ./examples/multi_devices/main.py - :language: python - -Full example code: `multi_devices `_ diff --git a/libs/able/docs/index.rst b/libs/able/docs/index.rst deleted file mode 100644 index 0c01475..0000000 --- a/libs/able/docs/index.rst +++ /dev/null @@ -1,3 +0,0 @@ -.. include:: ../README.rst -.. include:: api.rst -.. include:: example.rst diff --git a/libs/able/examples/adapter_state_change.py b/libs/able/examples/adapter_state_change.py deleted file mode 100644 index 0e97f6b..0000000 --- a/libs/able/examples/adapter_state_change.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Detect and log Bluetooth adapter state change.""" - -from typing import Optional - -from kivy.logger import Logger -from kivy.uix.widget import Widget - -from able import AdapterState, BluetoothDispatcher - - -class Dispatcher(BluetoothDispatcher): - def on_bluetooth_adapter_state_change(self, state: int): - Logger.info( - f"Bluetoth adapter state changed to {state} ('{AdapterState(state).name}')." - ) - if state == AdapterState.OFF: - Logger.info("Adapter state changed to OFF.") - - -class StateChangeApp(App): - def build(self): - Dispatcher() - return Widget() - - -if __name__ == "__main__": - StateChangeApp.run() diff --git a/libs/able/examples/advertising_battery.py b/libs/able/examples/advertising_battery.py deleted file mode 100644 index 970e622..0000000 --- a/libs/able/examples/advertising_battery.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Advertise battery level, that degrades every second.""" -from kivy.app import App -from kivy.clock import Clock -from kivy.uix.label import Label - -from able import BluetoothDispatcher -from able import advertising - -# Standard fully-qualified UUID for the Battery Service -BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb" - - -class BatteryAdvertiser(advertising.Advertiser): - - def on_advertising_started(self, advertising_set, tx_power, status): - if status == advertising.Status.SUCCESS: - print("Advertising is started successfully") - else: - print(f"Advertising start error status: {status}") - - def on_advertising_stopped(self, advertising_set): - print("Advertising stopped") - - -class BatteryLabel(Label): - """Widget to control advertiser and show current battery level.""" - - def __init__(self): - self._level = 0 - super().__init__(text="Waiting for advertising to start...") - self.advertiser = BatteryAdvertiser( - ble=BluetoothDispatcher(), - data=self.construct_data(level=100), - interval=advertising.Interval.MIN - ) - self.advertiser.bind(on_advertising_started=self.on_started) # bind to start of advertising - self.advertiser.start() - - def on_started(self, advertiser, advertising_set, tx_power, status): - if status == advertising.Status.SUCCESS: - # Advertising is started - update battery level every second - self.clock = Clock.schedule_interval(self.update_level, 1) - - def update_level(self, dt): - level = self._level = (self._level - 1) % 101 - self.text = str(level) - - if level > 0: - # Set new advertising data - self.advertiser.data = self.construct_data(level) - else: - self.clock.cancel() - # Stop advertising - self.advertiser.stop() - - def construct_data(self, level): - return advertising.AdvertiseData( - advertising.DeviceName(), - advertising.TXPowerLevel(), - advertising.ServiceData(BATTERY_SERVICE_UUID, [level]) - ) - - -class BatteryApp(App): - - def build(self): - return BatteryLabel() - - -if __name__ == "__main__": - BatteryApp().run() diff --git a/libs/able/examples/alert/buildozer.spec b/libs/able/examples/alert/buildozer.spec deleted file mode 100644 index 43b4d7b..0000000 --- a/libs/able/examples/alert/buildozer.spec +++ /dev/null @@ -1,27 +0,0 @@ -[app] -title = Alert Mi -version = 1.1 -package.name = alert_mi -package.domain = org.kivy -source.dir = . -source.include_exts = py,png,jpg,kv,atlas - -requirements = python3,kivy,android,able_recipe - -android.accept_sdk_license = True -android.permissions = - BLUETOOTH, - BLUETOOTH_ADMIN, - BLUETOOTH_SCAN, - BLUETOOTH_CONNECT, - BLUETOOTH_ADVERTISE, - ACCESS_FINE_LOCATION - -# android.api = 31 -# android.minapi = 31 - - -[buildozer] -warn_on_root = 1 -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 2 diff --git a/libs/able/examples/alert/error_message.kv b/libs/able/examples/alert/error_message.kv deleted file mode 100644 index 90edcf0..0000000 --- a/libs/able/examples/alert/error_message.kv +++ /dev/null @@ -1,21 +0,0 @@ -: - BoxLayout: - orientation: 'vertical' - padding: 10 - spacing: 20 - Label: - size_hint_y: None - font_size: '18sp' - height: '24sp' - text: 'Application has crashed, details: ' - ScrollView: - size_hint: 1, 1 - TextInput: - text: root.message - size_hint: 1, None - height: self.minimum_height - Button: - size_hint_y: None - height: '40sp' - text: 'OK, terminate' - on_press: root.dismiss() diff --git a/libs/able/examples/alert/error_message.py b/libs/able/examples/alert/error_message.py deleted file mode 100644 index d6722a7..0000000 --- a/libs/able/examples/alert/error_message.py +++ /dev/null @@ -1,39 +0,0 @@ -import os -import traceback - -from kivy.base import ( - ExceptionHandler, - ExceptionManager, - stopTouchApp, -) -from kivy.properties import StringProperty -from kivy.uix.popup import Popup -from kivy.lang import Builder -from kivy.logger import Logger - - -Builder.load_file(os.path.join(os.path.dirname(__file__), 'error_message.kv')) - - -class ErrorMessageOnException(ExceptionHandler): - - def handle_exception(self, exception): - Logger.exception('Unhandled Exception catched') - message = ErrorMessage(message=traceback.format_exc()) - - def raise_exception(*ar2gs, **kwargs): - stopTouchApp() - raise Exception("Exit due to errors") - - message.bind(on_dismiss=raise_exception) - message.open() - return ExceptionManager.PASS - - -class ErrorMessage(Popup): - title = StringProperty('Bang!') - message = StringProperty('') - - -def install_exception_handler(): - ExceptionManager.add_handler(ErrorMessageOnException()) diff --git a/libs/able/examples/alert/main.py b/libs/able/examples/alert/main.py deleted file mode 100644 index 433673a..0000000 --- a/libs/able/examples/alert/main.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Turn the alert on Mi Band device -""" -from kivy.app import App -from kivy.uix.button import Button - -from able import BluetoothDispatcher, GATT_SUCCESS -from error_message import install_exception_handler - - -class BLE(BluetoothDispatcher): - device = alert_characteristic = None - - def start_alert(self, *args, **kwargs): - if self.alert_characteristic: # alert service is already discovered - self.alert(self.alert_characteristic) - elif self.device: # device is already founded during the scan - self.connect_gatt(self.device) # reconnect - else: - self.stop_scan() # stop previous scan - self.start_scan() # start a scan for devices - - def on_device(self, device, rssi, advertisement): - # some device is found during the scan - name = device.getName() - if name and name.startswith('MI'): # is a Mi Band device - self.device = device - self.stop_scan() - - def on_scan_completed(self): - if self.device: - self.connect_gatt(self.device) # connect to device - - def on_connection_state_change(self, status, state): - if status == GATT_SUCCESS and state: # connection established - self.discover_services() # discover what services a device offer - else: # disconnection or error - self.alert_characteristic = None - self.close_gatt() # close current connection - - def on_services(self, status, services): - # 0x2a06 is a standard code for "Alert Level" characteristic - # https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.alert_level.xml - self.alert_characteristic = services.search('2a06') - self.alert(self.alert_characteristic) - - def alert(self, characteristic): - self.write_characteristic(characteristic, [2]) # 2 is for "High Alert" - - -class AlertApp(App): - - def build(self): - self.ble = None - return Button(text='Press to Alert Mi', on_press=self.start_alert) - - def start_alert(self, *args, **kwargs): - if not self.ble: - self.ble = BLE() - self.ble.start_alert() - - -if __name__ == '__main__': - install_exception_handler() - AlertApp().run() diff --git a/libs/able/examples/mtu.py b/libs/able/examples/mtu.py deleted file mode 100644 index 83a8da9..0000000 --- a/libs/able/examples/mtu.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Request MTU change, and write 100 bytes to a characteristic.""" -from kivy.app import App -from kivy.clock import Clock -from kivy.logger import Logger -from kivy.uix.widget import Widget - -from able import BluetoothDispatcher, GATT_SUCCESS - - -class BLESender(BluetoothDispatcher): - - def __init__(self): - super().__init__() - self.characteristic_to_write = None - Clock.schedule_once(self.connect, 0) - - def connect(self, _): - self.connect_by_device_address("FF:FF:FF:FF:FF:FF") - - def on_connection_state_change(self, status, state): - if status == GATT_SUCCESS and state: - self.discover_services() - - def on_services(self, status, services): - if status == GATT_SUCCESS: - self.characteristic_to_write = services.search("0d03") - # Need to request 100 + 3 extra bytes for ATT packet header - self.request_mtu(103) - - def on_mtu_changed(self, mtu, status): - if status == GATT_SUCCESS and mtu == 103: - Logger.info("MTU changed: now it is possible to send 100 bytes at once") - self.write_characteristic(self.characteristic_to_write, range(100)) - else: - Logger.error("MTU not changed: mtu=%d, status=%d", mtu, status) - - def on_characteristic_write(self, characteristic, status): - if status == GATT_SUCCESS: - Logger.info("Characteristic write succeed") - else: - Logger.error("Write status: %d", status) - - -class MTUApp(App): - - def build(self): - BLESender() - return Widget() - - -if __name__ == '__main__': - MTUApp().run() diff --git a/libs/able/examples/multi_devices/buildozer.spec b/libs/able/examples/multi_devices/buildozer.spec deleted file mode 100644 index f75a488..0000000 --- a/libs/able/examples/multi_devices/buildozer.spec +++ /dev/null @@ -1,27 +0,0 @@ -[app] -title = Multiple BLE devices -version = 1.0 -package.name = multidevs -package.domain = test.able -source.dir = . -source.include_exts = py,png,jpg,kv,atlas - -requirements = python3,kivy,android,able_recipe - -android.accept_sdk_license = True -android.permissions = - BLUETOOTH, - BLUETOOTH_ADMIN, - BLUETOOTH_SCAN, - BLUETOOTH_CONNECT, - BLUETOOTH_ADVERTISE, - ACCESS_FINE_LOCATION - -# android.api = 31 -# android.minapi = 31 - - -[buildozer] -warn_on_root = 1 -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 2 diff --git a/libs/able/examples/multi_devices/main.py b/libs/able/examples/multi_devices/main.py deleted file mode 100644 index 99b10e9..0000000 --- a/libs/able/examples/multi_devices/main.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Scan for devices with name "KivyBLETest", -connect and periodically read connected devices RSSI. - -Multiple `BluetoothDispatcher` objects are used: - one for the scanning process and one for every connected device. -""" -from able import GATT_SUCCESS, BluetoothDispatcher -from able.filters import DeviceNameFilter -from kivy.app import App -from kivy.clock import Clock -from kivy.logger import Logger -from kivy.uix.label import Label - - -class DeviceDispatcher(BluetoothDispatcher): - """Dispatcher to control a single BLE device.""" - - def __init__(self, device: "BluetoothDevice"): - super().__init__() - self._device = device - self._address: str = device.getAddress() - self._name: str = device.getName() or "" - - @property - def title(self) -> str: - return f"<{self._address}><{self._name}>" - - def on_connection_state_change(self, status: int, state: int): - if status == GATT_SUCCESS and state: - Logger.info(f"Device: {self.title} connected") - else: - Logger.info(f"Device: {self.title} disconnected. {status=}, {state=}") - self.close_gatt() - Clock.schedule_once(callback=lambda dt: self.reconnect(), timeout=15) - - def on_rssi_updated(self, rssi: int, status: int): - Logger.info(f"Device: {self.title} RSSI: {rssi}") - - def periodically_update_rssi(self): - """ - Clock callback to read - the signal strength indicator for a connected device. - """ - if self.gatt: # if device is connected - self.update_rssi() - - def reconnect(self): - Logger.info(f"Device: {self.title} try to reconnect ...") - self.connect_gatt(self._device) - - def start(self): - """Start connection to device.""" - if not self.gatt: - self.connect_gatt(self._device) - Clock.schedule_interval( - callback=lambda dt: self.periodically_update_rssi(), timeout=5 - ) - - -class ScannerDispatcher(BluetoothDispatcher): - """Dispatcher to control the scanning process.""" - - def __init__(self): - super().__init__() - # Stores connected devices addresses - self._devices: dict[str, DeviceDispatcher] = {} - - def on_scan_started(self, success: bool): - if success: - Logger.info("Scan: started") - else: - Logger.error("Scan: error on start") - - def on_scan_completed(self): - Logger.info("Scan: completed") - - def on_device(self, device, rssi, advertisement): - address = device.getAddress() - if address not in self._devices: - # Create dispatcher instance for a new device - dispatcher = DeviceDispatcher(device) - # Remember address, - # to avoid multiple dispatchers creation for this device - self._devices[address] = dispatcher - Logger.info(f"Scan: device <{address}> added") - dispatcher.start() - - -class MultiDevicesApp(App): - def build(self): - ScannerDispatcher().start_scan(filters=[DeviceNameFilter("KivyBLETest")]) - return Label(text=self.name) - - -if __name__ == "__main__": - MultiDevicesApp().run() diff --git a/libs/able/examples/service_advertise/buildozer.spec b/libs/able/examples/service_advertise/buildozer.spec deleted file mode 100644 index a8340f3..0000000 --- a/libs/able/examples/service_advertise/buildozer.spec +++ /dev/null @@ -1,27 +0,0 @@ -[app] -title = BLE advertising service -version = 1.1 -package.name = advservice -package.domain = test.able -source.dir = . -source.include_exts = py,png,jpg,kv,atlas - -android.permissions = - FOREGROUND_SERVICE, - BLUETOOTH, - BLUETOOTH_ADMIN, - BLUETOOTH_CONNECT, - BLUETOOTH_ADVERTISE - -requirements = kivy==2.1.0,python3,able_recipe -services = Able:service.py:foreground - -android.accept_sdk_license = True - -# android.api = 31 -# android.minapi = 31 - -[buildozer] -warn_on_root = 1 -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 2 diff --git a/libs/able/examples/service_advertise/main.py b/libs/able/examples/service_advertise/main.py deleted file mode 100644 index 446158d..0000000 --- a/libs/able/examples/service_advertise/main.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Start advertising service.""" -from able import BluetoothDispatcher, Permission, require_bluetooth_enabled -from jnius import autoclass -from kivy.app import App -from kivy.lang import Builder - - -kv = """ -BoxLayout: - Button: - text: 'Start service' - on_press: app.ble_dispatcher.start_service() - Button: - text: 'Stop service' - on_press: app.ble_dispatcher.stop_service() -""" - - -class Dispatcher(BluetoothDispatcher): - @property - def service(self): - return autoclass("test.able.advservice.ServiceAble") - - @property - def activity(self): - return autoclass("org.kivy.android.PythonActivity").mActivity - - # Need to turn on the adapter, before service is started - @require_bluetooth_enabled - def start_service(self): - self.service.start( - self.activity, - # Pass UUID to advertise - "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", - ) - App.get_running_app().stop() # Can close the app, service will continue running - - def stop_service(self): - self.service.stop(self.activity) - - -class ServiceApp(App): - def build(self): - self.ble_dispatcher = Dispatcher( - # This app does not use device scanning, - # so the list of required permissions can be reduced - runtime_permissions=[ - Permission.BLUETOOTH_CONNECT, - Permission.BLUETOOTH_ADVERTISE, - ] - ) - return Builder.load_string(kv) - - -if __name__ == "__main__": - ServiceApp().run() diff --git a/libs/able/examples/service_advertise/service.py b/libs/able/examples/service_advertise/service.py deleted file mode 100644 index 69edc81..0000000 --- a/libs/able/examples/service_advertise/service.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Service to advertise data, while not stopped.""" -import time -from os import environ - -from able import BluetoothDispatcher -from able.advertising import ( - Advertiser, - AdvertiseData, - ServiceUUID, -) - - -def main(): - uuid = environ.get( - "PYTHON_SERVICE_ARGUMENT", - "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - ) - advertiser = Advertiser( - ble=BluetoothDispatcher(), - data=AdvertiseData(ServiceUUID(uuid)), - ) - advertiser.start() - while True: - time.sleep(0xDEAD) - - -if __name__ == "__main__": - main() diff --git a/libs/able/examples/service_scan/buildozer.spec b/libs/able/examples/service_scan/buildozer.spec deleted file mode 100644 index 54407b3..0000000 --- a/libs/able/examples/service_scan/buildozer.spec +++ /dev/null @@ -1,29 +0,0 @@ -[app] -title = BLE scan dev service -version = 1.1 -package.name = scanservice -package.domain = test.able -source.dir = . -source.include_exts = py,png,jpg,kv,atlas - -android.permissions = - FOREGROUND_SERVICE, - BLUETOOTH, - BLUETOOTH_ADMIN, - BLUETOOTH_SCAN, - BLUETOOTH_CONNECT, - BLUETOOTH_ADVERTISE, - ACCESS_FINE_LOCATION - -requirements = kivy==2.1.0,python3,able_recipe -services = Able:service.py:foreground - -android.accept_sdk_license = True - -# android.api = 31 -# android.minapi = 31 - -[buildozer] -warn_on_root = 1 -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 2 diff --git a/libs/able/examples/service_scan/main.py b/libs/able/examples/service_scan/main.py deleted file mode 100644 index ce1f8a2..0000000 --- a/libs/able/examples/service_scan/main.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Start BLE devices scaning service.""" -from able import ( - BluetoothDispatcher, - require_bluetooth_enabled, -) -from jnius import autoclass -from kivy.app import App -from kivy.lang import Builder - - -kv = """ -BoxLayout: - Button: - text: 'Start service' - on_press: app.ble_dispatcher.start_service() - Button: - text: 'Stop service' - on_press: app.ble_dispatcher.stop_service() -""" - - -class Dispatcher(BluetoothDispatcher): - @property - def service(self): - return autoclass("test.able.scanservice.ServiceAble") - - @property - def activity(self): - return autoclass("org.kivy.android.PythonActivity").mActivity - - # Need to turn on the adapter and obtain permissions, before service is started - @require_bluetooth_enabled - def start_service(self): - self.service.start(self.activity, "") - App.get_running_app().stop() # Can close the app, service will continue to run - - def stop_service(self): - self.service.stop(self.activity) - - -class ServiceApp(App): - def build(self): - self.ble_dispatcher = Dispatcher() - return Builder.load_string(kv) - - -if __name__ == "__main__": - ServiceApp().run() diff --git a/libs/able/examples/service_scan/service.py b/libs/able/examples/service_scan/service.py deleted file mode 100644 index 74e393e..0000000 --- a/libs/able/examples/service_scan/service.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Service to run BLE scan for 60 seconds, -and log each `on_device` event. -""" -import time - -from able import BluetoothDispatcher -from kivy.logger import Logger - - -class BLE(BluetoothDispatcher): - def on_device(self, device, rssi, advertisement): - title = device.getName() or device.getAddress() - Logger.info("BLE Device found: %s", title) - - def on_error(self, msg): - Logger.error("BLE Error %s", msg) - - -def main(): - ble = BLE() - ble.start_scan() - time.sleep(60) - ble.stop_scan() - - -if __name__ == "__main__": - main() diff --git a/libs/able/recipes/__init__.py b/libs/able/recipes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/libs/able/recipes/able_recipe/__init__.py b/libs/able/recipes/able_recipe/__init__.py deleted file mode 100644 index ac4d546..0000000 --- a/libs/able/recipes/able_recipe/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Android Bluetooth Low Energy -""" -from pythonforandroid.recipe import PythonRecipe -from pythonforandroid.toolchain import current_directory, info, shprint -import sh -from os.path import join - - -class AbleRecipe(PythonRecipe): - name = 'able_recipe' - depends = ['python3', 'setuptools', 'android'] - call_hostpython_via_targetpython = False - install_in_hostpython = True - - def prepare_build_dir(self, arch): - build_dir = self.get_build_dir(arch) - assert build_dir.endswith(self.name) - shprint(sh.rm, '-rf', build_dir) - shprint(sh.mkdir, build_dir) - - for filename in ('../../able', 'setup.py'): - shprint(sh.cp, '-a', join(self.get_recipe_dir(), filename), - build_dir) - - def postbuild_arch(self, arch): - super(AbleRecipe, self).postbuild_arch(arch) - info('Copying able java class to classes build dir') - with current_directory(self.get_build_dir(arch.arch)): - shprint(sh.cp, '-a', join('able', 'src', 'org'), - self.ctx.javaclass_dir) - - -recipe = AbleRecipe() diff --git a/libs/able/recipes/able_recipe/setup.py b/libs/able/recipes/able_recipe/setup.py deleted file mode 100644 index c2ca462..0000000 --- a/libs/able/recipes/able_recipe/setup.py +++ /dev/null @@ -1,9 +0,0 @@ -from setuptools import setup - -setup( - name='able', - version='0.0.0', - packages=['able', 'able.android'], - description='Bluetooth Low Energy for Android', - license='MIT', -) diff --git a/libs/able/setup.py b/libs/able/setup.py deleted file mode 100644 index 9ed545b..0000000 --- a/libs/able/setup.py +++ /dev/null @@ -1,135 +0,0 @@ -import os -import re -import shutil -from pathlib import Path -from setuptools import setup -from setuptools.command.install import install - - -main_ns = {} -with Path("able/version.py").open() as ver_file: - exec(ver_file.read(), main_ns) - -with Path("README.rst").open() as readme_file: - long_description = readme_file.read() - - -class PathParser: - @property - def javaclass_dir(self): - path = self.build_dir / "javaclasses" - if not path.exists(): - raise Exception( - "Java classes directory is not found. " - "Please report issue to: https://github.com/b3b/able/issues" - ) - path = path / self.distribution_name - print(f"Java classes directory found: '{path}'.") - path.mkdir(parents=True, exist_ok=True) - return path - - @property - def distribution_name(self): - path = self.python_path - while path.parent.name != "python-installs": - if len(path.parts) <= 1: - raise Exception( - "Distribution name is not found. " - "Please report issue to: https://github.com/b3b/able/issues" - ) - path = path.parent - print(f"Distribution name found: '{path.name}'.") - return path.name - - @property - def build_dir(self): - return self.python_installs_dir.parent - - @property - def python_installs_dir(self): - path = self.python_path.parent - while path.name != "python-installs": - if len(path.parts) <= 1: - raise Exception( - "Python installs directory is not found. " - "Please report issue to: https://github.com/b3b/able/issues" - ) - path = path.parent - return path - - @property - def python_path(self): - cppflags = os.environ["CPPFLAGS"] - print(f"Searching for Python install directory in CPPFLAGS: '{cppflags}'") - match = re.search(r"-I(/[^\s]+/build/python-installs/[^/\s]+/)", cppflags) - if not match: - raise Exception("Can't find Python install directory.") - found_path = Path(match.group(1)) - print("FOUND INSTALL DIRECTORY: "+found_path) - return found_path - - -class InstallRecipe(install): - """Command to install `able` recipe, - copies Java files to distribution `javaclass` directory.""" - - def run(self): - if False and "ANDROIDAPI" not in os.environ: - raise Exception( - "This recipe should not be installed directly, " - "only with the buildozer tool." - ) - - # Find Java classes target directory from the environment - javaclass_dir = str(PathParser().javaclass_dir) - - for java_file in ( - "able/src/org/able/BLE.java", - "able/src/org/able/BLEAdvertiser.java", - "able/src/org/able/PythonBluetooth.java", - "able/src/org/able/PythonBluetoothAdvertiser.java", - ): - shutil.copy(java_file, javaclass_dir) - - install.run(self) - - -setup( - name="able_recipe", - version=main_ns["__version__"], - packages=["able", "able.android"], - description="Bluetooth Low Energy for Android", - long_description=long_description, - long_description_content_type="text/x-rst", - author="b3b", - author_email="ash.b3b@gmail.com", - install_requires=[], - url="https://github.com/b3b/able", - project_urls={ - "Changelog": "https://github.com/b3b/able/blob/master/CHANGELOG.rst", - }, - # https://pypi.org/classifiers/ - classifiers=[ - "Development Status :: 3 - Alpha", - "License :: OSI Approved :: MIT License", - "Operating System :: Android", - "Topic :: System :: Networking", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], - keywords="android ble bluetooth kivy", - license="MIT", - zip_safe=False, - cmdclass={ - "install": InstallRecipe, - }, - options={ - "bdist_wheel": { - # Changing the wheel name - # to avoid installing a package from cache. - "plat_name": "unused-nocache", - }, - }, -) diff --git a/libs/able/testapps/bletest/.gitignore b/libs/able/testapps/bletest/.gitignore deleted file mode 100644 index 254defd..0000000 --- a/libs/able/testapps/bletest/.gitignore +++ /dev/null @@ -1 +0,0 @@ -server diff --git a/libs/able/testapps/bletest/bletestapp.kv b/libs/able/testapps/bletest/bletestapp.kv deleted file mode 100644 index bb9cab1..0000000 --- a/libs/able/testapps/bletest/bletestapp.kv +++ /dev/null @@ -1,193 +0,0 @@ -#:kivy 1.1.0 -#: import Factory kivy.factory.Factory -#: import findall re.findall - -: - padding_left: '4sp' - halign: 'left' - text_size: self.size - valign: 'middle' - -: - padding_left: '4sp' - halign: 'left' - text_size: self.size - valign: 'middle' - -: - title: 'Connect by MAC address' - size_hint: None, None - size: '400sp', '120sp' - BoxLayout: - orientation: 'vertical' - pos: self.pos - size: root.size - - TextInput: - size_hint_y: .5 - hint_text: 'Device address' - input_filter: lambda value, _ : ''.join(findall('[0-9a-fA-F:]+', value)).upper() - multiline: False - text: app.device_address - on_text: app.device_address = self.text - - BoxLayout: - orientation: 'horizontal' - size_hint_y: .5 - Button: - text: 'Connect' - on_press: root.dismiss(), app.connect_by_mac_address() - Button: - text: 'Cancel' - on_press: root.dismiss() - -: - padding: '10sp' - BoxLayout: - orientation: 'horizontal' - GridLayout: - cols: 2 - padding: '0sp' - spacing: '0sp' - orientation: 'lr-tb' - - Caption: - text: 'Adapter:' - Value: - text: app.adapter_state - - Caption: - text: 'State:' - Value: - text: app.state - halign: 'left' - valign: 'middle' - text_size: self.size - - Caption: - text: 'Read test:' - Value: - text: app.test_string - - Caption: - text: 'Notifications count:' - Value: - text: app.notification_value - - Caption: - text: 'N packets sended:' - Value: - text: app.increment_count_value - - Caption: - text: 'N packets delivered:' - Value: - text: app.counter_value - - Caption: - text: 'Total transmission time:' - Value: - text: app.counter_total_time - - BoxLayout: - spacing: '20sp' - orientation: 'vertical' - - BoxLayout: - orientation: 'horizontal' - size_hint_y: .3 - - Button: - text: 'Scan and connect' - on_press: app.start_scan() - - Button: - text: 'Connect by MAC address' - on_press: Factory.ConnectByMACDialog().open() - - BoxLayout: - id: queue_box - orientation: 'vertical' - size_hint_y: .15 - - BoxLayout: - orientation: 'horizontal' - Caption: - text: 'Enable GATT autoconnect:' - CheckBox: - id: timeout_checkbox - active: app.autoconnect - on_active: app.autoconnect = self.active - - BoxLayout: - orientation: 'horizontal' - size_hint_y: .2 - spacing: 10 - Button: - disabled: app.state != 'connected' - text: 'Read RSSI' - on_press: app.read_rssi() - Caption: - text: 'RSSI Value:' - Value: - text: app.rssi - - ToggleButton: - disabled: app.state != 'connected' - text: "Enable notifications" - size_hint_y: .2 - on_state: app.enable_notifications(self.state == 'down') - BoxLayout: - id: queue_box - orientation: 'vertical' - disabled: app.state != 'connected' - - BoxLayout: - orientation: 'horizontal' - Caption: - text: 'Enable BLE queue timeout:' - CheckBox: - id: timeout_checkbox - active: app.queue_timeout_enabled - on_active: app.queue_timeout_enabled = self.active - BoxLayout: - orientation: 'horizontal' - Caption: - text: 'BLE queue timeout (ms):' - TextInput: - disabled: queue_box.disabled or not timeout_checkbox.active - input_filter: 'int' - multiline: False - text: app.queue_timeout - on_text: app.queue_timeout = self.text - BoxLayout: - Button: - text: 'Apply queue settings' - on_press: app.set_queue_settings() - - BoxLayout: - disabled: app.state != 'connected' - orientation: 'vertical' - BoxLayout: - orientation: 'horizontal' - Caption: - text: 'Transmission interval (ms):' - TextInput: - input_filter: 'int' - multiline: False - text: app.incremental_interval - on_text: app.incremental_interval = self.text - BoxLayout: - orientation: 'horizontal' - Caption: - text: 'Packet count limit:' - TextInput: - input_filter: 'int' - multiline: False - text: app.counter_max - on_text: app.counter_max = self.text - padding_bottom: '100sp' - ToggleButton: - width: self.texture_size[0] + 50 - text: "Enable transmission" - on_state: app.enable_counter(self.state == 'down') diff --git a/libs/able/testapps/bletest/buildozer.spec b/libs/able/testapps/bletest/buildozer.spec deleted file mode 100644 index 102e39a..0000000 --- a/libs/able/testapps/bletest/buildozer.spec +++ /dev/null @@ -1,17 +0,0 @@ -[app] -title = BLE functions test -version = 1.0 -package.name = kivy_ble_test -package.domain = org.kivy -source.dir = . -source.include_exts = py,png,jpg,kv,atlas -android.permissions = BLUETOOTH, BLUETOOTH_ADMIN, ACCESS_FINE_LOCATION -requirements = python3,kivy,android,able_recipe - -# (str) Android's logcat filters to use -android.logcat_filters = *:S python:D - -[buildozer] -warn_on_root = 1 -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 2 diff --git a/libs/able/testapps/bletest/main.py b/libs/able/testapps/bletest/main.py deleted file mode 100644 index 9d27429..0000000 --- a/libs/able/testapps/bletest/main.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Connect to "KivyBLETest" server and test various BLE functions -""" -import time - -from able import AdapterState, GATT_SUCCESS, BluetoothDispatcher -from kivy.app import App -from kivy.clock import Clock -from kivy.config import Config -from kivy.properties import BooleanProperty, StringProperty -from kivy.uix.boxlayout import BoxLayout -from kivy.storage.jsonstore import JsonStore - -Config.set('kivy', 'log_level', 'debug') -Config.set('kivy', 'log_enable', '1') - - -class MainLayout(BoxLayout): - pass - - -class BLETestApp(App): - ble = BluetoothDispatcher() - adapter_state = StringProperty('') - state = StringProperty('') - test_string = StringProperty('') - rssi = StringProperty('') - notification_value = StringProperty('') - counter_value = StringProperty('') - increment_count_value = StringProperty('') - incremental_interval = StringProperty('100') - counter_max = StringProperty('128') - counter_value = StringProperty('') - counter_state = StringProperty('') - counter_total_time = StringProperty('') - queue_timeout_enabled = BooleanProperty(True) - queue_timeout = StringProperty('1000') - device_name = StringProperty('KivyBLETest') - device_address = StringProperty('') - autoconnect = BooleanProperty(False) - - store = JsonStore('bletestapp.json') - - uids = { - 'string': '0d01', - 'counter_reset': '0d02', - 'counter_increment': '0d03', - 'counter_read': '0d04', - 'notifications': '0d05' - } - - def build(self): - if self.store.exists('device'): - self.device_address = self.store.get('device')['address'] - else: - self.device_address = '' - return MainLayout() - - def on_pause(self): - return True - - def on_resume(self): - pass - - def init(self): - self.set_queue_settings() - self.ble.bind(on_device=self.on_device) - self.ble.bind(on_scan_started=self.on_scan_started) - self.ble.bind(on_scan_completed=self.on_scan_completed) - self.ble.bind(on_bluetooth_adapter_state_change=self.on_bluetooth_adapter_state_change) - self.ble.bind( - on_connection_state_change=self.on_connection_state_change) - self.ble.bind(on_services=self.on_services) - self.ble.bind(on_characteristic_read=self.on_characteristic_read) - self.ble.bind(on_characteristic_changed=self.on_characteristic_changed) - self.ble.bind(on_rssi_updated=self.on_rssi_updated) - - def start_scan(self): - if not self.state: - self.init() - self.state = 'scan_start' - self.ble.close_gatt() - self.ble.start_scan() - - def connect_by_mac_address(self): - self.store.put('device', address=self.device_address) - if not self.state: - self.init() - self.state = 'try_connect' - self.ble.close_gatt() - try: - self.ble.connect_by_device_address( - self.device_address, - autoconnect=self.autoconnect, - ) - except ValueError as exc: - self.state = str(exc) - - def on_scan_started(self, ble, success): - self.state = 'scan' if success else 'scan_error' - - def on_device(self, ble, device, rssi, advertisement): - if self.state != 'scan': - return - if device.getName() == self.device_name: - self.device = device - self.state = 'found' - self.ble.stop_scan() - - def on_scan_completed(self, ble): - if self.device: - self.ble.connect_gatt( - self.device, - autoconnect=self.autoconnect, - ) - - def on_connection_state_change(self, ble, status, state): - if status == GATT_SUCCESS: - if state: - self.ble.discover_services() - else: - self.state = 'disconnected' - else: - self.state = 'connection_error' - - def on_services(self, ble, status, services): - if status != GATT_SUCCESS: - self.state = 'services_error' - return - self.state = 'connected' - self.services = services - self.read_test_string(ble) - self.characteristics = { - 'counter_increment': self.services.search( - self.uids['counter_increment']), - 'counter_reset': self.services.search( - self.uids['counter_reset']), - } - - def on_bluetooth_adapter_state_change(self, ble, state): - self.adapter_state = AdapterState(state).name - - def read_rssi(self): - self.rssi = '...' - result = self.ble.update_rssi() - - def on_rssi_updated(self, ble, rssi, status): - self.rssi = str(rssi) if status == GATT_SUCCESS else f"Bad status: {status}" - - def read_test_string(self, ble): - characteristic = self.services.search(self.uids['string']) - if characteristic: - ble.read_characteristic(characteristic) - else: - self.test_string = 'not found' - - def read_remote_counter(self): - characteristic = self.services.search(self.uids['counter_read']) - if characteristic: - self.ble.read_characteristic(characteristic) - else: - self.counter_value = 'error' - - def enable_notifications(self, enable): - if enable: - self.notification_value = '0' - characteristic = self.services.search(self.uids['notifications']) - if characteristic: - self.ble.enable_notifications(characteristic, enable) - else: - self.notification_value = 'error' - - def enable_counter(self, enable): - if enable: - self.counter_state = 'init' - interval = int(self.incremental_interval) * .001 - Clock.schedule_interval(self.counter_next, interval) - else: - Clock.unschedule(self.counter_next) - if self.counter_state != 'stop': - self.counter_state = 'stop' - self.read_remote_counter() - - def counter_next(self, dt): - if self.counter_state == 'init': - self.counter_started_time = time.time() - self.counter_total_time = '' - self.reset_remote_counter() - self.increment_remote_counter() - elif self.counter_state == 'enabled': - if int(self.increment_count_value) < int(self.counter_max): - self.increment_remote_counter() - else: - self.enable_counter(False) - - def reset_remote_counter(self): - self.increment_count_value = '0' - self.counter_value = '' - self.ble.write_characteristic(self.characteristics['counter_reset'], []) - self.counter_state = 'enabled' - - def on_characteristic_read(self, ble, characteristic, status): - uuid = characteristic.getUuid().toString() - if self.uids['string'] in uuid: - self.update_string_value(characteristic, status) - elif self.uids['counter_read'] in uuid: - self.counter_total_time = str( - time.time() - self.counter_started_time) - self.update_counter_value(characteristic, status) - - def update_string_value(self, characteristic, status): - result = 'ERROR' - if status == GATT_SUCCESS: - value = characteristic.getStringValue(0) - if value == 'test': - result = 'OK' - self.test_string = result - - def increment_remote_counter(self): - characteristic = self.characteristics['counter_increment'] - self.ble.write_characteristic(characteristic, []) - prev_value = int(self.increment_count_value) - self.increment_count_value = str(prev_value + 1) - - def update_counter_value(self, characteristic, status): - if status == GATT_SUCCESS: - self.counter_value = characteristic.getStringValue(0) - else: - self.counter_value = 'ERROR' - - def set_queue_settings(self): - self.ble.set_queue_timeout(None if not self.queue_timeout_enabled - else int(self.queue_timeout) * .001) - - def on_characteristic_changed(self, ble, characteristic): - uuid = characteristic.getUuid().toString() - if self.uids['notifications'] in uuid: - prev_value = self.notification_value - value = int(characteristic.getStringValue(0)) - if (prev_value == 'error') or (value != int(prev_value) + 1): - value = 'error' - self.notification_value = str(value) - - -if __name__ == '__main__': - BLETestApp().run() diff --git a/libs/able/testapps/bletest/server.go b/libs/able/testapps/bletest/server.go deleted file mode 100644 index fcb9ced..0000000 --- a/libs/able/testapps/bletest/server.go +++ /dev/null @@ -1,95 +0,0 @@ -// +build - -// based on https://github.com/paypal/gatt/blob/master/examples/server.go - -package main - -import ( - "fmt" - "log" - "time" - "github.com/paypal/gatt" - "github.com/paypal/gatt/linux/cmd" -) - - -var DefaultServerOptions = []gatt.Option{ - gatt.LnxMaxConnections(1), - gatt.LnxDeviceID(-1, false), - gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{ - AdvertisingIntervalMin: 0x04ff, - AdvertisingIntervalMax: 0x04ff, - AdvertisingChannelMap: 0x7, - }), -} - - -func NewTestPythonService() *gatt.Service { - n := 0 - s := gatt.NewService(gatt.MustParseUUID("16fe0d00-c111-11e3-b8c8-0002a5d5c51b")) - - s.AddCharacteristic(gatt.MustParseUUID("16fe0d01-c111-11e3-b8c8-0002a5d5c51b")).HandleReadFunc( - func(rsp gatt.ResponseWriter, req *gatt.ReadRequest) { - n = 0 - log.Println("Echo") - fmt.Fprintf(rsp, "test") - }) - s.AddCharacteristic(gatt.MustParseUUID("16fe0d02-c111-11e3-b8c8-0002a5d5c51b")).HandleWriteFunc( - func(r gatt.Request, data []byte) (status byte) { - n = 0 - log.Println("Reset counter") - return gatt.StatusSuccess - }) - s.AddCharacteristic(gatt.MustParseUUID("16fe0d03-c111-11e3-b8c8-0002a5d5c51b")).HandleWriteFunc( - func(r gatt.Request, data []byte) (status byte) { - n++ - log.Println("Increment counter") - return gatt.StatusSuccess - }) - s.AddCharacteristic(gatt.MustParseUUID("16fe0d04-c111-11e3-b8c8-0002a5d5c51b")).HandleReadFunc( - func(rsp gatt.ResponseWriter, req *gatt.ReadRequest) { - log.Println("Response counter: ", n) - fmt.Fprintf(rsp, "%d", n) - }) - - s.AddCharacteristic(gatt.MustParseUUID("16fe0d05-c111-11e3-b8c8-0002a5d5c51b")).HandleNotifyFunc( - func(r gatt.Request, n gatt.Notifier) { - log.Println("Notifications enabled") - cnt := 1 - for !n.Done() { - fmt.Fprintf(n, "%d", cnt) - cnt++ - time.Sleep(100 * time.Millisecond) - } - log.Println("Notifications disabled") - }) - - return s -} - - -func main() { - d, err := gatt.NewDevice(DefaultServerOptions...) - if err != nil { - log.Fatalf("Failed to open device, err: %s", err) - } - - d.Handle( - gatt.CentralConnected(func(c gatt.Central) { fmt.Println("Connect: ", c.ID()) }), - gatt.CentralDisconnected(func(c gatt.Central) { fmt.Println("Disconnect: ", c.ID()) }), - ) - - onStateChanged := func(d gatt.Device, s gatt.State) { - fmt.Printf("State: %s\n", s) - switch s { - case gatt.StatePoweredOn: - s1 := NewTestPythonService() - d.AddService(s1) - d.AdvertiseNameAndServices("KivyBLETest", []gatt.UUID{s1.UUID()}) - default: - } - } - - d.Init(onStateChanged) - select {} -} diff --git a/libs/able/tests/__init__.py b/libs/able/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/libs/able/tests/notebooks/.gitignore b/libs/able/tests/notebooks/.gitignore deleted file mode 100644 index 51b09fe..0000000 --- a/libs/able/tests/notebooks/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -there.env -.ipynb_checkpoints/ -*.asciidoc -*.ipynb diff --git a/libs/able/tests/notebooks/init.md b/libs/able/tests/notebooks/init.md deleted file mode 100644 index d25939a..0000000 --- a/libs/able/tests/notebooks/init.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -jupyter: - jupytext: - formats: ipynb,md - text_representation: - extension: .md - format_name: markdown - format_version: '1.3' - jupytext_version: 1.11.2 - kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```python -from time import sleep -%load_ext pythonhere -%connect-there -``` - -```python -%%there -from dataclasses import dataclass, field -from typing import List - -from jnius import autoclass, cast - -from able.android.dispatcher import ( - BluetoothDispatcher -) - -from able.scan_settings import ( - ScanSettings, - ScanSettingsBuilder -) - -@dataclass -class Results: - started: bool = None - completed: bool = None - devices: List = field(default_factory=lambda: []) -``` diff --git a/libs/able/tests/notebooks/run b/libs/able/tests/notebooks/run deleted file mode 100755 index 19d701a..0000000 --- a/libs/able/tests/notebooks/run +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -set -ex - -name="$1" -command="${2}" -command="${command:=test}" - - -cat "${name}.md" | - jupytext --execute --to ipynb | - jupyter nbconvert --stdin --no-input --to asciidoc --output "${name}" - -cat "${name}".asciidoc - -if [ "${command}" = "test" ]; then - diff "${name}.asciidoc" "${name}.expected" -elif [ "${command}" = "record" ]; then - cp "${name}".asciidoc "${name}".expected -else - echo "Unknown command: ${command}" - exit 1 -fi diff --git a/libs/able/tests/notebooks/run_all_tests b/libs/able/tests/notebooks/run_all_tests deleted file mode 100755 index 7906d87..0000000 --- a/libs/able/tests/notebooks/run_all_tests +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -e - -for name in test_*.md; do ./run "${name%%.*}"; done diff --git a/libs/able/tests/notebooks/test_basic.expected b/libs/able/tests/notebooks/test_basic.expected deleted file mode 100644 index bd4bd66..0000000 --- a/libs/able/tests/notebooks/test_basic.expected +++ /dev/null @@ -1,26 +0,0 @@ -[[setup]] -= Setup - -[[run-ble-devices-scan]] -= Run BLE devices scan - - ----- -Started: None Completed: None ----- - -[[check-that-scan-started-and-completed]] -= Check that scan started and completed - - ----- -Started: 1 Completed: 1 ----- - -[[check-that-testing-device-was-discovered]] -= Check that testing device was discovered - - ----- -True ----- diff --git a/libs/able/tests/notebooks/test_basic.md b/libs/able/tests/notebooks/test_basic.md deleted file mode 100644 index 4e16ed9..0000000 --- a/libs/able/tests/notebooks/test_basic.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -jupyter: - jupytext: - formats: ipynb,md - text_representation: - extension: .md - format_name: markdown - format_version: '1.3' - jupytext_version: 1.11.2 - kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -# Setup - -```python -%run init.ipynb -``` - -```python -%%there -class BLE(BluetoothDispatcher): - - def on_scan_started(self, success): - results.started = success - - def on_scan_completed(self): - results.completed = 1 - - def on_device(self, device, rssi, advertisement): - results.devices.append(device) - -ble = BLE() -``` - -# Run BLE devices scan - -```python -%%there -results = Results() -print(f"Started: {results.started} Completed: {results.completed}") -ble.start_scan() -``` - -```python -sleep(10) -``` - -```python -%%there -ble.stop_scan() -``` - -```python -sleep(2) -``` - -# Check that scan started and completed - -```python -%%there -print(f"Started: {results.started} Completed: {results.completed}") -``` - -# Check that testing device was discovered - -```python -%%there -print( - "KivyBLETest" in [dev.getName() for dev in results.devices] -) -``` diff --git a/libs/able/tests/notebooks/test_scan_filters.expected b/libs/able/tests/notebooks/test_scan_filters.expected deleted file mode 100644 index 165be43..0000000 --- a/libs/able/tests/notebooks/test_scan_filters.expected +++ /dev/null @@ -1,72 +0,0 @@ -[[setup]] -= Setup - -[[test-device-is-found-with-scan-filters-set]] -= Test device is found with scan filters set - - ----- -{'KivyBLETest'} ----- - -[[test-device-is-not-found-filtered-out-by-name]] -= Test device is not found: filtered out by name - - ----- -[] ----- - -[[test-scan-filter-mathes]] -= Test scan filter mathes - - ----- -EmptyFilter() True -EmptyFilter() True -EmptyFilter() True ----- - - ----- -DeviceAddressFilter(address='AA:AA:AA:AA:AA:AA') True -DeviceAddressFilter(address='AA:AA:AA:AA:AA:AB') False -AA is not a valid Bluetooth address ----- - - ----- -DeviceNameFilter(name='KivyBLETest') True -DeviceNameFilter(name='KivyBLETes') False ----- - - ----- -ManufacturerDataFilter(id=76, data=[], mask=None) False -ManufacturerDataFilter(id=76, data=[], mask=None) True -ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 229], mask=None) True -ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=None) False -ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=[255, 255, 255, 255, 255, 255, 0]) True -ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=[255, 255, 255, 255, 255, 255, 255]) False -ManufacturerDataFilter(id=76, data=[2, 0, 141, 166, 131], mask=[255, 0, 255, 255, 255]) True -ManufacturerDataFilter(id=76, data=b'\x02\x15', mask=None) True -ManufacturerDataFilter(id=76, data=b'\x02\x16', mask=None) False ----- - - ----- -ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[], mask=None) True -ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fc', data=[], mask=None) False -ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[34], mask=None) True -ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=None) False -ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=[240]) True -ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=[15]) False ----- - - ----- -ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51B', mask=None) True -ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask=None) False -ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask='ffffffff-ffff-ffff-ffff-ffffffffffff') False -ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask='ffffffff-ffff-ffff-ffff-fffffffffff0') True ----- diff --git a/libs/able/tests/notebooks/test_scan_filters.md b/libs/able/tests/notebooks/test_scan_filters.md deleted file mode 100644 index 177a6c0..0000000 --- a/libs/able/tests/notebooks/test_scan_filters.md +++ /dev/null @@ -1,222 +0,0 @@ ---- -jupyter: - jupytext: - formats: ipynb,md - text_representation: - extension: .md - format_name: markdown - format_version: '1.3' - jupytext_version: 1.11.2 - kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -# Setup - -```python -%run init.ipynb -``` - -```python -%%there -from able.filters import * - -class BLE(BluetoothDispatcher): - - def on_scan_started(self, success): - results.started = success - - def on_scan_completed(self): - results.completed = 1 - - def on_device(self, device, rssi, advertisement): - results.devices.append(device) - -ble = BLE() -``` - -```python -%%there -BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice") -ScanResult = autoclass("android.bluetooth.le.ScanResult") -ScanRecord = autoclass("android.bluetooth.le.ScanRecord") -Parcel = autoclass("android/os/Parcel") -ParcelUuid = autoclass('android.os.ParcelUuid') - -def filter_matches(f, scan_result): - print(f, f.build().matches(scan_result)) - -def mock_device(): - """Return BluetoothDevice instance with address=AA:AA:AA:AA:AA:AA""" - device_data = [17, 0, 0, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65] - p = Parcel.obtain() - p.unmarshall(device_data, 0, len(device_data)) - p.setDataPosition(0) - return BluetoothDevice.CREATOR.createFromParcel(p) - - -def mock_scan_result(record): - return ScanResult(mock_device(), ScanRecord.parseFromBytes(record), -33, 1633954394000) - -def mock_test_app_scan_result(): - return mock_scan_result( - [2, 1, 6, 17, 6, 27, 197, 213, 165, 2, 0, 200, 184, 227, 17, 17, 193, 0, 13, - 254, 22, 12, 9, 75, 105, 118, 121, 66, 76, 69, 84, 101, 115, 116, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - ] - ) - -def mock_beacon_scan_result(): - """0x4C, # Apple Manufacturer ID - bytes([ - 0x2, # SubType: Custom Manufacturer Data - 0x15 # Subtype lenth - ]) + - uuid + # UUID of beacon: UUID=8da683d6-e574-4a2e-bb9b-d83f2d05fc12 - bytes([ - 0, 15, # Major value - 0, 1, # Minor value - 10 # RSSI, dBm at 1m - ]) - """ - return mock_scan_result(bytes.fromhex('1AFF4C0002158DA683D6E5744A2EBB9BD83F2D05FC12000F00010A')) - -def mock_battery_scan_result(): - """Battery ("0000180f-0000-1000-8000-00805f9b34fb" or "180f" in short form) - service data: 34% (0x22) - """ - return mock_scan_result(bytes.fromhex('04160F1822')) - -beacon = mock_beacon_scan_result() -battery = mock_battery_scan_result() -testapp = mock_test_app_scan_result() -``` - -# Test device is found with scan filters set - -```python -%%there -results = Results() -ble.start_scan(filters=[ - DeviceNameFilter("KivyBLETest") & ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51b"), -]) -``` - -```python -sleep(10) -``` - -```python -%%there -ble.stop_scan() -``` - -```python -sleep(2) -``` - -```python -%%there -print(set([dev.getName() for dev in results.devices])) -``` - -# Test device is not found: filtered out by name - -```python -%%there -results = Results() -ble.start_scan(filters=[DeviceNameFilter("No-such-device-8458e2e35158")]) -``` - -```python -sleep(10) -``` - -```python -%%there -ble.stop_scan() -``` - -```python -sleep(2) -``` - -```python -%%there -print(results.devices) -``` - -# Test scan filter mathes - -```python -%%there -filter_matches(EmptyFilter(), testapp) -filter_matches(EmptyFilter(), beacon) -filter_matches(EmptyFilter(), battery) -``` - -```python -%%there -filter_matches(DeviceAddressFilter("AA:AA:AA:AA:AA:AA"), testapp) -filter_matches(DeviceAddressFilter("AA:AA:AA:AA:AA:AB"), testapp) -try: - filter_matches(DeviceAddressFilter("AA"), testapp) -except Exception as exc: - print(exc) -``` - -```python -%%there -filter_matches(DeviceNameFilter("KivyBLETest"), testapp) -filter_matches(DeviceNameFilter("KivyBLETes"), testapp) -``` - -```python -%%there -filter_matches(ManufacturerDataFilter(0x4c, []), testapp) -filter_matches(ManufacturerDataFilter(0x4c, []), beacon) -filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xe5]), beacon) -filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa]), beacon) -filter_matches( - ManufacturerDataFilter(0x4c, - [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa], - [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]), - beacon -) -filter_matches( - ManufacturerDataFilter(0x4c, - [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa], - [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), - beacon -) -filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0, 0x8d, 0xa6, 0x83], [0xff, 0, 0xff, 0xff, 0xff]), beacon) -filter_matches(ManufacturerDataFilter(0x4c, b'\x02\x15'), beacon) -filter_matches(ManufacturerDataFilter(0x4c, b'\x02\x16'), beacon) -``` - -```python -%%there -filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", []), battery) -filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fc", []), battery) - -filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x22]), battery) -filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21]), battery) -filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21], mask=[0xf0]), battery) -filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21], mask=[0x0f]), battery) -``` - -```python -%%there -filter_matches(ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51B"), testapp) -filter_matches(ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51C"), testapp) -filter_matches(ServiceUUIDFilter( - "16fe0d00-c111-11e3-b8c8-0002a5d5c51C", - "ffffffff-ffff-ffff-ffff-ffffffffffff" -), testapp) -filter_matches(ServiceUUIDFilter( - "16fe0d00-c111-11e3-b8c8-0002a5d5c51C", - "ffffffff-ffff-ffff-ffff-fffffffffff0" -), testapp) -``` diff --git a/libs/able/tests/notebooks/test_scan_settings.expected b/libs/able/tests/notebooks/test_scan_settings.expected deleted file mode 100644 index 8f3caef..0000000 --- a/libs/able/tests/notebooks/test_scan_settings.expected +++ /dev/null @@ -1,27 +0,0 @@ -[[setup]] -= Setup - -[[run-scan_mode_low_power]] -= Run SCAN_MODE_LOW_POWER - - ----- -True ----- - -[[run-scan_mode_low_latency]] -= Run SCAN_MODE_LOW_LATENCY - - ----- -True ----- - -[[check-that-received-advertisement-count-is-greater-with-scan_mode_low_latency]] -= Check that received advertisement count is greater with -SCAN_MODE_LOW_LATENCY - - ----- -True ----- diff --git a/libs/able/tests/notebooks/test_scan_settings.md b/libs/able/tests/notebooks/test_scan_settings.md deleted file mode 100644 index a231bb4..0000000 --- a/libs/able/tests/notebooks/test_scan_settings.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -jupyter: - jupytext: - formats: ipynb,md - text_representation: - extension: .md - format_name: markdown - format_version: '1.3' - jupytext_version: 1.11.2 - kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -# Setup - -```python -%run init.ipynb -``` - -```python -%%there -class BLE(BluetoothDispatcher): - - def on_scan_started(self, success): - results.started = success - - def on_scan_completed(self): - results.completed = 1 - - def on_device(self, device, rssi, advertisement): - results.devices.append(device) - -def get_advertisemnt_count(): - return len([dev for dev in results.devices if dev.getName() == "KivyBLETest"]) - -ble = BLE() -``` - -# Run SCAN_MODE_LOW_POWER - -```python -%%there -results = Results() -ble.start_scan( - settings=ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) -) -``` - -```python -sleep(20) -``` - -```python -%%there -ble.stop_scan() -``` - -```python -sleep(2) -``` - -```python -%%there -low_power_advertisement_count = get_advertisemnt_count() -print(low_power_advertisement_count > 0) -``` - -# Run SCAN_MODE_LOW_LATENCY - -```python -%%there -results = Results() - -ble.start_scan( - settings=ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) -) -``` - -```python -sleep(20) -``` - -```python -%%there -ble.stop_scan() -``` - -```python -sleep(2) -``` - -```python -%%there -low_latency_advertisement_count = get_advertisemnt_count() -print(low_latency_advertisement_count > 0) -``` - -# Check that received advertisement count is greater with SCAN_MODE_LOW_LATENCY - -```python -%%there -print(low_latency_advertisement_count - low_power_advertisement_count > 2) -``` diff --git a/libs/able/tests/test_adapter.py b/libs/able/tests/test_adapter.py deleted file mode 100644 index 7b0f8c0..0000000 --- a/libs/able/tests/test_adapter.py +++ /dev/null @@ -1,81 +0,0 @@ -import pytest - -from able.adapter import ( - AdapterManager, - require_bluetooth_enabled, - set_adapter_failure_rollback, -) -from able.android.dispatcher import BluetoothDispatcher - - -@pytest.fixture -def manager(mocker): - return AdapterManager(mocker.Mock(), ..., []) - - -def test_operation_executed(mocker, manager): - operation = mocker.Mock() - logger = mocker.patch("able.adapter.Logger") - - manager.execute(operation) - - operation.assert_called_once() - logger.exception.assert_not_called() - - -def test_operation_failed_as_expected(mocker, manager): - manager.check_permissions = mocker.Mock(return_value=False) - expected = Exception("expected") - operation = mocker.Mock(side_effect=expected) - logger = mocker.patch("able.adapter.Logger") - - manager.execute(operation) - operation.assert_not_called() - - manager.check_permissions = mocker.Mock(return_value=True) - manager.execute_operations() - - operation.assert_called_once() - logger.exception.assert_called_once_with(expected) - - -def test_operations_executed(mocker, manager): - operations = [mocker.Mock(), mocker.Mock()] - manager.operations = operations.copy() - manager.check_permissions = mocker.Mock(return_value=False) - - manager.execute_operations() - - # permissions not granted = > suspended - calls = [operation.call_count for operation in manager.operations] - assert calls == [0, 0] - assert manager.operations == operations - - # one more operation requested - manager.execute(next_operation := mocker.Mock()) - - assert [operation.call_count for operation in manager.operations] == [0, 0, 0] - assert manager.operations == operations + [next_operation] - - manager.check_permissions = mocker.Mock(return_value=True) - manager.execute_operations() - assert not manager.operations - assert [operation.call_count for operation in operations + [next_operation]] == [ - 1, - 1, - 1, - ] - - -def test_rollback_performed(mocker, manager): - handlers = [mocker.Mock(), mocker.Mock()] - operations = [mocker.Mock(), mocker.Mock()] - - manager.operations = operations.copy() - manager.rollback_handlers = handlers.copy() - manager.rollback() - - assert not manager.rollback_handlers - assert not manager.operations - assert [operation.call_count for operation in operations] == [0, 0] - assert [operation.call_count for operation in handlers] == [1, 1] diff --git a/libs/able/tests/test_ble_queue.py b/libs/able/tests/test_ble_queue.py deleted file mode 100644 index a6fcdc8..0000000 --- a/libs/able/tests/test_ble_queue.py +++ /dev/null @@ -1,34 +0,0 @@ -import unittest -import mock -from able.queue import ble_task - - -class TestBLETask(unittest.TestCase): - - def setUp(self): - self.queue = mock.Mock() - self.task_called = None - - @ble_task - def increment(self, a=1, b=0): - self.task_called = a + b - - def test_method_not_executed(self): - self.increment() - self.assertEqual(self.task_called, None) - - def test_task_enqued(self): - self.increment() - self.assertTrue(self.queue.enque.called) - - def test_task_default_arguments(self): - self.increment() - task = self.queue.enque.call_args[0][0] - task() - self.assertEqual(self.task_called, 1) - - def test_task_arguments_passed(self): - self.increment(200, 11) - task = self.queue.enque.call_args[0][0] - task() - self.assertEqual(self.task_called, 211) diff --git a/libs/able/tests/test_dispatcher.py b/libs/able/tests/test_dispatcher.py deleted file mode 100644 index 637c189..0000000 --- a/libs/able/tests/test_dispatcher.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from able.android.dispatcher import BluetoothDispatcher - - -@pytest.fixture -def ble(mocker): - mocker.patch("able.android.dispatcher.PythonBluetooth") - ble = BluetoothDispatcher() - ble._ble = mocker.Mock() - ble.on_scan_started = mocker.Mock() - return ble - - -def test_adapter_returned(mocker, ble): - manager = ble._adapter_manager - manager.check_permissions = mocker.Mock(return_value=False) - assert not ble.adapter - assert not ble.adapter - - manager.check_permissions = mocker.Mock(return_value=True) - assert ble.adapter - - -def test_start_scan_executed(ble): - manager = ble._adapter_manager - assert manager - - ble.start_scan() - ble._ble.startScan.assert_called_once() - - -def test_start_scan_failed_as_expected(mocker, ble): - manager = ble._adapter_manager - manager.check_permissions = mocker.Mock(return_value=False) - - ble.start_scan() - ble._ble.startScan.assert_not_called() - - assert len(manager.operations) == 1 - assert len(manager.rollback_handlers) == 1 - - manager.on_runtime_permissions(permissions=[...], grant_results=[False]) - - ble.on_scan_started.assert_called_once_with(success=False) - assert len(manager.operations) == 0 - assert len(manager.rollback_handlers) == 0 diff --git a/libs/able/tests/test_filters.py b/libs/able/tests/test_filters.py deleted file mode 100644 index bd2f446..0000000 --- a/libs/able/tests/test_filters.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest - -import able.filters as filters - - -@pytest.fixture -def java_builder(mocker): - instance = mocker.Mock() - mocker.patch("able.filters.ScanFilterBuilder", return_value=instance) - return instance - - -def test_filter_builded(java_builder): - filters.Filter().build() - assert java_builder.build.call_count == 1 - - -def test_builder_method_called(java_builder): - f = filters.DeviceNameFilter("test") - - f.build() - - assert java_builder.method_calls == [ - ("setDeviceName", ("test",)), - ("build", ) - ] - - -def test_filters_combined(java_builder): - f = filters.DeviceNameFilter("test") & ( - filters.DeviceAddressFilter("AA:AA:AA:AA:AA:AA") & - filters.ManufacturerDataFilter("test-id", [1, 2, 3]) - ) - - f.build() - - assert java_builder.method_calls == [ - ("setDeviceName", ("test",)), - ("setDeviceAddress", ("AA:AA:AA:AA:AA:AA",)), - ("setManufacturerData", ("test-id", [1, 2, 3])), - ("build", ) - ] - - -def test_combine_same_type_exception(java_builder): - with pytest.raises(ValueError, match="cannot combine filters of the same type"): - f = filters.DeviceNameFilter("test") & ( - filters.DeviceAddressFilter("AA:AA:AA:AA:AA:AA") & - filters.DeviceNameFilter("test2") - ) diff --git a/libs/able/tests/test_setup.py b/libs/able/tests/test_setup.py deleted file mode 100644 index 53dfca3..0000000 --- a/libs/able/tests/test_setup.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path -import pytest - - -@pytest.fixture -def parser(mocker): - mocker.patch("setuptools.setup") - from setup import PathParser - - return PathParser() - - -@pytest.mark.parametrize( - ("cppflags", "expected"), - [ - ( - "-I/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/alert_mi/", - "/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/javaclasses/alert_mi", - ), - ( - "-DANDROID -I/home/user/.buildozer/android/platform/android-ndk-r25b/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include -I/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/alert_mi/arm64-v8a/include/python3.9", - "/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/javaclasses/alert_mi", - ), - ], -) -def test_javaclass_dir_found(mocker, parser, cppflags, expected): - mocker.patch("os.environ", {"CPPFLAGS": cppflags}) - mocker.patch("pathlib.Path.exists", return_value=True) - mocker.patch("pathlib.Path.mkdir") - assert parser.javaclass_dir == Path(expected) diff --git a/recipes/able_recipe/__init__.py b/recipes/able_recipe/__init__.py deleted file mode 100644 index 3040fd3..0000000 --- a/recipes/able_recipe/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Android Bluetooth Low Energy -""" -from pythonforandroid.recipe import PythonRecipe -from pythonforandroid.toolchain import current_directory, info, shprint -import sh -from os.path import join - - -class AbleRecipe(PythonRecipe): - name = 'able_recipe' - depends = ['python3', 'setuptools', 'android'] - call_hostpython_via_targetpython = False - install_in_hostpython = True - - def prepare_build_dir(self, arch): - build_dir = self.get_build_dir(arch) - assert build_dir.endswith(self.name) - shprint(sh.rm, '-rf', build_dir) - shprint(sh.mkdir, build_dir) - - srcs = ('../../libs/able/able', 'setup.py') - - for filename in srcs: - print(f"Copy {join(self.get_recipe_dir(), filename)} to {build_dir}") - shprint(sh.cp, '-a', join(self.get_recipe_dir(), filename), - build_dir) - - def postbuild_arch(self, arch): - super(AbleRecipe, self).postbuild_arch(arch) - info('Copying able java class to classes build dir') - with current_directory(self.get_build_dir(arch.arch)): - shprint(sh.cp, '-a', join('able', 'src', 'org'), - self.ctx.javaclass_dir) - - -recipe = AbleRecipe() diff --git a/recipes/able_recipe/setup.py b/recipes/able_recipe/setup.py deleted file mode 100644 index c2ca462..0000000 --- a/recipes/able_recipe/setup.py +++ /dev/null @@ -1,9 +0,0 @@ -from setuptools import setup - -setup( - name='able', - version='0.0.0', - packages=['able', 'able.android'], - description='Bluetooth Low Energy for Android', - license='MIT', -) diff --git a/recipes/codec2/__init__.py b/recipes/codec2/__init__.py index f8712df..81aa527 100644 --- a/recipes/codec2/__init__.py +++ b/recipes/codec2/__init__.py @@ -2,7 +2,6 @@ from os.path import join from pythonforandroid.recipe import Recipe from pythonforandroid.toolchain import current_directory, shprint import sh -import os # For debugging, clean with # buildozer android p4a -- clean_recipe_build codec2 --local-recipes ~/Information/Source/Sideband/recipes diff --git a/recipes/cython/__init__.py b/recipes/cython/__init__.py deleted file mode 100644 index 1256edf..0000000 --- a/recipes/cython/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from pythonforandroid.recipe import CompiledComponentsPythonRecipe - - -class CythonRecipe(CompiledComponentsPythonRecipe): - - version = '3.1.6' - url = 'https://github.com/cython/cython/archive/{version}.tar.gz' - site_packages_name = 'cython' - depends = ['setuptools'] - call_hostpython_via_targetpython = False - install_in_hostpython = True - - -recipe = CythonRecipe() \ No newline at end of file diff --git a/recipes/ffpyplayer/__init__.py b/recipes/ffpyplayer/__init__.py new file mode 100644 index 0000000..03960b7 --- /dev/null +++ b/recipes/ffpyplayer/__init__.py @@ -0,0 +1,1516 @@ +from os.path import join + +from os.path import basename, dirname, exists, isdir, isfile, join, realpath, split +import glob + +import hashlib +from re import match + +import sh +import shutil +import fnmatch +import zipfile +import urllib.request +from urllib.request import urlretrieve +from os import listdir, unlink, environ, curdir, walk +from sys import stdout +from wheel.wheelfile import WheelFile +from wheel.cli.tags import tags as wheel_tags +import time +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse + +import packaging.version + +from pythonforandroid.logger import ( + logger, info, warning, debug, shprint, info_main, error) +from pythonforandroid.util import ( + current_directory, ensure_dir, BuildInterruptingException, rmdir, move, + touch) +from pythonforandroid.util import load_source as import_recipe + + +url_opener = urllib.request.build_opener() +url_orig_headers = url_opener.addheaders +urllib.request.install_opener(url_opener) + + +class RecipeMeta(type): + def __new__(cls, name, bases, dct): + if name != 'Recipe': + if 'url' in dct: + dct['_url'] = dct.pop('url') + if 'version' in dct: + dct['_version'] = dct.pop('version') + + return super().__new__(cls, name, bases, dct) + + +class Recipe(metaclass=RecipeMeta): + _url = None + '''The address from which the recipe may be downloaded. This is not + essential, it may be omitted if the source is available some other + way, such as via the :class:`IncludedFilesBehaviour` mixin. + + If the url includes the version, you may (and probably should) + replace this with ``{version}``, which will automatically be + replaced by the :attr:`version` string during download. + + .. note:: Methods marked (internal) are used internally and you + probably don't need to call them, but they are available + if you want. + ''' + + _version = None + '''A string giving the version of the software the recipe describes, + e.g. ``2.0.3`` or ``master``.''' + + md5sum = None + '''The md5sum of the source from the :attr:`url`. Non-essential, but + you should try to include this, it is used to check that the download + finished correctly. + ''' + + sha512sum = None + '''The sha512sum of the source from the :attr:`url`. Non-essential, but + you should try to include this, it is used to check that the download + finished correctly. + ''' + + blake2bsum = None + '''The blake2bsum of the source from the :attr:`url`. Non-essential, but + you should try to include this, it is used to check that the download + finished correctly. + ''' + + depends = [] + '''A list containing the names of any recipes that this recipe depends on. + ''' + + conflicts = [] + '''A list containing the names of any recipes that are known to be + incompatible with this one.''' + + opt_depends = [] + '''A list of optional dependencies, that must be built before this + recipe if they are built at all, but whose presence is not essential.''' + + patches = [] + '''A list of patches to apply to the source. Values can be either a string + referring to the patch file relative to the recipe dir, or a tuple of the + string patch file and a callable, which will receive the kwargs `arch` and + `recipe`, which should return True if the patch should be applied.''' + + python_depends = [] + '''A list of pure-Python packages that this package requires. These + packages will NOT be available at build time, but will be added to the + list of pure-Python packages to install via pip. If you need these packages + at build time, you must create a recipe.''' + + archs = ['armeabi'] # Not currently implemented properly + + built_libraries = {} + """Each recipe that builds a system library (e.g.:libffi, openssl, etc...) + should contain a dict holding the relevant information of the library. The + keys should be the generated libraries and the values the relative path of + the library inside his build folder. This dict will be used to perform + different operations: + - copy the library into the right location, depending on if it's shared + or static) + - check if we have to rebuild the library + + Here an example of how it would look like for `libffi` recipe: + + - `built_libraries = {'libffi.so': '.libs'}` + + .. note:: in case that the built library resides in recipe's build + directory, you can set the following values for the relative + path: `'.', None or ''` + """ + + need_stl_shared = False + '''Some libraries or python packages may need the c++_shared in APK. + We can automatically do this for any recipe if we set this property to + `True`''' + + stl_lib_name = 'c++_shared' + ''' + The default STL shared lib to use: `c++_shared`. + + .. note:: Android NDK version > 17 only supports 'c++_shared', because + starting from NDK r18 the `gnustl_shared` lib has been deprecated. + ''' + + def get_stl_library(self, arch): + return join( + arch.ndk_lib_dir, + 'lib{name}.so'.format(name=self.stl_lib_name), + ) + + def install_stl_lib(self, arch): + if not self.ctx.has_lib( + arch.arch, 'lib{name}.so'.format(name=self.stl_lib_name) + ): + self.install_libs(arch, self.get_stl_library(arch)) + + @property + def version(self): + key = 'VERSION_' + self.name + return environ.get(key, self._version) + + @property + def url(self): + key = 'URL_' + self.name + return environ.get(key, self._url) + + @property + def versioned_url(self): + '''A property returning the url of the recipe with ``{version}`` + replaced by the :attr:`url`. If accessing the url, you should use this + property, *not* access the url directly.''' + if self.url is None: + return None + return self.url.format(version=self.version) + + def download_file(self, url, target, cwd=None): + """ + (internal) Download an ``url`` to a ``target``. + """ + if not url: + return + + info('Downloading {} from {}'.format(self.name, url)) + + if cwd: + target = join(cwd, target) + + parsed_url = urlparse(url) + if parsed_url.scheme in ('http', 'https'): + def report_hook(index, blksize, size): + if size <= 0: + progression = '{0} bytes'.format(index * blksize) + else: + progression = '{0:.2f}%'.format( + index * blksize * 100. / float(size)) + if "CI" not in environ: + stdout.write('- Download {}\r'.format(progression)) + stdout.flush() + + if exists(target): + unlink(target) + + # Download item with multiple attempts (for bad connections): + attempts = 0 + seconds = 1 + while True: + try: + # jqueryui.com returns a 403 w/ the default user agent + # Mozilla/5.0 doesnt handle redirection for liblzma + url_opener.addheaders = [('User-agent', 'Wget/1.0')] + urlretrieve(url, target, report_hook) + except OSError as e: + attempts += 1 + if attempts >= 5: + raise + stdout.write('Download failed: {}; retrying in {} second(s)...'.format(e, seconds)) + time.sleep(seconds) + seconds *= 2 + continue + finally: + url_opener.addheaders = url_orig_headers + break + return target + elif parsed_url.scheme in ('git', 'git+file', 'git+ssh', 'git+http', 'git+https'): + if not isdir(target): + if url.startswith('git+'): + url = url[4:] + # if 'version' is specified, do a shallow clone + if self.version: + ensure_dir(target) + with current_directory(target): + shprint(sh.git, 'init') + shprint(sh.git, 'remote', 'add', 'origin', url) + else: + shprint(sh.git, 'clone', '--recursive', url, target) + with current_directory(target): + if self.version: + shprint(sh.git, 'fetch', '--tags', '--depth', '1') + shprint(sh.git, 'checkout', self.version) + branch = sh.git('branch', '--show-current') + if branch: + shprint(sh.git, 'pull') + shprint(sh.git, 'pull', '--recurse-submodules') + shprint(sh.git, 'submodule', 'update', '--recursive', '--init', '--depth', '1') + return target + + def apply_patch(self, filename, arch, build_dir=None): + """ + Apply a patch from the current recipe directory into the current + build directory. + + .. versionchanged:: 0.6.0 + Add ability to apply patch from any dir via kwarg `build_dir`''' + """ + info("Applying patch {}".format(filename)) + build_dir = build_dir if build_dir else self.get_build_dir(arch) + filename = join(self.get_recipe_dir(), filename) + shprint(sh.patch, "-t", "-d", build_dir, "-p1", + "-i", filename, _tail=10) + + def copy_file(self, filename, dest): + info("Copy {} to {}".format(filename, dest)) + filename = join(self.get_recipe_dir(), filename) + dest = join(self.build_dir, dest) + shutil.copy(filename, dest) + + def append_file(self, filename, dest): + info("Append {} to {}".format(filename, dest)) + filename = join(self.get_recipe_dir(), filename) + dest = join(self.build_dir, dest) + with open(filename, "rb") as fd: + data = fd.read() + with open(dest, "ab") as fd: + fd.write(data) + + @property + def name(self): + '''The name of the recipe, the same as the folder containing it.''' + modname = self.__class__.__module__ + return modname.split(".", 2)[-1] + + @property + def filtered_archs(self): + '''Return archs of self.ctx that are valid build archs + for the Recipe.''' + result = [] + for arch in self.ctx.archs: + if not self.archs or (arch.arch in self.archs): + result.append(arch) + return result + + def check_recipe_choices(self): + '''Checks what recipes are being built to see which of the alternative + and optional dependencies are being used, + and returns a list of these.''' + recipes = [] + built_recipes = self.ctx.recipe_build_order + for recipe in self.depends: + if isinstance(recipe, (tuple, list)): + for alternative in recipe: + if alternative in built_recipes: + recipes.append(alternative) + break + for recipe in self.opt_depends: + if recipe in built_recipes: + recipes.append(recipe) + return sorted(recipes) + + def get_opt_depends_in_list(self, recipes): + '''Given a list of recipe names, returns those that are also in + self.opt_depends. + ''' + return [recipe for recipe in recipes if recipe in self.opt_depends] + + def get_build_container_dir(self, arch): + '''Given the arch name, returns the directory where it will be + built. + + This returns a different directory depending on what + alternative or optional dependencies are being built. + ''' + dir_name = self.get_dir_name() + return join(self.ctx.build_dir, 'other_builds', + dir_name, '{}__ndk_target_{}'.format(arch, self.ctx.ndk_api)) + + def get_dir_name(self): + choices = self.check_recipe_choices() + dir_name = '-'.join([self.name] + choices) + return dir_name + + def get_build_dir(self, arch): + '''Given the arch name, returns the directory where the + downloaded/copied package will be built.''' + + return join(self.get_build_container_dir(arch), self.name) + + def get_recipe_dir(self): + """ + Returns the local recipe directory or defaults to the core recipe + directory. + """ + if self.ctx.local_recipes is not None: + local_recipe_dir = join(self.ctx.local_recipes, self.name) + if exists(local_recipe_dir): + return local_recipe_dir + return join(self.ctx.root_dir, 'recipes', self.name) + + # Public Recipe API to be subclassed if needed + + def download_if_necessary(self): + info_main('Downloading {}'.format(self.name)) + user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower())) + if user_dir is not None: + info('P4A_{}_DIR is set, skipping download for {}'.format( + self.name, self.name)) + return + self.download() + + def download(self): + if self.url is None: + info('Skipping {} download as no URL is set'.format(self.name)) + return + + url = self.versioned_url + expected_digests = {} + for alg in set(hashlib.algorithms_guaranteed) | set(('md5', 'sha512', 'blake2b')): + expected_digest = getattr(self, alg + 'sum') if hasattr(self, alg + 'sum') else None + ma = match(u'^(.+)#' + alg + u'=([0-9a-f]{32,})$', url) + if ma: # fragmented URL? + if expected_digest: + raise ValueError( + ('Received {}sum from both the {} recipe ' + 'and its url').format(alg, self.name)) + url = ma.group(1) + expected_digest = ma.group(2) + if expected_digest: + expected_digests[alg] = expected_digest + + ensure_dir(join(self.ctx.packages_path, self.name)) + + with current_directory(join(self.ctx.packages_path, self.name)): + filename = shprint(sh.basename, url).stdout[:-1].decode('utf-8') + + do_download = True + marker_filename = '.mark-{}'.format(filename) + if exists(filename) and isfile(filename): + if not exists(marker_filename): + shprint(sh.rm, filename) + else: + for alg, expected_digest in expected_digests.items(): + current_digest = algsum(alg, filename) + if current_digest != expected_digest: + debug('* Generated {}sum: {}'.format(alg, + current_digest)) + debug('* Expected {}sum: {}'.format(alg, + expected_digest)) + raise ValueError( + ('Generated {0}sum does not match expected {0}sum ' + 'for {1} recipe').format(alg, self.name)) + do_download = False + + # If we got this far, we will download + if do_download: + debug('Downloading {} from {}'.format(self.name, url)) + + shprint(sh.rm, '-f', marker_filename) + self.download_file(self.versioned_url, filename) + touch(marker_filename) + + if exists(filename) and isfile(filename): + for alg, expected_digest in expected_digests.items(): + current_digest = algsum(alg, filename) + if current_digest != expected_digest: + debug('* Generated {}sum: {}'.format(alg, + current_digest)) + debug('* Expected {}sum: {}'.format(alg, + expected_digest)) + raise ValueError( + ('Generated {0}sum does not match expected {0}sum ' + 'for {1} recipe').format(alg, self.name)) + else: + info('{} download already cached, skipping'.format(self.name)) + + def unpack(self, arch): + info_main('Unpacking {} for {}'.format(self.name, arch)) + + build_dir = self.get_build_container_dir(arch) + + user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower())) + if user_dir is not None: + info('P4A_{}_DIR exists, symlinking instead'.format( + self.name.lower())) + if exists(self.get_build_dir(arch)): + return + rmdir(build_dir) + ensure_dir(build_dir) + shprint(sh.cp, '-a', user_dir, self.get_build_dir(arch)) + return + + if self.url is None: + info('Skipping {} unpack as no URL is set'.format(self.name)) + return + + filename = shprint( + sh.basename, self.versioned_url).stdout[:-1].decode('utf-8') + ma = match(u'^(.+)#[a-z0-9_]{3,}=([0-9a-f]{32,})$', filename) + if ma: # fragmented URL? + filename = ma.group(1) + + with current_directory(build_dir): + directory_name = self.get_build_dir(arch) + + if not exists(directory_name) or not isdir(directory_name): + extraction_filename = join( + self.ctx.packages_path, self.name, filename) + if isfile(extraction_filename): + if extraction_filename.endswith(('.zip', '.whl')): + try: + sh.unzip(extraction_filename) + except (sh.ErrorReturnCode_1, sh.ErrorReturnCode_2): + # return code 1 means unzipping had + # warnings but did complete, + # apparently happens sometimes with + # github zips + pass + fileh = zipfile.ZipFile(extraction_filename, 'r') + root_directory = fileh.filelist[0].filename.split('/')[0] + if root_directory != basename(directory_name): + move(root_directory, directory_name) + elif extraction_filename.endswith( + ('.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')): + sh.tar('xf', extraction_filename) + root_directory = sh.tar('tf', extraction_filename).stdout.decode( + 'utf-8').split('\n')[0].split('/')[0] + if root_directory != basename(directory_name): + move(root_directory, directory_name) + else: + raise Exception( + 'Could not extract {} download, it must be .zip, ' + '.tar.gz or .tar.bz2 or .tar.xz'.format(extraction_filename)) + elif isdir(extraction_filename): + ensure_dir(directory_name) + for entry in listdir(extraction_filename): + # Previously we filtered out the .git folder, but during the build process for some recipes + # (e.g. when version is parsed by `setuptools_scm`) that may be needed. + shprint(sh.cp, '-Rv', + join(extraction_filename, entry), + directory_name) + else: + raise Exception( + 'Given path is neither a file nor a directory: {}' + .format(extraction_filename)) + + else: + info('{} is already unpacked, skipping'.format(self.name)) + + def get_recipe_env(self, arch=None, with_flags_in_cc=True): + """Return the env specialized for the recipe + """ + if arch is None: + arch = self.filtered_archs[0] + env = arch.get_env(with_flags_in_cc=with_flags_in_cc) + return env + + def prebuild_arch(self, arch): + '''Run any pre-build tasks for the Recipe. By default, this checks if + any prebuild_archname methods exist for the archname of the current + architecture, and runs them if so.''' + prebuild = "prebuild_{}".format(arch.arch.replace('-', '_')) + if hasattr(self, prebuild): + getattr(self, prebuild)() + else: + info('{} has no {}, skipping'.format(self.name, prebuild)) + + def is_patched(self, arch): + build_dir = self.get_build_dir(arch.arch) + return exists(join(build_dir, '.patched')) + + def apply_patches(self, arch, build_dir=None): + '''Apply any patches for the Recipe. + + .. versionchanged:: 0.6.0 + Add ability to apply patches from any dir via kwarg `build_dir`''' + if self.patches: + info_main('Applying patches for {}[{}]' + .format(self.name, arch.arch)) + + if self.is_patched(arch): + info_main('{} already patched, skipping'.format(self.name)) + return + + build_dir = build_dir if build_dir else self.get_build_dir(arch.arch) + for patch in self.patches: + if isinstance(patch, (tuple, list)): + patch, patch_check = patch + if not patch_check(arch=arch, recipe=self): + continue + + self.apply_patch( + patch.format(version=self.version, arch=arch.arch), + arch.arch, build_dir=build_dir) + + touch(join(build_dir, '.patched')) + + def should_build(self, arch): + '''Should perform any necessary test and return True only if it needs + building again. Per default we implement a library test, in case that + we detect so. + + ''' + if self.built_libraries: + return not all( + exists(lib) for lib in self.get_libraries(arch.arch) + ) + return True + + def build_arch(self, arch): + '''Run any build tasks for the Recipe. By default, this checks if + any build_archname methods exist for the archname of the current + architecture, and runs them if so.''' + build = "build_{}".format(arch.arch) + if hasattr(self, build): + getattr(self, build)() + + def install_libraries(self, arch): + '''This method is always called after `build_arch`. In case that we + detect a library recipe, defined by the class attribute + `built_libraries`, we will copy all defined libraries into the + right location. + ''' + if not self.built_libraries: + return + shared_libs = [ + lib for lib in self.get_libraries(arch) if lib.endswith(".so") + ] + self.install_libs(arch, *shared_libs) + + def postbuild_arch(self, arch): + '''Run any post-build tasks for the Recipe. By default, this checks if + any postbuild_archname methods exist for the archname of the + current architecture, and runs them if so. + ''' + postbuild = "postbuild_{}".format(arch.arch) + if hasattr(self, postbuild): + getattr(self, postbuild)() + + if self.need_stl_shared: + self.install_stl_lib(arch) + + def prepare_build_dir(self, arch): + '''Copies the recipe data into a build dir for the given arch. By + default, this unpacks a downloaded recipe. You should override + it (or use a Recipe subclass with different behaviour) if you + want to do something else. + ''' + self.unpack(arch) + + def clean_build(self, arch=None): + '''Deletes all the build information of the recipe. + + If arch is not None, only this arch dir is deleted. Otherwise + (the default) all builds for all archs are deleted. + + By default, this just deletes the main build dir. If the + recipe has e.g. object files biglinked, or .so files stored + elsewhere, you should override this method. + + This method is intended for testing purposes, it may have + strange results. Rebuild everything if this seems to happen. + + ''' + if arch is None: + base_dir = join(self.ctx.build_dir, 'other_builds', self.name) + else: + base_dir = self.get_build_container_dir(arch) + dirs = glob.glob(base_dir + '-*') + if exists(base_dir): + dirs.append(base_dir) + if not dirs: + warning('Attempted to clean build for {} but found no existing ' + 'build dirs'.format(self.name)) + + for directory in dirs: + rmdir(directory) + + # Delete any Python distributions to ensure the recipe build + # doesn't persist in site-packages + rmdir(self.ctx.python_installs_dir) + + def install_libs(self, arch, *libs): + libs_dir = self.ctx.get_libs_dir(arch.arch) + if not libs: + warning('install_libs called with no libraries to install!') + return + args = libs + (libs_dir,) + shprint(sh.cp, *args) + + def has_libs(self, arch, *libs): + return all(map(lambda lib: self.ctx.has_lib(arch.arch, lib), libs)) + + def get_libraries(self, arch_name, in_context=False): + """Return the full path of the library depending on the architecture. + Per default, the build library path it will be returned, unless + `get_libraries` has been called with kwarg `in_context` set to + True. + + .. note:: this method should be used for library recipes only + """ + recipe_libs = set() + if not self.built_libraries: + return recipe_libs + for lib, rel_path in self.built_libraries.items(): + if not in_context: + abs_path = join(self.get_build_dir(arch_name), rel_path, lib) + if rel_path in {".", "", None}: + abs_path = join(self.get_build_dir(arch_name), lib) + else: + abs_path = join(self.ctx.get_libs_dir(arch_name), lib) + recipe_libs.add(abs_path) + return recipe_libs + + @classmethod + def recipe_dirs(cls, ctx): + recipe_dirs = [] + if ctx.local_recipes is not None: + recipe_dirs.append(realpath(ctx.local_recipes)) + if ctx.storage_dir: + recipe_dirs.append(join(ctx.storage_dir, 'recipes')) + recipe_dirs.append(join(ctx.root_dir, "recipes")) + return recipe_dirs + + @classmethod + def list_recipes(cls, ctx): + forbidden_dirs = ('__pycache__', ) + for recipes_dir in cls.recipe_dirs(ctx): + if recipes_dir and exists(recipes_dir): + for name in listdir(recipes_dir): + if name in forbidden_dirs: + continue + fn = join(recipes_dir, name) + if isdir(fn): + yield name + + @classmethod + def get_recipe(cls, name, ctx): + '''Returns the Recipe with the given name, if it exists.''' + name = name.lower() + if not hasattr(cls, "recipes"): + cls.recipes = {} + if name in cls.recipes: + return cls.recipes[name] + + recipe_file = None + for recipes_dir in cls.recipe_dirs(ctx): + if not exists(recipes_dir): + continue + # Find matching folder (may differ in case): + for subfolder in listdir(recipes_dir): + if subfolder.lower() == name: + recipe_file = join(recipes_dir, subfolder, '__init__.py') + if exists(recipe_file): + name = subfolder # adapt to actual spelling + break + recipe_file = None + if recipe_file is not None: + break + + else: + raise ValueError('Recipe does not exist: {}'.format(name)) + + mod = import_recipe('pythonforandroid.recipes.{}'.format(name), recipe_file) + if len(logger.handlers) > 1: + logger.removeHandler(logger.handlers[1]) + recipe = mod.recipe + recipe.ctx = ctx + cls.recipes[name.lower()] = recipe + return recipe + + +class IncludedFilesBehaviour(object): + '''Recipe mixin class that will automatically unpack files included in + the recipe directory.''' + src_filename = None + + def prepare_build_dir(self, arch): + if self.src_filename is None: + raise BuildInterruptingException( + 'IncludedFilesBehaviour failed: no src_filename specified') + rmdir(self.get_build_dir(arch)) + shprint(sh.cp, '-a', join(self.get_recipe_dir(), self.src_filename), + self.get_build_dir(arch)) + + +class BootstrapNDKRecipe(Recipe): + '''A recipe class for recipes built in an Android project jni dir with + an Android.mk. These are not cached separatly, but built in the + bootstrap's own building directory. + + To build an NDK project which is not part of the bootstrap, see + :class:`~pythonforandroid.recipe.NDKRecipe`. + + To link with python, call the method :meth:`get_recipe_env` + with the kwarg *with_python=True*. + ''' + + dir_name = None # The name of the recipe build folder in the jni dir + + def get_build_container_dir(self, arch): + return self.get_jni_dir() + + def get_build_dir(self, arch): + if self.dir_name is None: + raise ValueError('{} recipe doesn\'t define a dir_name, but ' + 'this is necessary'.format(self.name)) + return join(self.get_build_container_dir(arch), self.dir_name) + + def get_jni_dir(self): + return join(self.ctx.bootstrap.build_dir, 'jni') + + def get_recipe_env(self, arch=None, with_flags_in_cc=True, with_python=False): + env = super().get_recipe_env(arch, with_flags_in_cc) + if not with_python: + return env + + env['PYTHON_INCLUDE_ROOT'] = self.ctx.python_recipe.include_root(arch.arch) + env['PYTHON_LINK_ROOT'] = self.ctx.python_recipe.link_root(arch.arch) + env['EXTRA_LDLIBS'] = ' -lpython{}'.format( + self.ctx.python_recipe.link_version) + return env + + +class NDKRecipe(Recipe): + '''A recipe class for any NDK project not included in the bootstrap.''' + + generated_libraries = [] + + def should_build(self, arch): + lib_dir = self.get_lib_dir(arch) + + for lib in self.generated_libraries: + if not exists(join(lib_dir, lib)): + return True + + return False + + def get_lib_dir(self, arch): + return join(self.get_build_dir(arch.arch), 'obj', 'local', arch.arch) + + def get_jni_dir(self, arch): + return join(self.get_build_dir(arch.arch), 'jni') + + def build_arch(self, arch, *extra_args): + super().build_arch(arch) + + env = self.get_recipe_env(arch) + with current_directory(self.get_build_dir(arch.arch)): + shprint( + sh.Command(join(self.ctx.ndk_dir, "ndk-build")), + 'V=1', + 'NDK_DEBUG=' + ("1" if self.ctx.build_as_debuggable else "0"), + 'APP_PLATFORM=android-' + str(self.ctx.ndk_api), + 'APP_ABI=' + arch.arch, + *extra_args, _env=env + ) + + +class PythonRecipe(Recipe): + site_packages_name = None + '''The name of the module's folder when installed in the Python + site-packages (e.g. for pyjnius it is 'jnius')''' + + call_hostpython_via_targetpython = True + '''If True, tries to install the module using the hostpython binary + copied to the target (normally arm) python build dir. However, this + will fail if the module tries to import e.g. _io.so. Set this to False + to call hostpython from its own build dir, installing the module in + the right place via arguments to setup.py. However, this may not set + the environment correctly and so False is not the default.''' + + install_in_hostpython = False + '''If True, additionally installs the module in the hostpython build + dir. This will make it available to other recipes if + call_hostpython_via_targetpython is False. + ''' + + install_in_targetpython = True + '''If True, installs the module in the targetpython installation dir. + This is almost always what you want to do.''' + + setup_extra_args = [] + '''List of extra arguments to pass to setup.py''' + + depends = ['python3'] + ''' + .. note:: it's important to keep this depends as a class attribute outside + `__init__` because sometimes we only initialize the class, so the + `__init__` call won't be called and the deps would be missing + (which breaks the dependency graph computation) + + .. warning:: don't forget to call `super().__init__()` in any recipe's + `__init__`, or otherwise it may not be ensured that it depends + on python2 or python3 which can break the dependency graph + ''' + + hostpython_prerequisites = [] + '''List of hostpython packages required to build a recipe''' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'python3' not in self.depends: + # We ensure here that the recipe depends on python even it overrode + # `depends`. We only do this if it doesn't already depend on any + # python, since some recipes intentionally don't depend on/work + # with all python variants + depends = self.depends + depends.append('python3') + depends = list(set(depends)) + self.depends = depends + + def clean_build(self, arch=None): + super().clean_build(arch=arch) + name = self.folder_name + python_install_dirs = glob.glob(join(self.ctx.python_installs_dir, '*')) + for python_install in python_install_dirs: + site_packages_dir = glob.glob(join(python_install, 'lib', 'python*', + 'site-packages')) + if site_packages_dir: + build_dir = join(site_packages_dir[0], name) + if exists(build_dir): + info('Deleted {}'.format(build_dir)) + rmdir(build_dir) + + @property + def real_hostpython_location(self): + host_name = 'host{}'.format(self.ctx.python_recipe.name) + if host_name == 'hostpython3': + python_recipe = Recipe.get_recipe(host_name, self.ctx) + return python_recipe.python_exe + else: + python_recipe = self.ctx.python_recipe + return 'python{}'.format(python_recipe.version) + + @property + def hostpython_location(self): + if not self.call_hostpython_via_targetpython: + return self.real_hostpython_location + return self.ctx.hostpython + + @property + def folder_name(self): + '''The name of the build folders containing this recipe.''' + name = self.site_packages_name + if name is None: + name = self.name + return name + + def get_recipe_env(self, arch=None, with_flags_in_cc=True): + env = super().get_recipe_env(arch, with_flags_in_cc) + env['PYTHONNOUSERSITE'] = '1' + # Set the LANG, this isn't usually important but is a better default + # as it occasionally matters how Python e.g. reads files + env['LANG'] = "en_GB.UTF-8" + # Binaries made by packages installed by pip + env["PATH"] = join(self.hostpython_site_dir, "bin") + ":" + env["PATH"] + + if not self.call_hostpython_via_targetpython: + env['CFLAGS'] += ' -I{}'.format( + self.ctx.python_recipe.include_root(arch.arch) + ) + env['LDFLAGS'] += ' -L{} -lpython{}'.format( + self.ctx.python_recipe.link_root(arch.arch), + self.ctx.python_recipe.link_version, + ) + + hppath = [] + hppath.append(join(dirname(self.hostpython_location), 'Lib')) + hppath.append(join(hppath[0], 'site-packages')) + builddir = join(dirname(self.hostpython_location), 'build') + if exists(builddir): + hppath += [join(builddir, d) for d in listdir(builddir) + if isdir(join(builddir, d))] + if len(hppath) > 0: + if 'PYTHONPATH' in env: + env['PYTHONPATH'] = ':'.join(hppath + [env['PYTHONPATH']]) + else: + env['PYTHONPATH'] = ':'.join(hppath) + return env + + def should_build(self, arch): + name = self.folder_name + if self.ctx.has_package(name, arch): + info('Python package already exists in site-packages') + return False + info('{} apparently isn\'t already in site-packages'.format(name)) + return True + + def build_arch(self, arch): + '''Install the Python module by calling setup.py install with + the target Python dir.''' + self.install_hostpython_prerequisites() + super().build_arch(arch) + self.install_python_package(arch) + + def install_python_package(self, arch, name=None, env=None, is_dir=True): + '''Automate the installation of a Python package (or a cython + package where the cython components are pre-built).''' + # arch = self.filtered_archs[0] # old kivy-ios way + if name is None: + name = self.name + if env is None: + env = self.get_recipe_env(arch) + + info('Installing {} into site-packages'.format(self.name)) + + hostpython = sh.Command(self.hostpython_location) + hpenv = env.copy() + with current_directory(self.get_build_dir(arch.arch)): + shprint(hostpython, 'setup.py', 'install', '-O2', + '--root={}'.format(self.ctx.get_python_install_dir(arch.arch)), + '--install-lib=.', + _env=hpenv, *self.setup_extra_args) + + # If asked, also install in the hostpython build dir + if self.install_in_hostpython: + self.install_hostpython_package(arch) + + def get_hostrecipe_env(self, arch): + env = environ.copy() + env['PYTHONPATH'] = self.hostpython_site_dir + return env + + @property + def hostpython_site_dir(self): + return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages') + + def install_hostpython_package(self, arch): + env = self.get_hostrecipe_env(arch) + real_hostpython = sh.Command(self.real_hostpython_location) + shprint(real_hostpython, 'setup.py', 'install', '-O2', + '--root={}'.format(dirname(self.real_hostpython_location)), + '--install-lib=Lib/site-packages', + _env=env, *self.setup_extra_args) + + @property + def python_major_minor_version(self): + parsed_version = packaging.version.parse(self.ctx.python_recipe.version) + return f"{parsed_version.major}.{parsed_version.minor}" + + def install_hostpython_prerequisites(self, packages=None, force_upgrade=True): + if not packages: + packages = self.hostpython_prerequisites + + if len(packages) == 0: + return + + pip_options = [ + "install", + *packages, + "--target", self.hostpython_site_dir, "--python-version", + self.ctx.python_recipe.version, + # Don't use sources, instead wheels + "--only-binary=:all:", + ] + if force_upgrade: + pip_options.append("--upgrade") + # Use system's pip + shprint(sh.pip, *pip_options) + + def restore_hostpython_prerequisites(self, packages): + _packages = [] + for package in packages: + original_version = Recipe.get_recipe(package, self.ctx).version + _packages.append(package + "==" + original_version) + self.install_hostpython_prerequisites(packages=_packages) + + +class CompiledComponentsPythonRecipe(PythonRecipe): + pre_build_ext = False + + build_cmd = 'build_ext' + + def build_arch(self, arch): + '''Build any cython components, then install the Python module by + calling setup.py install with the target Python dir. + ''' + Recipe.build_arch(self, arch) + self.install_hostpython_prerequisites() + self.build_compiled_components(arch) + self.install_python_package(arch) + + def build_compiled_components(self, arch): + info('Building compiled components in {}'.format(self.name)) + + env = self.get_recipe_env(arch) + hostpython = sh.Command(self.hostpython_location) + with current_directory(self.get_build_dir(arch.arch)): + if self.install_in_hostpython: + shprint(hostpython, 'setup.py', 'clean', '--all', _env=env) + shprint(hostpython, 'setup.py', self.build_cmd, '-v', + _env=env, *self.setup_extra_args) + build_dir = glob.glob('build/lib.*')[0] + shprint(sh.find, build_dir, '-name', '"*.o"', '-exec', + env['STRIP'], '{}', ';', _env=env) + + def install_hostpython_package(self, arch): + env = self.get_hostrecipe_env(arch) + self.rebuild_compiled_components(arch, env) + super().install_hostpython_package(arch) + + def rebuild_compiled_components(self, arch, env): + info('Rebuilding compiled components in {}'.format(self.name)) + + hostpython = sh.Command(self.real_hostpython_location) + shprint(hostpython, 'setup.py', 'clean', '--all', _env=env) + shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env, + *self.setup_extra_args) + + +class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe): + """ Extensions that require the cxx-stl """ + call_hostpython_via_targetpython = False + need_stl_shared = True + + +class CythonRecipe(PythonRecipe): + pre_build_ext = False + cythonize = True + cython_args = [] + call_hostpython_via_targetpython = False + + def build_arch(self, arch): + '''Build any cython components, then install the Python module by + calling setup.py install with the target Python dir. + ''' + Recipe.build_arch(self, arch) + self.build_cython_components(arch) + self.install_python_package(arch) + + def build_cython_components(self, arch): + info('Cythonizing anything necessary in {}'.format(self.name)) + + env = self.get_recipe_env(arch) + + with current_directory(self.get_build_dir(arch.arch)): + hostpython = sh.Command(self.ctx.hostpython) + shprint(hostpython, '-c', 'import sys; print(sys.path)', _env=env) + debug('cwd is {}'.format(realpath(curdir))) + info('Trying first build of {} to get cython files: this is ' + 'expected to fail'.format(self.name)) + + manually_cythonise = False + try: + shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env, + *self.setup_extra_args) + except sh.ErrorReturnCode_1: + print() + info('{} first build failed (as expected)'.format(self.name)) + manually_cythonise = True + + if manually_cythonise: + self.cythonize_build(env=env) + shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env, + _tail=20, _critical=True, *self.setup_extra_args) + else: + info('First build appeared to complete correctly, skipping manual' + 'cythonising.') + + if not self.ctx.with_debug_symbols: + self.strip_object_files(arch, env) + + def strip_object_files(self, arch, env, build_dir=None): + if build_dir is None: + build_dir = self.get_build_dir(arch.arch) + with current_directory(build_dir): + info('Stripping object files') + shprint(sh.find, '.', '-iname', '*.so', '-exec', + '/usr/bin/echo', '{}', ';', _env=env) + shprint(sh.find, '.', '-iname', '*.so', '-exec', + env['STRIP'].split(' ')[0], '--strip-unneeded', + # '/usr/bin/strip', '--strip-unneeded', + '{}', ';', _env=env) + + def cythonize_file(self, env, build_dir, filename): + short_filename = filename + if filename.startswith(build_dir): + short_filename = filename[len(build_dir) + 1:] + info(u"Cythonize {}".format(short_filename)) + cyenv = env.copy() + if 'CYTHONPATH' in cyenv: + cyenv['PYTHONPATH'] = cyenv['CYTHONPATH'] + elif 'PYTHONPATH' in cyenv: + del cyenv['PYTHONPATH'] + if 'PYTHONNOUSERSITE' in cyenv: + cyenv.pop('PYTHONNOUSERSITE') + python_command = sh.Command("python{}".format( + self.ctx.python_recipe.major_minor_version_string.split(".")[0] + )) + shprint(python_command, "-c" + "import sys; from Cython.Compiler.Main import setuptools_main; sys.exit(setuptools_main());", + filename, *self.cython_args, _env=cyenv) + + def cythonize_build(self, env, build_dir="."): + if not self.cythonize: + info('Running cython cancelled per recipe setting') + return + info('Running cython where appropriate') + for root, dirnames, filenames in walk("."): + for filename in fnmatch.filter(filenames, "*.pyx"): + self.cythonize_file(env, build_dir, join(root, filename)) + + def get_recipe_env(self, arch, with_flags_in_cc=True): + env = super().get_recipe_env(arch, with_flags_in_cc) + env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format( + self.ctx.get_libs_dir(arch.arch) + + ' -L{} '.format(self.ctx.libs_dir) + + ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local', + arch.arch))) + + env['LDSHARED'] = env['CC'] + ' -shared' + # shprint(sh.whereis, env['LDSHARED'], _env=env) + env['LIBLINK'] = 'NOTNONE' + if self.ctx.copy_libs: + env['COPYLIBS'] = '1' + + # Every recipe uses its own liblink path, object files are + # collected and biglinked later + liblink_path = join(self.get_build_container_dir(arch.arch), + 'objects_{}'.format(self.name)) + env['LIBLINK_PATH'] = liblink_path + ensure_dir(liblink_path) + + return env + + +class PyProjectRecipe(PythonRecipe): + '''Recipe for projects which containes `pyproject.toml`''' + + # Extra args to pass to `python -m build ...` + extra_build_args = [] + call_hostpython_via_targetpython = False + + def get_recipe_env(self, arch, **kwargs): + # Custom hostpython + self.ctx.python_recipe.python_exe = join( + self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3") + env = super().get_recipe_env(arch, **kwargs) + build_dir = self.get_build_dir(arch) + ensure_dir(build_dir) + build_opts = join(build_dir, "build-opts.cfg") + + with open(build_opts, "w") as file: + file.write("[bdist_wheel]\nplat-name={}".format( + self.get_wheel_platform_tag(arch) + )) + file.close() + + env["DIST_EXTRA_CONFIG"] = build_opts + return env + + def get_wheel_platform_tag(self, arch): + return "android_" + { + "armeabi-v7a": "arm", + "arm64-v8a": "aarch64", + "x86_64": "x86_64", + "x86": "i686", + }[arch.arch] + + def install_wheel(self, arch, built_wheels): + _wheel = built_wheels[0] + built_wheel_dir = dirname(_wheel) + # Fix wheel platform tag + wheel_tag = wheel_tags( + _wheel, + platform_tags=self.get_wheel_platform_tag(arch), + remove=True, + ) + selected_wheel = join(built_wheel_dir, wheel_tag) + + _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False) + if _dev_wheel_dir: + ensure_dir(_dev_wheel_dir) + shprint(sh.cp, selected_wheel, _dev_wheel_dir) + + info(f"Installing built wheel: {wheel_tag}") + destination = self.ctx.get_python_install_dir(arch.arch) + with WheelFile(selected_wheel) as wf: + for zinfo in wf.filelist: + wf.extract(zinfo, destination) + wf.close() + + def build_arch(self, arch): + self.install_hostpython_prerequisites( + packages=["build[virtualenv]", "pip"] + self.hostpython_prerequisites + ) + build_dir = self.get_build_dir(arch.arch) + env = self.get_recipe_env(arch, with_flags_in_cc=True) + # make build dir separatly + sub_build_dir = join(build_dir, "p4a_android_build") + ensure_dir(sub_build_dir) + # copy hostpython to built python to ensure correct selection of libs and includes + shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe) + + build_args = [ + "-m", + "build", + "--wheel", + "--config-setting", + "builddir={}".format(sub_build_dir), + ] + self.extra_build_args + + built_wheels = [] + with current_directory(build_dir): + shprint( + sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env + ) + built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")] + self.install_wheel(arch, built_wheels) + + +class MesonRecipe(PyProjectRecipe): + '''Recipe for projects which uses meson as build system''' + + meson_version = "1.4.0" + ninja_version = "1.11.1.1" + + def sanitize_flags(self, *flag_strings): + return " ".join(flag_strings).strip().split(" ") + + def get_recipe_meson_options(self, arch): + env = self.get_recipe_env(arch, with_flags_in_cc=True) + return { + "binaries": { + "c": arch.get_clang_exe(with_target=True), + "cpp": arch.get_clang_exe(with_target=True, plus_plus=True), + "ar": self.ctx.ndk.llvm_ar, + "strip": self.ctx.ndk.llvm_strip, + }, + "built-in options": { + "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]), + "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]), + "c_link_args": self.sanitize_flags(env["LDFLAGS"]), + "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]), + }, + "properties": { + "needs_exe_wrapper": True, + "sys_root": self.ctx.ndk.sysroot + }, + "host_machine": { + "cpu_family": { + "arm64-v8a": "aarch64", + "armeabi-v7a": "arm", + "x86_64": "x86_64", + "x86": "x86" + }[arch.arch], + "cpu": { + "arm64-v8a": "aarch64", + "armeabi-v7a": "armv7", + "x86_64": "x86_64", + "x86": "i686" + }[arch.arch], + "endian": "little", + "system": "android", + } + } + + def write_build_options(self, arch): + """Writes python dict to meson config file""" + option_data = "" + build_options = self.get_recipe_meson_options(arch) + for key in build_options.keys(): + data_chunk = "[{}]".format(key) + for subkey in build_options[key].keys(): + value = build_options[key][subkey] + if isinstance(value, int): + value = str(value) + elif isinstance(value, str): + value = "'{}'".format(value) + elif isinstance(value, bool): + value = "true" if value else "false" + elif isinstance(value, list): + value = "['" + "', '".join(value) + "']" + data_chunk += "\n" + subkey + " = " + value + option_data += data_chunk + "\n\n" + return option_data + + def ensure_args(self, *args): + for arg in args: + if arg not in self.extra_build_args: + self.extra_build_args.append(arg) + + def build_arch(self, arch): + cross_file = join("/tmp", "android.meson.cross") + info("Writing cross file at: {}".format(cross_file)) + # write cross config file + with open(cross_file, "w") as file: + file.write(self.write_build_options(arch)) + file.close() + # set cross file + self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file)) + # ensure ninja and meson + for dep in [ + "ninja=={}".format(self.ninja_version), + "meson=={}".format(self.meson_version), + ]: + if dep not in self.hostpython_prerequisites: + self.hostpython_prerequisites.append(dep) + super().build_arch(arch) + + +class RustCompiledComponentsRecipe(PyProjectRecipe): + # Rust toolchain codes + # https://doc.rust-lang.org/nightly/rustc/platform-support.html + RUST_ARCH_CODES = { + "arm64-v8a": "aarch64-linux-android", + "armeabi-v7a": "armv7-linux-androideabi", + "x86_64": "x86_64-linux-android", + "x86": "i686-linux-android", + } + + call_hostpython_via_targetpython = False + + def get_recipe_env(self, arch, **kwargs): + env = super().get_recipe_env(arch, **kwargs) + + # Set rust build target + build_target = self.RUST_ARCH_CODES[arch.arch] + cargo_linker_name = "CARGO_TARGET_{}_LINKER".format( + build_target.upper().replace("-", "_") + ) + env["CARGO_BUILD_TARGET"] = build_target + env[cargo_linker_name] = join( + self.ctx.ndk.llvm_prebuilt_dir, + "bin", + "{}{}-clang".format( + # NDK's Clang format + build_target.replace("7", "7a") + if build_target.startswith("armv7") + else build_target, + self.ctx.ndk_api, + ), + ) + realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch) + + env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format( + self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build") + ) + + env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join( + realpython_dir, "android-build", "build", + "lib.linux-*-{}/".format(self.python_major_minor_version), + ))[0]) + + info_main("Ensuring rust build toolchain") + shprint(sh.rustup, "target", "add", build_target) + + # Add host python to PATH + env["PATH"] = ("{hostpython_dir}:{old_path}").format( + hostpython_dir=Recipe.get_recipe( + "hostpython3", self.ctx + ).get_path_to_python(), + old_path=env["PATH"], + ) + return env + + def check_host_deps(self): + if not hasattr(sh, "rustup"): + error( + "`rustup` was not found on host system." + "Please install it using :" + "\n`curl https://sh.rustup.rs -sSf | sh`\n" + ) + exit(1) + + def build_arch(self, arch): + self.check_host_deps() + super().build_arch(arch) + + +class TargetPythonRecipe(Recipe): + '''Class for target python recipes. Sets ctx.python_recipe to point to + itself, so as to know later what kind of Python was built or used.''' + + def __init__(self, *args, **kwargs): + self._ctx = None + super().__init__(*args, **kwargs) + + def prebuild_arch(self, arch): + super().prebuild_arch(arch) + self.ctx.python_recipe = self + + def include_root(self, arch): + '''The root directory from which to include headers.''' + raise NotImplementedError('Not implemented in TargetPythonRecipe') + + def link_root(self): + raise NotImplementedError('Not implemented in TargetPythonRecipe') + + @property + def major_minor_version_string(self): + parsed_version = packaging.version.parse(self.version) + return f"{parsed_version.major}.{parsed_version.minor}" + + def create_python_bundle(self, dirn, arch): + """ + Create a packaged python bundle in the target directory, by + copying all the modules and standard library to the right + place. + """ + raise NotImplementedError('{} does not implement create_python_bundle'.format(self)) + + def reduce_object_file_names(self, dirn): + """Recursively renames all files named XXX.cpython-...-linux-gnu.so" + to "XXX.so", i.e. removing the erroneous architecture name + coming from the local system. + """ + py_so_files = shprint(sh.find, dirn, '-iname', '*.so') + filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1] + for filen in filens: + file_dirname, file_basename = split(filen) + parts = file_basename.split('.') + if len(parts) <= 2: + continue + # PySide6 libraries end with .abi3.so + if parts[1] == "abi3": + continue + move(filen, join(file_dirname, parts[0] + '.so')) + + +def algsum(alg, filen): + '''Calculate the digest of a file. + ''' + with open(filen, 'rb') as fileh: + digest = getattr(hashlib, alg)(fileh.read()) + + return digest.hexdigest() + + +class FFPyPlayerRecipe(PyProjectRecipe): + version = 'v4.5.1' + url = 'https://github.com/matham/ffpyplayer/archive/{version}.zip' + depends = ['python3', 'sdl2', 'ffmpeg'] + patches = ["setup.py.patch"] + opt_depends = ['openssl', 'ffpyplayer_codecs'] + + def get_recipe_env(self, arch, with_flags_in_cc=True): + env = super().get_recipe_env(arch) + + build_dir = Recipe.get_recipe('ffmpeg', self.ctx).get_build_dir(arch.arch) + env["FFMPEG_INCLUDE_DIR"] = join(build_dir, "include") + env["FFMPEG_LIB_DIR"] = join(build_dir, "lib") + + env["SDL_INCLUDE_DIR"] = join(self.ctx.bootstrap.build_dir, 'jni', 'SDL', 'include') + env["SDL_LIB_DIR"] = join(self.ctx.bootstrap.build_dir, 'libs', arch.arch) + + env["USE_SDL2_MIXER"] = '1' + + # ffpyplayer does not allow to pass more than one include dir for sdl2_mixer (and ATM is + # not needed), so we only pass the first one. + sdl2_mixer_recipe = self.get_recipe('sdl2_mixer', self.ctx) + env["SDL2_MIXER_INCLUDE_DIR"] = sdl2_mixer_recipe.get_include_dirs(arch)[0] + + # NDKPLATFORM and LIBLINK are our switches for detecting Android platform, so can't be empty + # FIXME: We may want to introduce a cleaner approach to this? + env['NDKPLATFORM'] = "NOTNONE" + env['LIBLINK'] = 'NOTNONE' + + # ffmpeg recipe enables GPL components only if ffpyplayer_codecs recipe used. + # Therefor we need to disable libpostproc if skipped. + if 'ffpyplayer_codecs' not in self.ctx.recipe_build_order: + env["CONFIG_POSTPROC"] = '0' + + return env + + +recipe = FFPyPlayerRecipe() \ No newline at end of file diff --git a/recipes/ffpyplayer/setup.py.patch b/recipes/ffpyplayer/setup.py.patch new file mode 100644 index 0000000..6a7d42f --- /dev/null +++ b/recipes/ffpyplayer/setup.py.patch @@ -0,0 +1,15 @@ +--- ffpyplayer/setup.py 2024-06-02 11:10:49.691183467 +0530 ++++ ffpyplayer.mod/setup.py 2024-06-02 11:20:16.220966873 +0530 +@@ -27,12 +27,6 @@ + # This sets whether or not Cython gets added to setup_requires. + declare_cython = False + +-if platform in ('ios', 'android'): +- # NEVER use or declare cython on these platforms +- print('Not using cython on %s' % platform) +- can_use_cython = False +-else: +- declare_cython = True + + src_path = build_path = dirname(__file__) + print(f'Source/build path: {src_path}') \ No newline at end of file diff --git a/recipes/hostpython3/__init__.py b/recipes/hostpython3/__init__.py deleted file mode 100644 index b449a30..0000000 --- a/recipes/hostpython3/__init__.py +++ /dev/null @@ -1,175 +0,0 @@ -import sh -import os - -from multiprocessing import cpu_count -from pathlib import Path -from os.path import join - -from packaging.version import Version -from pythonforandroid.logger import shprint -from pythonforandroid.recipe import Recipe -from pythonforandroid.util import ( - BuildInterruptingException, - current_directory, - ensure_dir, -) -from pythonforandroid.prerequisites import OpenSSLPrerequisite - -HOSTPYTHON_VERSION_UNSET_MESSAGE = ( - 'The hostpython recipe must have set version' -) - -SETUP_DIST_NOT_FIND_MESSAGE = ( - 'Could not find Setup.dist or Setup in Python build' -) - - -class HostPython3Recipe(Recipe): - ''' - The hostpython3's recipe. - - .. versionchanged:: 2019.10.06.post0 - Refactored from deleted class ``python.HostPythonRecipe`` into here. - - .. versionchanged:: 0.6.0 - Refactored into the new class - :class:`~pythonforandroid.python.HostPythonRecipe` - ''' - - version = '3.11.5' - name = 'hostpython3' - - build_subdir = 'native-build' - '''Specify the sub build directory for the hostpython3 recipe. Defaults - to ``native-build``.''' - - url = 'https://www.python.org/ftp/python/{version}/Python-{version}.tgz' - '''The default url to download our host python recipe. This url will - change depending on the python version set in attribute :attr:`version`.''' - - patches = ['patches/pyconfig_detection.patch'] - - @property - def _exe_name(self): - ''' - Returns the name of the python executable depending on the version. - ''' - if not self.version: - raise BuildInterruptingException(HOSTPYTHON_VERSION_UNSET_MESSAGE) - return f'python{self.version.split(".")[0]}' - - @property - def python_exe(self): - '''Returns the full path of the hostpython executable.''' - return join(self.get_path_to_python(), self._exe_name) - - def get_recipe_env(self, arch=None): - env = os.environ.copy() - openssl_prereq = OpenSSLPrerequisite() - if env.get("PKG_CONFIG_PATH", ""): - env["PKG_CONFIG_PATH"] = os.pathsep.join( - [openssl_prereq.pkg_config_location, env["PKG_CONFIG_PATH"]] - ) - else: - env["PKG_CONFIG_PATH"] = openssl_prereq.pkg_config_location - return env - - def should_build(self, arch): - if Path(self.python_exe).exists(): - # no need to build, but we must set hostpython for our Context - self.ctx.hostpython = self.python_exe - return False - return True - - def get_build_container_dir(self, arch=None): - choices = self.check_recipe_choices() - dir_name = '-'.join([self.name] + choices) - return join(self.ctx.build_dir, 'other_builds', dir_name, 'desktop') - - def get_build_dir(self, arch=None): - ''' - .. note:: Unlike other recipes, the hostpython build dir doesn't - depend on the target arch - ''' - return join(self.get_build_container_dir(), self.name) - - def get_path_to_python(self): - return join(self.get_build_dir(), self.build_subdir) - - @property - def site_root(self): - return join(self.get_path_to_python(), "root") - - @property - def site_bin(self): - return join(self.site_root, self.site_dir, "bin") - - @property - def local_bin(self): - return join(self.site_root, "usr/local/bin/") - - @property - def site_dir(self): - p_version = Version(self.version) - return join( - self.site_root, - f"usr/local/lib/python{p_version.major}.{p_version.minor}/site-packages/" - ) - - def build_arch(self, arch): - env = self.get_recipe_env(arch) - - recipe_build_dir = self.get_build_dir(arch.arch) - - # Create a subdirectory to actually perform the build - build_dir = join(recipe_build_dir, self.build_subdir) - ensure_dir(build_dir) - - # Configure the build - build_configured = False - with current_directory(build_dir): - if not Path('config.status').exists(): - shprint(sh.Command(join(recipe_build_dir, 'configure')), _env=env) - build_configured = True - - with current_directory(recipe_build_dir): - # Create the Setup file. This copying from Setup.dist is - # the normal and expected procedure before Python 3.8, but - # after this the file with default options is already named "Setup" - setup_dist_location = join('Modules', 'Setup.dist') - if Path(setup_dist_location).exists(): - shprint(sh.cp, setup_dist_location, - join(build_dir, 'Modules', 'Setup')) - else: - # Check the expected file does exist - setup_location = join('Modules', 'Setup') - if not Path(setup_location).exists(): - raise BuildInterruptingException( - SETUP_DIST_NOT_FIND_MESSAGE - ) - - shprint(sh.make, '-j', str(cpu_count()), '-C', build_dir, _env=env) - - # make a copy of the python executable giving it the name we want, - # because we got different python's executable names depending on - # the fs being case-insensitive (Mac OS X, Cygwin...) or - # case-sensitive (linux)...so this way we will have an unique name - # for our hostpython, regarding the used fs - for exe_name in ['python.exe', 'python']: - exe = join(self.get_path_to_python(), exe_name) - if Path(exe).is_file(): - shprint(sh.cp, exe, self.python_exe) - break - - ensure_dir(self.site_root) - self.ctx.hostpython = self.python_exe - if build_configured: - print("RUNNING ENSUREPIP:"+self.site_root) - shprint( - sh.Command(self.python_exe), "-m", "ensurepip", "--root", self.site_root, "-U", - _env={"HOME": "/tmp"} - ) - print("RAN ENSUREPIP") - - -recipe = HostPython3Recipe() \ No newline at end of file diff --git a/recipes/hostpython3/patches/pyconfig_detection.patch b/recipes/hostpython3/patches/pyconfig_detection.patch deleted file mode 100644 index a19b468..0000000 --- a/recipes/hostpython3/patches/pyconfig_detection.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff -Nru Python-3.8.2/Lib/site.py Python-3.8.2-new/Lib/site.py ---- Python-3.8.2/Lib/site.py 2020-04-28 12:48:38.000000000 -0700 -+++ Python-3.8.2-new/Lib/site.py 2020-04-28 12:52:46.000000000 -0700 -@@ -487,7 +487,8 @@ - if key == 'include-system-site-packages': - system_site = value.lower() - elif key == 'home': -- sys._home = value -+ # this is breaking pyconfig.h path detection with venv -+ print('Ignoring "sys._home = value" override', file=sys.stderr) - - sys.prefix = sys.exec_prefix = site_prefix - \ No newline at end of file diff --git a/recipes/jpeg/Application.mk b/recipes/jpeg/Application.mk deleted file mode 100644 index 5942a03..0000000 --- a/recipes/jpeg/Application.mk +++ /dev/null @@ -1,4 +0,0 @@ -APP_OPTIM := release -APP_ABI := all # or armeabi -APP_MODULES := libjpeg -APP_ALLOW_MISSING_DEPS := true diff --git a/recipes/jpeg/__init__.py b/recipes/jpeg/__init__.py deleted file mode 100644 index 33a9ba4..0000000 --- a/recipes/jpeg/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -from pythonforandroid.recipe import Recipe -from pythonforandroid.logger import shprint -from pythonforandroid.util import current_directory -from os.path import join -import sh - - -class JpegRecipe(Recipe): - ''' - .. versionchanged:: 0.6.0 - rewrote recipe to be build with clang and updated libraries to latest - version of the official git repo. - ''' - name = 'jpeg' - version = '2.0.1' - url = 'https://github.com/libjpeg-turbo/libjpeg-turbo/archive/{version}.tar.gz' # noqa - built_libraries = {'libjpeg.a': '.', 'libturbojpeg.a': '.'} - # we will require this below patch to build the shared library - # patches = ['remove-version.patch'] - - def build_arch(self, arch): - build_dir = self.get_build_dir(arch.arch) - - # TODO: Fix simd/neon - with current_directory(build_dir): - env = self.get_recipe_env(arch) - toolchain_file = join(self.ctx.ndk_dir, - 'build/cmake/android.toolchain.cmake') - - shprint(sh.rm, '-rf', 'CMakeCache.txt', 'CMakeFiles/') - shprint(sh.cmake, '-G', 'Unix Makefiles', - '-DCMAKE_SYSTEM_NAME=Android', - '-DCMAKE_POSITION_INDEPENDENT_CODE=1', - '-DCMAKE_ANDROID_ARCH_ABI={arch}'.format(arch=arch.arch), - '-DCMAKE_ANDROID_NDK=' + self.ctx.ndk_dir, - '-DCMAKE_C_COMPILER={cc}'.format(cc=arch.get_clang_exe()), - '-DCMAKE_CXX_COMPILER={cc_plus}'.format( - cc_plus=arch.get_clang_exe(plus_plus=True)), - '-DCMAKE_BUILD_TYPE=Release', - '-DCMAKE_INSTALL_PREFIX=./install', - '-DCMAKE_TOOLCHAIN_FILE=' + toolchain_file, - - '-DANDROID_ABI={arch}'.format(arch=arch.arch), - '-DANDROID_ARM_NEON=ON', - '-DENABLE_NEON=ON', - # '-DREQUIRE_SIMD=1', - - # Force disable shared, with the static ones is enough - '-DENABLE_SHARED=0', - '-DENABLE_STATIC=1', - - # Fix cmake compatibility issue - '-DCMAKE_POLICY_VERSION_MINIMUM=3.5', - _env=env) - shprint(sh.make, _env=env) - - -recipe = JpegRecipe() diff --git a/recipes/jpeg/build-static.patch b/recipes/jpeg/build-static.patch deleted file mode 100644 index 0aa9c70..0000000 --- a/recipes/jpeg/build-static.patch +++ /dev/null @@ -1,85 +0,0 @@ -diff -Naur jpeg/Android.mk b/Android.mk ---- jpeg/Android.mk 2015-12-14 11:37:25.900190235 -0600 -+++ b/Android.mk 2015-12-14 11:41:27.532182210 -0600 -@@ -54,8 +54,7 @@ - - LOCAL_SRC_FILES:= $(libjpeg_SOURCES_DIST) - --LOCAL_SHARED_LIBRARIES := libcutils --LOCAL_STATIC_LIBRARIES := libsimd -+LOCAL_STATIC_LIBRARIES := libsimd libcutils - - LOCAL_C_INCLUDES := $(LOCAL_PATH) - -@@ -68,7 +67,7 @@ - - LOCAL_MODULE := libjpeg - --include $(BUILD_SHARED_LIBRARY) -+include $(BUILD_STATIC_LIBRARY) - - ###################################################### - ### cjpeg ### -@@ -82,7 +81,7 @@ - - LOCAL_SRC_FILES:= $(cjpeg_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) \ - $(LOCAL_PATH)/android -@@ -110,7 +109,7 @@ - - LOCAL_SRC_FILES:= $(djpeg_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) \ - $(LOCAL_PATH)/android -@@ -137,7 +136,7 @@ - - LOCAL_SRC_FILES:= $(jpegtran_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) \ - $(LOCAL_PATH)/android -@@ -163,7 +162,7 @@ - - LOCAL_SRC_FILES:= $(tjunittest_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) - -@@ -189,7 +188,7 @@ - - LOCAL_SRC_FILES:= $(tjbench_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) - -@@ -215,7 +214,7 @@ - - LOCAL_SRC_FILES:= $(rdjpgcom_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) - -@@ -240,7 +239,7 @@ - - LOCAL_SRC_FILES:= $(wrjpgcom_SOURCES) - --LOCAL_SHARED_LIBRARIES := libjpeg -+LOCAL_STATIC_LIBRARIES := libjpeg - - LOCAL_C_INCLUDES := $(LOCAL_PATH) - diff --git a/recipes/jpeg/remove-version.patch b/recipes/jpeg/remove-version.patch deleted file mode 100644 index 311aa33..0000000 --- a/recipes/jpeg/remove-version.patch +++ /dev/null @@ -1,12 +0,0 @@ ---- jpeg/CMakeLists.txt.orig 2018-11-12 20:20:28.000000000 +0100 -+++ jpeg/CMakeLists.txt 2018-12-14 12:43:45.338704504 +0100 -@@ -573,6 +573,9 @@ - add_library(turbojpeg SHARED ${TURBOJPEG_SOURCES}) - set_property(TARGET turbojpeg PROPERTY COMPILE_FLAGS - "-DBMP_SUPPORTED -DPPM_SUPPORTED") -+ set_property(TARGET jpeg PROPERTY NO_SONAME 1) -+ set_property(TARGET turbojpeg PROPERTY NO_SONAME 1) -+ set(CMAKE_SHARED_LIBRARY_SONAME_C_FLAG "") - if(WIN32) - set_target_properties(turbojpeg PROPERTIES DEFINE_SYMBOL DLLDEFINE) - endif() diff --git a/recipes/lxst/__init__.py b/recipes/lxst/__init__.py deleted file mode 100644 index ec6639c..0000000 --- a/recipes/lxst/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Android Bluetooth Low Energy -""" -from pythonforandroid.recipe import PythonRecipe -from pythonforandroid.toolchain import current_directory, info, shprint -import sh -from os.path import join - - -class LXSTRecipe(PythonRecipe): - name = 'lxst_recipe' - depends = ['python3', 'setuptools', 'android', "cffi"] - call_hostpython_via_targetpython = False - install_in_hostpython = True - - def prepare_build_dir(self, arch): - build_dir = self.get_build_dir(arch) - assert build_dir.endswith(self.name) - shprint(sh.rm, '-rf', build_dir) - shprint(sh.mkdir, build_dir) - - srcs = ('/home/markqvist/Information/Source/LXST/LXST', '/home/markqvist/Information/Source/LXST/setup.py', '/home/markqvist/Information/Source/LXST/README.md') - - for filename in srcs: - print(f"Copy {join(self.get_recipe_dir(), filename)} to {build_dir}") - shprint(sh.cp, '-a', join(self.get_recipe_dir(), filename), - build_dir) - - def postbuild_arch(self, arch): - super(LXSTRecipe, self).postbuild_arch(arch) - info("LXST native build completed") - -recipe = LXSTRecipe() diff --git a/recipes/mffmpeg/__init__.py b/recipes/mffmpeg/__init__.py new file mode 100644 index 0000000..a51a246 --- /dev/null +++ b/recipes/mffmpeg/__init__.py @@ -0,0 +1,152 @@ +from pythonforandroid.toolchain import Recipe, current_directory, shprint +from os.path import exists, join, realpath +import sh + + +class FFMpegRecipe(Recipe): + version = 'n4.3.1' + # Moved to github.com instead of ffmpeg.org to improve download speed + url = 'https://github.com/FFmpeg/FFmpeg/archive/{version}.zip' + depends = ['sdl2'] # Need this to build correct recipe order + opts_depends = ['openssl', 'ffpyplayer_codecs'] + patches = ['patches/configure.patch'] + + def should_build(self, arch): + build_dir = self.get_build_dir(arch.arch) + return not exists(join(build_dir, 'lib', 'libavcodec.so')) + + def prebuild_arch(self, arch): + self.apply_patches(arch) + + def get_recipe_env(self, arch): + env = super().get_recipe_env(arch) + env['NDK'] = self.ctx.ndk_dir + return env + + def build_arch(self, arch): + with current_directory(self.get_build_dir(arch.arch)): + env = arch.get_env() + + # flags = ['--disable-everything'] + flags = [] + cflags = [] + ldflags = [] + + if 'openssl' in self.ctx.recipe_build_order: + flags += [ + '--enable-openssl', + '--enable-nonfree', + '--enable-protocol=https,tls_openssl', + ] + build_dir = Recipe.get_recipe( + 'openssl', self.ctx).get_build_dir(arch.arch) + cflags += ['-I' + build_dir + '/include/', + '-DOPENSSL_API_COMPAT=0x10002000L'] + ldflags += ['-L' + build_dir] + + if 'ffpyplayer_codecs' in self.ctx.recipe_build_order: + # Enable GPL + flags += ['--enable-gpl'] + + # libx264 + flags += ['--enable-libx264'] + build_dir = Recipe.get_recipe( + 'libx264', self.ctx).get_build_dir(arch.arch) + cflags += ['-I' + build_dir + '/include/'] + ldflags += ['-lx264', '-L' + build_dir + '/lib/'] + + # libshine + flags += ['--enable-libshine'] + build_dir = Recipe.get_recipe('libshine', self.ctx).get_build_dir(arch.arch) + cflags += ['-I' + build_dir + '/include/'] + ldflags += ['-lshine', '-L' + build_dir + '/lib/'] + ldflags += ['-lm'] + + # libvpx + flags += ['--enable-libvpx'] + build_dir = Recipe.get_recipe( + 'libvpx', self.ctx).get_build_dir(arch.arch) + cflags += ['-I' + build_dir + '/include/'] + ldflags += ['-lvpx', '-L' + build_dir + '/lib/'] + + # Enable all codecs: + flags += [ + '--enable-parsers', + '--enable-decoders', + '--enable-encoders', + '--enable-muxers', + '--enable-demuxers', + ] + else: + # Enable codecs only for .mp4: + flags += [ + '--enable-parser=aac,ac3,h261,h264,mpegaudio,mpeg4video,mpegvideo,vc1', + '--enable-decoder=aac,h264,mpeg4,mpegvideo', + '--enable-muxer=h264,mov,mp4,mpeg2video', + '--enable-demuxer=aac,h264,m4v,mov,mpegvideo,vc1,rtsp', + ] + + # needed to prevent _ffmpeg.so: version node not found for symbol av_init_packet@LIBAVFORMAT_52 + # /usr/bin/ld: failed to set dynamic section sizes: Bad value + flags += [ + '--disable-symver', + ] + + # disable binaries / doc + flags += [ + # '--disable-programs', + '--disable-doc', + ] + + # other flags: + flags += [ + '--enable-filter=aresample,resample,crop,adelay,volume,scale', + '--enable-protocol=file,http,hls,udp,tcp', + '--enable-small', + '--enable-hwaccels', + '--enable-pic', + '--disable-static', + '--disable-debug', + '--enable-shared', + ] + + if 'arm64' in arch.arch: + arch_flag = 'aarch64' + elif 'x86' in arch.arch: + arch_flag = 'x86' + flags += ['--disable-asm'] + else: + arch_flag = 'arm' + + # android: + flags += [ + '--target-os=android', + '--enable-cross-compile', + '--cross-prefix={}-'.format(arch.target), + '--arch={}'.format(arch_flag), + '--strip={}'.format(self.ctx.ndk.llvm_strip), + '--sysroot={}'.format(self.ctx.ndk.sysroot), + '--enable-neon', + '--prefix={}'.format(realpath('.')), + ] + + if arch_flag == 'arm': + cflags += [ + '-mfpu=vfpv3-d16', + '-mfloat-abi=softfp', + '-fPIC', + ] + + env['CFLAGS'] += ' ' + ' '.join(cflags) + env['LDFLAGS'] += ' ' + ' '.join(ldflags) + + configure = sh.Command('./configure') + shprint(configure, *flags, _env=env) + shprint(sh.make, '-j4', _env=env) + shprint(sh.make, 'install', _env=env) + # copy libs: + sh.cp('-a', sh.glob('./lib/lib*.so'), + self.ctx.get_libs_dir(arch.arch)) + + +recipe = FFMpegRecipe() diff --git a/recipes/mffmpeg/patches/configure.patch b/recipes/mffmpeg/patches/configure.patch new file mode 100644 index 0000000..cacf029 --- /dev/null +++ b/recipes/mffmpeg/patches/configure.patch @@ -0,0 +1,11 @@ +--- ./configure 2020-10-11 19:12:16.759760904 +0200 ++++ ./configure.patch 2020-10-11 19:15:49.059533563 +0200 +@@ -6361,7 +6361,7 @@ + enabled librsvg && require_pkg_config librsvg librsvg-2.0 librsvg-2.0/librsvg/rsvg.h rsvg_handle_render_cairo + enabled librtmp && require_pkg_config librtmp librtmp librtmp/rtmp.h RTMP_Socket + enabled librubberband && require_pkg_config librubberband "rubberband >= 1.8.1" rubberband/rubberband-c.h rubberband_new -lstdc++ && append librubberband_extralibs "-lstdc++" +-enabled libshine && require_pkg_config libshine shine shine/layer3.h shine_encode_buffer ++enabled libshine && require "shine" shine/layer3.h shine_encode_buffer -lshine -lm + enabled libsmbclient && { check_pkg_config libsmbclient smbclient libsmbclient.h smbc_init || + require libsmbclient libsmbclient.h smbc_init -lsmbclient; } + enabled libsnappy && require libsnappy snappy-c.h snappy_compress -lsnappy -lstdc++ \ No newline at end of file diff --git a/recipes/numpy/__init__.py b/recipes/numpy/__init__.py new file mode 100644 index 0000000..55a0279 --- /dev/null +++ b/recipes/numpy/__init__.py @@ -0,0 +1,75 @@ +from pythonforandroid.recipe import CompiledComponentsPythonRecipe +from pythonforandroid.logger import shprint, info +from pythonforandroid.util import current_directory +from multiprocessing import cpu_count +from os.path import join +import glob +import sh +import shutil + + +class NumpyRecipe(CompiledComponentsPythonRecipe): + + version = '1.22.3' + url = 'https://pypi.python.org/packages/source/n/numpy/numpy-{version}.zip' + site_packages_name = 'numpy' + depends = ['setuptools', 'cython'] + install_in_hostpython = True + call_hostpython_via_targetpython = False + + patches = [ + join("patches", "remove-default-paths.patch"), + join("patches", "add_libm_explicitly_to_build.patch"), + join("patches", "ranlib.patch"), + ] + + def get_recipe_env(self, arch=None, with_flags_in_cc=True): + env = super().get_recipe_env(arch, with_flags_in_cc) + + # _PYTHON_HOST_PLATFORM declares that we're cross-compiling + # and avoids issues when building on macOS for Android targets. + env["_PYTHON_HOST_PLATFORM"] = arch.command_prefix + + # NPY_DISABLE_SVML=1 allows numpy to build for non-AVX512 CPUs + # See: https://github.com/numpy/numpy/issues/21196 + env["NPY_DISABLE_SVML"] = "1" + + return env + + def _build_compiled_components(self, arch): + info('Building compiled components in {}'.format(self.name)) + + env = self.get_recipe_env(arch) + with current_directory(self.get_build_dir(arch.arch)): + hostpython = sh.Command(self.hostpython_location) + shprint(hostpython, 'setup.py', self.build_cmd, '-v', + _env=env, *self.setup_extra_args) + build_dir = glob.glob('build/lib.*')[0] + shprint(sh.find, build_dir, '-name', '"*.o"', '-exec', + env['STRIP'], '{}', ';', _env=env) + + def _rebuild_compiled_components(self, arch, env): + info('Rebuilding compiled components in {}'.format(self.name)) + + hostpython = sh.Command(self.real_hostpython_location) + shprint(hostpython, 'setup.py', 'clean', '--all', '--force', _env=env) + shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env, + *self.setup_extra_args) + + def build_compiled_components(self, arch): + self.setup_extra_args = ['-j', str(cpu_count())] + self._build_compiled_components(arch) + self.setup_extra_args = [] + + def rebuild_compiled_components(self, arch, env): + self.setup_extra_args = ['-j', str(cpu_count())] + self._rebuild_compiled_components(arch, env) + self.setup_extra_args = [] + + def get_hostrecipe_env(self, arch): + env = super().get_hostrecipe_env(arch) + env['RANLIB'] = shutil.which('ranlib') + return env + + +recipe = NumpyRecipe() diff --git a/recipes/numpy/patches/add_libm_explicitly_to_build.patch b/recipes/numpy/patches/add_libm_explicitly_to_build.patch new file mode 100644 index 0000000..f9ba9e9 --- /dev/null +++ b/recipes/numpy/patches/add_libm_explicitly_to_build.patch @@ -0,0 +1,20 @@ +diff --git a/numpy/linalg/setup.py b/numpy/linalg/setup.py +index 66c07c9..d34bd93 100644 +--- a/numpy/linalg/setup.py ++++ b/numpy/linalg/setup.py +@@ -46,6 +46,7 @@ def configuration(parent_package='', top_path=None): + sources=['lapack_litemodule.c', get_lapack_lite_sources], + depends=['lapack_lite/f2c.h'], + extra_info=lapack_info, ++ libraries=['m'], + ) + + # umath_linalg module +@@ -54,7 +54,7 @@ def configuration(parent_package='', top_path=None): + sources=['umath_linalg.c.src', get_lapack_lite_sources], + depends=['lapack_lite/f2c.h'], + extra_info=lapack_info, +- libraries=['npymath'], ++ libraries=['npymath', 'm'], + ) + return config diff --git a/recipes/numpy/patches/ranlib.patch b/recipes/numpy/patches/ranlib.patch new file mode 100644 index 0000000..c0b5dad --- /dev/null +++ b/recipes/numpy/patches/ranlib.patch @@ -0,0 +1,11 @@ +diff -Naur numpy.orig/numpy/distutils/unixccompiler.py numpy/numpy/distutils/unixccompiler.py +--- numpy.orig/numpy/distutils/unixccompiler.py 2022-05-28 10:22:10.000000000 +0200 ++++ numpy/numpy/distutils/unixccompiler.py 2022-05-28 10:22:24.000000000 +0200 +@@ -124,6 +124,7 @@ + # platform intelligence here to skip ranlib if it's not + # needed -- or maybe Python's configure script took care of + # it for us, hence the check for leading colon. ++ self.ranlib = [os.environ.get('RANLIB')] + if self.ranlib: + display = '%s:@ %s' % (os.path.basename(self.ranlib[0]), + output_filename) diff --git a/recipes/numpy/patches/remove-default-paths.patch b/recipes/numpy/patches/remove-default-paths.patch new file mode 100644 index 0000000..3581f0f --- /dev/null +++ b/recipes/numpy/patches/remove-default-paths.patch @@ -0,0 +1,28 @@ +diff --git a/numpy/distutils/system_info.py b/numpy/distutils/system_info.py +index fc7018a..7b514bc 100644 +--- a/numpy/distutils/system_info.py ++++ b/numpy/distutils/system_info.py +@@ -340,10 +340,10 @@ if os.path.join(sys.prefix, 'lib') not in default_lib_dirs: + default_include_dirs.append(os.path.join(sys.prefix, 'include')) + default_src_dirs.append(os.path.join(sys.prefix, 'src')) + +-default_lib_dirs = [_m for _m in default_lib_dirs if os.path.isdir(_m)] +-default_runtime_dirs = [_m for _m in default_runtime_dirs if os.path.isdir(_m)] +-default_include_dirs = [_m for _m in default_include_dirs if os.path.isdir(_m)] +-default_src_dirs = [_m for _m in default_src_dirs if os.path.isdir(_m)] ++default_lib_dirs = [] #[_m for _m in default_lib_dirs if os.path.isdir(_m)] ++default_runtime_dirs =[] # [_m for _m in default_runtime_dirs if os.path.isdir(_m)] ++default_include_dirs =[] # [_m for _m in default_include_dirs if os.path.isdir(_m)] ++default_src_dirs =[] # [_m for _m in default_src_dirs if os.path.isdir(_m)] + + so_ext = get_shared_lib_extension() + +@@ -814,7 +814,7 @@ class system_info(object): + path = self.get_paths(self.section, key) + if path == ['']: + path = [] +- return path ++ return [] + + def get_include_dirs(self, key='include_dirs'): + return self.get_paths(self.section, key) diff --git a/recipes/pycodec2/__init__.py b/recipes/pycodec2/__init__.py index 5b71a80..6ad05bd 100644 --- a/recipes/pycodec2/__init__.py +++ b/recipes/pycodec2/__init__.py @@ -5,7 +5,7 @@ import sh # class PyCodec2Recipe(IncludedFilesBehaviour, CythonRecipe): class PyCodec2Recipe(CythonRecipe): - url = "https://github.com/markqvist/pycodec2/archive/438ee4f2f3ee30635a34caddf520cfaccdbbc646.zip" + url = "https://github.com/markqvist/pycodec2/archive/refs/heads/main.zip" # src_filename = "../../../pycodec2" depends = ["setuptools", "numpy", "Cython", "codec2"] call_hostpython_via_targetpython = False diff --git a/recipes/pyjnius/__init__.py b/recipes/pyjnius/__init__.py deleted file mode 100644 index 86d8803..0000000 --- a/recipes/pyjnius/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -from pythonforandroid.recipe import PyProjectRecipe -from pythonforandroid.toolchain import shprint, current_directory, info -from pythonforandroid.patching import will_build -import sh -from os.path import join - - -class PyjniusRecipe(PyProjectRecipe): - version = '1.7.0' - url = 'https://github.com/kivy/pyjnius/archive/{version}.zip' - name = 'pyjnius' - depends = [('genericndkbuild', 'sdl2', 'sdl3'), 'six'] - site_packages_name = 'jnius' - hostpython_prerequisites = ["Cython<3.2"] - patches = [ - "use_cython.patch", - ('genericndkbuild_jnienv_getter.patch', will_build('genericndkbuild')), - ('sdl3_jnienv_getter.patch', will_build('sdl3')), - ] - - def get_recipe_env(self, arch, **kwargs): - env = super().get_recipe_env(arch, **kwargs) - - # Taken from CythonRecipe - env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format( - self.ctx.get_libs_dir(arch.arch) + - ' -L{} '.format(self.ctx.libs_dir) + - ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local', - arch.arch))) - env['LDSHARED'] = env['CC'] + ' -shared' - env['LIBLINK'] = 'NOTNONE' - - # NDKPLATFORM is our switch for detecting Android platform, so can't be None - env['NDKPLATFORM'] = "NOTNONE" - return env - - def postbuild_arch(self, arch): - super().postbuild_arch(arch) - info('Copying pyjnius java class to classes build dir') - with current_directory(self.get_build_dir(arch.arch)): - shprint(sh.cp, '-a', join('jnius', 'src', 'org'), self.ctx.javaclass_dir) - - -recipe = PyjniusRecipe() diff --git a/recipes/pyjnius/genericndkbuild_jnienv_getter.patch b/recipes/pyjnius/genericndkbuild_jnienv_getter.patch deleted file mode 100644 index fcd5387..0000000 --- a/recipes/pyjnius/genericndkbuild_jnienv_getter.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff -Naur pyjnius.orig/jnius/env.py pyjnius/jnius/env.py ---- pyjnius.orig/jnius/env.py 2022-05-28 11:16:02.000000000 +0200 -+++ pyjnius/jnius/env.py 2022-05-28 11:18:30.000000000 +0200 -@@ -268,7 +268,7 @@ - - class AndroidJavaLocation(UnixJavaLocation): - def get_libraries(self): -- return ['SDL2', 'log'] -+ return ['main', 'log'] - - def get_include_dirs(self): - # When cross-compiling for Android, we should not use the include dirs -diff -Naur pyjnius.orig/jnius/jnius_jvm_android.pxi pyjnius/jnius/jnius_jvm_android.pxi ---- pyjnius.orig/jnius/jnius_jvm_android.pxi 2022-05-28 11:16:02.000000000 +0200 -+++ pyjnius/jnius/jnius_jvm_android.pxi 2022-05-28 11:17:17.000000000 +0200 -@@ -1,6 +1,6 @@ - # on android, rely on SDL to get the JNI env --cdef extern JNIEnv *SDL_AndroidGetJNIEnv() -+cdef extern JNIEnv *WebView_AndroidGetJNIEnv() - - - cdef JNIEnv *get_platform_jnienv() except NULL: -- return SDL_AndroidGetJNIEnv() -+ return WebView_AndroidGetJNIEnv() diff --git a/recipes/pyjnius/sdl3_jnienv_getter.patch b/recipes/pyjnius/sdl3_jnienv_getter.patch deleted file mode 100644 index d91da76..0000000 --- a/recipes/pyjnius/sdl3_jnienv_getter.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff -Naur pyjnius.orig/jnius/env.py pyjnius/jnius/env.py ---- pyjnius.orig/jnius/env.py 2022-05-28 11:16:02.000000000 +0200 -+++ pyjnius/jnius/env.py 2022-05-28 11:18:30.000000000 +0200 -@@ -268,7 +268,7 @@ - - class AndroidJavaLocation(UnixJavaLocation): - def get_libraries(self): -- return ['SDL2', 'log'] -+ return ['SDL3', 'log'] - - def get_include_dirs(self): - # When cross-compiling for Android, we should not use the include dirs -diff -Naur pyjnius.orig/jnius/jnius_jvm_android.pxi pyjnius/jnius/jnius_jvm_android.pxi ---- pyjnius.orig/jnius/jnius_jvm_android.pxi 2022-05-28 11:16:02.000000000 +0200 -+++ pyjnius/jnius/jnius_jvm_android.pxi 2022-05-28 11:17:17.000000000 +0200 -@@ -1,6 +1,6 @@ - # on android, rely on SDL to get the JNI env --cdef extern JNIEnv *SDL_AndroidGetJNIEnv() -+cdef extern JNIEnv *SDL_GetAndroidJNIEnv() - - - cdef JNIEnv *get_platform_jnienv() except NULL: -- return SDL_AndroidGetJNIEnv() -+ return SDL_GetAndroidJNIEnv() diff --git a/recipes/pyjnius/use_cython.patch b/recipes/pyjnius/use_cython.patch deleted file mode 100644 index 59265e9..0000000 --- a/recipes/pyjnius/use_cython.patch +++ /dev/null @@ -1,13 +0,0 @@ ---- pyjnius-1.6.1/setup.py 2023-11-05 21:07:43.000000000 +0530 -+++ pyjnius-1.6.1.mod/setup.py 2025-03-01 14:47:11.964847337 +0530 -@@ -59,10 +59,6 @@ - if NDKPLATFORM is not None and getenv('LIBLINK'): - PLATFORM = 'android' - --# detect platform --if PLATFORM == 'android': -- PYX_FILES = [fn[:-3] + 'c' for fn in PYX_FILES] -- - JAVA=get_java_setup(PLATFORM) - - assert JAVA.is_jdk(), "You need a JDK, we only found a JRE. Try setting JAVA_HOME" diff --git a/recipes/python3/__init__.py b/recipes/python3/__init__.py deleted file mode 100644 index 2a2e07d..0000000 --- a/recipes/python3/__init__.py +++ /dev/null @@ -1,445 +0,0 @@ -import glob -import sh -import subprocess - -from os import environ, utime -from os.path import dirname, exists, join -from pathlib import Path -import shutil - -from pythonforandroid.logger import info, warning, shprint -from pythonforandroid.patching import version_starts_with -from pythonforandroid.recipe import Recipe, TargetPythonRecipe -from pythonforandroid.util import ( - current_directory, - ensure_dir, - walk_valid_filens, - BuildInterruptingException, -) - -NDK_API_LOWER_THAN_SUPPORTED_MESSAGE = ( - 'Target ndk-api is {ndk_api}, ' - 'but the python3 recipe supports only {min_ndk_api}+' -) - - -class Python3Recipe(TargetPythonRecipe): - ''' - The python3's recipe - ^^^^^^^^^^^^^^^^^^^^ - - The python 3 recipe can be built with some extra python modules, but to do - so, we need some libraries. By default, we ship the python3 recipe with - some common libraries, defined in ``depends``. We also support some optional - libraries, which are less common that the ones defined in ``depends``, so - we added them as optional dependencies (``opt_depends``). - - Below you have a relationship between the python modules and the recipe - libraries:: - - - _ctypes: you must add the recipe for ``libffi``. - - _sqlite3: you must add the recipe for ``sqlite3``. - - _ssl: you must add the recipe for ``openssl``. - - _bz2: you must add the recipe for ``libbz2`` (optional). - - _lzma: you must add the recipe for ``liblzma`` (optional). - - .. note:: This recipe can be built only against API 21+. - - .. versionchanged:: 2019.10.06.post0 - - Refactored from deleted class ``python.GuestPythonRecipe`` into here - - Added optional dependencies: :mod:`~pythonforandroid.recipes.libbz2` - and :mod:`~pythonforandroid.recipes.liblzma` - - .. versionchanged:: 0.6.0 - Refactored into class - :class:`~pythonforandroid.python.GuestPythonRecipe` - ''' - - version = '3.11.5' - url = 'https://www.python.org/ftp/python/{version}/Python-{version}.tgz' - name = 'python3' - - patches = [ - 'patches/pyconfig_detection.patch', - 'patches/reproducible-buildinfo.diff', - - # Python 3.7.1 - ('patches/py3.7.1_fix-ctypes-util-find-library.patch', version_starts_with("3.7")), - ('patches/py3.7.1_fix-zlib-version.patch', version_starts_with("3.7")), - - # Python 3.8.1 & 3.9.X - ('patches/py3.8.1.patch', version_starts_with("3.8")), - ('patches/py3.8.1.patch', version_starts_with("3.9")), - ('patches/py3.8.1.patch', version_starts_with("3.10")), - ('patches/cpython-311-ctypes-find-library.patch', version_starts_with("3.11")), - ] - - if shutil.which('lld') is not None: - patches += [ - ("patches/py3.7.1_fix_cortex_a8.patch", version_starts_with("3.7")), - ("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.8")), - ("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.9")), - ("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.10")), - ("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.11")), - ] - - depends = ['hostpython3', 'sqlite3', 'openssl', 'libffi'] - # those optional depends allow us to build python compression modules: - # - _bz2.so - # - _lzma.so - opt_depends = ['libbz2', 'liblzma'] - '''The optional libraries which we would like to get our python linked''' - - configure_args = ( - '--host={android_host}', - '--build={android_build}', - '--enable-shared', - '--enable-ipv6', - 'ac_cv_file__dev_ptmx=yes', - 'ac_cv_file__dev_ptc=no', - '--without-ensurepip', - 'ac_cv_little_endian_double=yes', - 'ac_cv_header_sys_eventfd_h=no', - '--prefix={prefix}', - '--exec-prefix={exec_prefix}', - '--enable-loadable-sqlite-extensions' - ) - - if version_starts_with("3.11"): - configure_args += ('--with-build-python={python_host_bin}',) - - '''The configure arguments needed to build the python recipe. Those are - used in method :meth:`build_arch` (if not overwritten like python3's - recipe does). - ''' - - MIN_NDK_API = 21 - '''Sets the minimal ndk api number needed to use the recipe. - - .. warning:: This recipe can be built only against API 21+, so it means - that any class which inherits from class:`GuestPythonRecipe` will have - this limitation. - ''' - - stdlib_dir_blacklist = { - '__pycache__', - 'test', - 'tests', - 'lib2to3', - 'ensurepip', - 'idlelib', - 'tkinter', - } - '''The directories that we want to omit for our python bundle''' - - stdlib_filen_blacklist = [ - '*.py', - '*.exe', - '*.whl', - ] - '''The file extensions that we want to blacklist for our python bundle''' - - site_packages_dir_blacklist = { - '__pycache__', - 'tests' - } - '''The directories from site packages dir that we don't want to be included - in our python bundle.''' - - site_packages_filen_blacklist = [ - '*.py' - ] - '''The file extensions from site packages dir that we don't want to be - included in our python bundle.''' - - compiled_extension = '.pyc' - '''the default extension for compiled python files. - - .. note:: the default extension for compiled python files has been .pyo for - python 2.x-3.4 but as of Python 3.5, the .pyo filename extension is no - longer used and has been removed in favour of extension .pyc - ''' - - def __init__(self, *args, **kwargs): - self._ctx = None - super().__init__(*args, **kwargs) - - @property - def _libpython(self): - '''return the python's library name (with extension)''' - return 'libpython{link_version}.so'.format( - link_version=self.link_version - ) - - @property - def link_version(self): - '''return the python's library link version e.g. 3.7m, 3.8''' - major, minor = self.major_minor_version_string.split('.') - flags = '' - if major == '3' and int(minor) < 8: - flags += 'm' - return '{major}.{minor}{flags}'.format( - major=major, - minor=minor, - flags=flags - ) - - def include_root(self, arch_name): - return join(self.get_build_dir(arch_name), 'Include') - - def link_root(self, arch_name): - return join(self.get_build_dir(arch_name), 'android-build') - - def should_build(self, arch): - return not Path(self.link_root(arch.arch), self._libpython).is_file() - - def prebuild_arch(self, arch): - super().prebuild_arch(arch) - self.ctx.python_recipe = self - - def get_recipe_env(self, arch=None, with_flags_in_cc=True): - env = super().get_recipe_env(arch) - env['HOSTARCH'] = arch.command_prefix - - env['CC'] = arch.get_clang_exe(with_target=True) - - env['PATH'] = ( - '{hostpython_dir}:{old_path}').format( - hostpython_dir=self.get_recipe( - 'host' + self.name, self.ctx).get_path_to_python(), - old_path=env['PATH']) - - env['CFLAGS'] = ' '.join( - [ - '-fPIC', - '-DANDROID' - ] - ) - - env['LDFLAGS'] = env.get('LDFLAGS', '') - if shutil.which('lld') is not None: - # Note: The -L. is to fix a bug in python 3.7. - # https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=234409 - env['LDFLAGS'] += ' -L. -fuse-ld=lld' - else: - warning('lld not found, linking without it. ' - 'Consider installing lld if linker errors occur.') - - return env - - def set_libs_flags(self, env, arch): - '''Takes care to properly link libraries with python depending on our - requirements and the attribute :attr:`opt_depends`. - ''' - def add_flags(include_flags, link_dirs, link_libs): - env['CPPFLAGS'] = env.get('CPPFLAGS', '') + include_flags - env['LDFLAGS'] = env.get('LDFLAGS', '') + link_dirs - env['LIBS'] = env.get('LIBS', '') + link_libs - - if 'sqlite3' in self.ctx.recipe_build_order: - info('Activating flags for sqlite3') - recipe = Recipe.get_recipe('sqlite3', self.ctx) - add_flags(' -I' + recipe.get_build_dir(arch.arch), - ' -L' + recipe.get_lib_dir(arch), ' -lsqlite3') - - if 'libffi' in self.ctx.recipe_build_order: - info('Activating flags for libffi') - recipe = Recipe.get_recipe('libffi', self.ctx) - # In order to force the correct linkage for our libffi library, we - # set the following variable to point where is our libffi.pc file, - # because the python build system uses pkg-config to configure it. - env['PKG_CONFIG_PATH'] = recipe.get_build_dir(arch.arch) - add_flags(' -I' + ' -I'.join(recipe.get_include_dirs(arch)), - ' -L' + join(recipe.get_build_dir(arch.arch), '.libs'), - ' -lffi') - - if 'openssl' in self.ctx.recipe_build_order: - info('Activating flags for openssl') - recipe = Recipe.get_recipe('openssl', self.ctx) - self.configure_args += \ - ('--with-openssl=' + recipe.get_build_dir(arch.arch),) - add_flags(recipe.include_flags(arch), - recipe.link_dirs_flags(arch), recipe.link_libs_flags()) - - for library_name in {'libbz2', 'liblzma'}: - if library_name in self.ctx.recipe_build_order: - info(f'Activating flags for {library_name}') - recipe = Recipe.get_recipe(library_name, self.ctx) - add_flags(recipe.get_library_includes(arch), - recipe.get_library_ldflags(arch), - recipe.get_library_libs_flag()) - - # python build system contains hardcoded zlib version which prevents - # the build of zlib module, here we search for android's zlib version - # and sets the right flags, so python can be build with android's zlib - info("Activating flags for android's zlib") - zlib_lib_path = arch.ndk_lib_dir_versioned - zlib_includes = self.ctx.ndk.sysroot_include_dir - zlib_h = join(zlib_includes, 'zlib.h') - try: - with open(zlib_h) as fileh: - zlib_data = fileh.read() - except IOError: - raise BuildInterruptingException( - "Could not determine android's zlib version, no zlib.h ({}) in" - " the NDK dir includes".format(zlib_h) - ) - for line in zlib_data.split('\n'): - if line.startswith('#define ZLIB_VERSION '): - break - else: - raise BuildInterruptingException( - 'Could not parse zlib.h...so we cannot find zlib version,' - 'required by python build,' - ) - env['ZLIB_VERSION'] = line.replace('#define ZLIB_VERSION ', '') - add_flags(' -I' + zlib_includes, ' -L' + zlib_lib_path, ' -lz') - - return env - - def build_arch(self, arch): - if self.ctx.ndk_api < self.MIN_NDK_API: - raise BuildInterruptingException( - NDK_API_LOWER_THAN_SUPPORTED_MESSAGE.format( - ndk_api=self.ctx.ndk_api, min_ndk_api=self.MIN_NDK_API - ), - ) - - recipe_build_dir = self.get_build_dir(arch.arch) - - # Create a subdirectory to actually perform the build - build_dir = join(recipe_build_dir, 'android-build') - ensure_dir(build_dir) - - # TODO: Get these dynamically, like bpo-30386 does - sys_prefix = '/usr/local' - sys_exec_prefix = '/usr/local' - - env = self.get_recipe_env(arch) - env = self.set_libs_flags(env, arch) - - android_build = sh.Command( - join(recipe_build_dir, - 'config.guess'))().strip() - - with current_directory(build_dir): - if not exists('config.status'): - shprint( - sh.Command(join(recipe_build_dir, 'configure')), - *(' '.join(self.configure_args).format( - android_host=env['HOSTARCH'], - android_build=android_build, - python_host_bin=join(self.get_recipe( - 'host' + self.name, self.ctx - ).get_path_to_python(), "python3"), - prefix=sys_prefix, - exec_prefix=sys_exec_prefix)).split(' '), - _env=env) - - # Python build does not seem to play well with make -j option from Python 3.11 and onwards - # Before losing some time, please check issue - # https://github.com/python/cpython/issues/101295 , as the root cause looks similar - shprint( - sh.make, - 'all', - 'INSTSONAME={lib_name}'.format(lib_name=self._libpython), - _env=env - ) - - # TODO: Look into passing the path to pyconfig.h in a - # better way, although this is probably acceptable - sh.cp('pyconfig.h', join(recipe_build_dir, 'Include')) - - def compile_python_files(self, dir): - ''' - Compile the python files (recursively) for the python files inside - a given folder. - - .. note:: python2 compiles the files into extension .pyo, but in - python3, and as of Python 3.5, the .pyo filename extension is no - longer used...uses .pyc (https://www.python.org/dev/peps/pep-0488) - ''' - args = [self.ctx.hostpython] - args += ['-OO', '-m', 'compileall', '-b', '-f', dir] - subprocess.call(args) - - def create_python_bundle(self, dirn, arch): - """ - Create a packaged python bundle in the target directory, by - copying all the modules and standard library to the right - place. - """ - # Todo: find a better way to find the build libs folder - modules_build_dir = join( - self.get_build_dir(arch.arch), - 'android-build', - 'build', - 'lib.linux{}-{}-{}'.format( - '2' if self.version[0] == '2' else '', - arch.command_prefix.split('-')[0], - self.major_minor_version_string - )) - - # Compile to *.pyc the python modules - self.compile_python_files(modules_build_dir) - # Compile to *.pyc the standard python library - self.compile_python_files(join(self.get_build_dir(arch.arch), 'Lib')) - # Compile to *.pyc the other python packages (site-packages) - self.compile_python_files(self.ctx.get_python_install_dir(arch.arch)) - - # Bundle compiled python modules to a folder - modules_dir = join(dirn, 'modules') - c_ext = self.compiled_extension - ensure_dir(modules_dir) - module_filens = (glob.glob(join(modules_build_dir, '*.so')) + - glob.glob(join(modules_build_dir, '*' + c_ext))) - info("Copy {} files into the bundle".format(len(module_filens))) - for filen in module_filens: - info(" - copy {}".format(filen)) - shutil.copy2(filen, modules_dir) - - # zip up the standard library - stdlib_zip = join(dirn, 'stdlib.zip') - with current_directory(join(self.get_build_dir(arch.arch), 'Lib')): - stdlib_filens = list(walk_valid_filens( - '.', self.stdlib_dir_blacklist, self.stdlib_filen_blacklist)) - if 'SOURCE_DATE_EPOCH' in environ: - # for reproducible builds - stdlib_filens.sort() - timestamp = int(environ['SOURCE_DATE_EPOCH']) - for filen in stdlib_filens: - utime(filen, (timestamp, timestamp)) - info("Zip {} files into the bundle".format(len(stdlib_filens))) - shprint(sh.zip, '-X', stdlib_zip, *stdlib_filens) - - # copy the site-packages into place - ensure_dir(join(dirn, 'site-packages')) - ensure_dir(self.ctx.get_python_install_dir(arch.arch)) - # TODO: Improve the API around walking and copying the files - with current_directory(self.ctx.get_python_install_dir(arch.arch)): - filens = list(walk_valid_filens( - '.', self.site_packages_dir_blacklist, - self.site_packages_filen_blacklist)) - info("Copy {} files into the site-packages".format(len(filens))) - for filen in filens: - info(" - copy {}".format(filen)) - ensure_dir(join(dirn, 'site-packages', dirname(filen))) - shutil.copy2(filen, join(dirn, 'site-packages', filen)) - - # copy the python .so files into place - python_build_dir = join(self.get_build_dir(arch.arch), - 'android-build') - python_lib_name = 'libpython' + self.link_version - shprint( - sh.cp, - join(python_build_dir, python_lib_name + '.so'), - join(self.ctx.bootstrap.dist_dir, 'libs', arch.arch) - ) - - info('Renaming .so files to reflect cross-compile') - self.reduce_object_file_names(join(dirn, 'site-packages')) - - return join(dirn, 'site-packages') - - -recipe = Python3Recipe() \ No newline at end of file diff --git a/recipes/python3/patches/cpython-311-ctypes-find-library.patch b/recipes/python3/patches/cpython-311-ctypes-find-library.patch deleted file mode 100644 index 7864d57..0000000 --- a/recipes/python3/patches/cpython-311-ctypes-find-library.patch +++ /dev/null @@ -1,19 +0,0 @@ ---- Python-3.11.5/Lib/ctypes/util.py 2023-08-24 17:39:18.000000000 +0530 -+++ Python-3.11.5.mod/Lib/ctypes/util.py 2023-11-18 22:12:17.356160615 +0530 -@@ -4,7 +4,15 @@ - import sys - - # find_library(name) returns the pathname of a library, or None. --if os.name == "nt": -+ -+# This patch overrides the find_library to look in the right places on -+# Android -+if True: -+ from android._ctypes_library_finder import find_library as _find_lib -+ def find_library(name): -+ return _find_lib(name) -+ -+elif os.name == "nt": - - def _get_build_version(): - """Return the version of MSVC that was used to build Python. diff --git a/recipes/python3/patches/py3.7.1_fix-ctypes-util-find-library.patch b/recipes/python3/patches/py3.7.1_fix-ctypes-util-find-library.patch deleted file mode 100644 index 494270d..0000000 --- a/recipes/python3/patches/py3.7.1_fix-ctypes-util-find-library.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py ---- a/Lib/ctypes/util.py -+++ b/Lib/ctypes/util.py -@@ -67,4 +67,11 @@ - return fname - return None - -+# This patch overrides the find_library to look in the right places on -+# Android -+if True: -+ from android._ctypes_library_finder import find_library as _find_lib -+ def find_library(name): -+ return _find_lib(name) -+ - elif os.name == "posix" and sys.platform == "darwin": diff --git a/recipes/python3/patches/py3.7.1_fix-zlib-version.patch b/recipes/python3/patches/py3.7.1_fix-zlib-version.patch deleted file mode 100644 index 0dbffae..0000000 --- a/recipes/python3/patches/py3.7.1_fix-zlib-version.patch +++ /dev/null @@ -1,12 +0,0 @@ ---- Python-3.7.1/setup.py.orig 2018-10-20 08:04:19.000000000 +0200 -+++ Python-3.7.1/setup.py 2019-02-17 00:24:30.715904412 +0100 -@@ -1410,7 +1410,8 @@ class PyBuildExt(build_ext): - if zlib_inc is not None: - zlib_h = zlib_inc[0] + '/zlib.h' - version = '"0.0.0"' -- version_req = '"1.1.3"' -+ version_req = '"{}"'.format( -+ os.environ.get('ZLIB_VERSION', '1.1.3')) - if host_platform == 'darwin' and is_macosx_sdk_path(zlib_h): - zlib_h = os.path.join(macosx_sdk_root(), zlib_h[1:]) - with open(zlib_h) as fp: diff --git a/recipes/python3/patches/py3.7.1_fix_cortex_a8.patch b/recipes/python3/patches/py3.7.1_fix_cortex_a8.patch deleted file mode 100644 index 5ddc3c4..0000000 --- a/recipes/python3/patches/py3.7.1_fix_cortex_a8.patch +++ /dev/null @@ -1,14 +0,0 @@ -This patch removes --fix-cortex-a8 from the linker flags in order to support linking -with lld, as lld does not support this flag (https://github.com/android-ndk/ndk/issues/766). -diff --git a/configure b/configure ---- a/configure -+++ b/configure -@@ -5671,7 +5671,7 @@ $as_echo_n "checking for the Android arm ABI... " >&6; } - $as_echo "$_arm_arch" >&6; } - if test "$_arm_arch" = 7; then - BASECFLAGS="${BASECFLAGS} -mfloat-abi=softfp -mfpu=vfpv3-d16" -- LDFLAGS="${LDFLAGS} -march=armv7-a -Wl,--fix-cortex-a8" -+ LDFLAGS="${LDFLAGS} -march=armv7-a" - fi - else - { $as_echo "$as_me:${as_lineno-$LINENO}: result: not Android" >&5 \ No newline at end of file diff --git a/recipes/python3/patches/py3.8.1.patch b/recipes/python3/patches/py3.8.1.patch deleted file mode 100644 index 6018805..0000000 --- a/recipes/python3/patches/py3.8.1.patch +++ /dev/null @@ -1,42 +0,0 @@ -diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py -index 97973bc..053c231 100644 ---- a/Lib/ctypes/util.py -+++ b/Lib/ctypes/util.py -@@ -67,6 +67,13 @@ if os.name == "nt": - return fname - return None - -+# This patch overrides the find_library to look in the right places on -+# Android -+if True: -+ from android._ctypes_library_finder import find_library as _find_lib -+ def find_library(name): -+ return _find_lib(name) -+ - elif os.name == "posix" and sys.platform == "darwin": - from ctypes.macholib.dyld import dyld_find as _dyld_find - def find_library(name): -diff --git a/configure b/configure -index 0914e24..dd00812 100755 ---- a/configure -+++ b/configure -@@ -18673,4 +18673,3 @@ if test "$Py_OPT" = 'false' -a "$Py_DEBUG" != 'true'; then - echo "" >&6 - echo "" >&6 - fi -- -diff --git a/setup.py b/setup.py -index 20d7f35..af15cc2 100644 ---- a/setup.py -+++ b/setup.py -@@ -1501,7 +1501,9 @@ class PyBuildExt(build_ext): - if zlib_inc is not None: - zlib_h = zlib_inc[0] + '/zlib.h' - version = '"0.0.0"' -- version_req = '"1.1.3"' -+ # version_req = '"1.1.3"' -+ version_req = '"{}"'.format( -+ os.environ.get('ZLIB_VERSION', '1.1.3')) - if MACOS and is_macosx_sdk_path(zlib_h): - zlib_h = os.path.join(macosx_sdk_root(), zlib_h[1:]) - with open(zlib_h) as fp: diff --git a/recipes/python3/patches/py3.8.1_fix_cortex_a8.patch b/recipes/python3/patches/py3.8.1_fix_cortex_a8.patch deleted file mode 100644 index 92a41b5..0000000 --- a/recipes/python3/patches/py3.8.1_fix_cortex_a8.patch +++ /dev/null @@ -1,15 +0,0 @@ -This patch removes --fix-cortex-a8 from the linker flags in order to support linking -with lld, as lld does not support this flag (https://github.com/android-ndk/ndk/issues/766). -diff --git a/configure b/configure -index 0914e24..7517168 100755 ---- a/configure -+++ b/configure -@@ -5642,7 +5642,7 @@ $as_echo_n "checking for the Android arm ABI... " >&6; } - $as_echo "$_arm_arch" >&6; } - if test "$_arm_arch" = 7; then - BASECFLAGS="${BASECFLAGS} -mfloat-abi=softfp -mfpu=vfpv3-d16" -- LDFLAGS="${LDFLAGS} -march=armv7-a -Wl,--fix-cortex-a8" -+ LDFLAGS="${LDFLAGS} -march=armv7-a" - fi - else - { $as_echo "$as_me:${as_lineno-$LINENO}: result: not Android" >&5 diff --git a/recipes/python3/patches/pyconfig_detection.patch b/recipes/python3/patches/pyconfig_detection.patch deleted file mode 100644 index 087ab58..0000000 --- a/recipes/python3/patches/pyconfig_detection.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff -Nru Python-3.8.2/Lib/site.py Python-3.8.2-new/Lib/site.py ---- Python-3.8.2/Lib/site.py 2020-04-28 12:48:38.000000000 -0700 -+++ Python-3.8.2-new/Lib/site.py 2020-04-28 12:52:46.000000000 -0700 -@@ -487,7 +487,8 @@ - if key == 'include-system-site-packages': - system_site = value.lower() - elif key == 'home': -- sys._home = value -+ # this is breaking pyconfig.h path detection with venv -+ print('Ignoring "sys._home = value" override') - - sys.prefix = sys.exec_prefix = site_prefix - diff --git a/recipes/python3/patches/reproducible-buildinfo.diff b/recipes/python3/patches/reproducible-buildinfo.diff deleted file mode 100644 index 807d180..0000000 --- a/recipes/python3/patches/reproducible-buildinfo.diff +++ /dev/null @@ -1,13 +0,0 @@ -# DP: Build getbuildinfo.o with DATE/TIME values when defined - ---- a/Makefile.pre.in -+++ b/Makefile.pre.in -@@ -785,6 +785,8 @@ Modules/getbuildinfo.o: $(PARSER_OBJS) \ - -DGITVERSION="\"`LC_ALL=C $(GITVERSION)`\"" \ - -DGITTAG="\"`LC_ALL=C $(GITTAG)`\"" \ - -DGITBRANCH="\"`LC_ALL=C $(GITBRANCH)`\"" \ -+ $(if $(BUILD_DATE),-DDATE='"$(BUILD_DATE)"') \ -+ $(if $(BUILD_TIME),-DTIME='"$(BUILD_TIME)"') \ - -o $@ $(srcdir)/Modules/getbuildinfo.c - - Modules/getpath.o: $(srcdir)/Modules/getpath.c Makefile diff --git a/recipes/sqlite3/Android.mk b/recipes/sqlite3/Android.mk deleted file mode 100644 index 05d13cd..0000000 --- a/recipes/sqlite3/Android.mk +++ /dev/null @@ -1,11 +0,0 @@ -LOCAL_PATH := $(call my-dir)/.. - -include $(CLEAR_VARS) - -LOCAL_SRC_FILES := sqlite3.c - -LOCAL_MODULE := sqlite3 - -LOCAL_CFLAGS := -DSQLITE_ENABLE_FTS4 -D_FILE_OFFSET_BITS=32 -DSQLITE_ENABLE_JSON1 - -include $(BUILD_SHARED_LIBRARY) \ No newline at end of file diff --git a/recipes/sqlite3/__init__.py b/recipes/sqlite3/__init__.py deleted file mode 100644 index 2970302..0000000 --- a/recipes/sqlite3/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from os.path import join -import shutil - -from pythonforandroid.recipe import NDKRecipe -from pythonforandroid.util import ensure_dir - - -class Sqlite3Recipe(NDKRecipe): - version = '3.35.5' - # Don't forget to change the URL when changing the version - url = 'https://www.sqlite.org/2021/sqlite-amalgamation-3350500.zip' - generated_libraries = ['sqlite3'] - - def should_build(self, arch): - return not self.has_libs(arch, 'libsqlite3.so') - - def prebuild_arch(self, arch): - super().prebuild_arch(arch) - # Copy the Android make file - ensure_dir(join(self.get_build_dir(arch.arch), 'jni')) - shutil.copyfile(join(self.get_recipe_dir(), 'Android.mk'), - join(self.get_build_dir(arch.arch), 'jni/Android.mk')) - - def build_arch(self, arch, *extra_args): - super().build_arch(arch) - # Copy the shared library - shutil.copyfile(join(self.get_build_dir(arch.arch), 'libs', arch.arch, 'libsqlite3.so'), - join(self.ctx.get_libs_dir(arch.arch), 'libsqlite3.so')) - - def get_recipe_env(self, arch): - env = super().get_recipe_env(arch) - env['NDK_PROJECT_PATH'] = self.get_build_dir(arch.arch) - return env - - -recipe = Sqlite3Recipe() \ No newline at end of file diff --git a/sbapp/Makefile b/sbapp/Makefile index d425806..11bdb19 100644 --- a/sbapp/Makefile +++ b/sbapp/Makefile @@ -19,44 +19,34 @@ pacthfiles: patchsdl injectxml patchpycodec2 patchsdl: # Pach USB HID behaviour - cp patches/HIDDeviceUSB.java .buildozer/android/platform/build-arm64-v8a/build/bootstrap_builds/sdl2/jni/SDL/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java - cp patches/HIDDeviceUSB.java .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/java/org/libsdl/app/HIDDeviceUSB.java - cp patches/HIDDeviceUSB.java .buildozer/android/platform/build-arm64-v8a/dists/sideband/jni/SDL/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java + cp patches/HIDDeviceUSB.java .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/bootstrap_builds/sdl2/jni/SDL/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java + cp patches/HIDDeviceUSB.java .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/src/main/java/org/libsdl/app/HIDDeviceUSB.java + cp patches/HIDDeviceUSB.java .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/jni/SDL/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java # Pach service loader cp patches/PythonService.java .buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonService.java - cp patches/PythonService.java .buildozer/android/platform/build-arm64-v8a/build/bootstrap_builds/sdl2/src/main/java/org/kivy/android/PythonService.java - cp patches/PythonService.java .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/java/org/kivy/android/PythonService.java - - # Pach python activity - cp patches/PythonActivity.java .buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/sdl2/build/src/main/java/org/kivy/android/PythonActivity.java - cp patches/PythonActivity.java .buildozer/android/platform/build-arm64-v8a/build/bootstrap_builds/sdl2/src/main/java/org/kivy/android/PythonActivity.java - cp patches/PythonActivity.java .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/java/org/kivy/android/PythonActivity.java + cp patches/PythonService.java .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/bootstrap_builds/sdl2/src/main/java/org/kivy/android/PythonService.java + cp patches/PythonService.java .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/src/main/java/org/kivy/android/PythonService.java patchpycodec2: - patchelf --replace-needed libcodec2.so.1.2 libcodec2.so .buildozer/android/platform/build-arm64-v8a/dists/sideband/_python_bundle__arm64-v8a/_python_bundle/site-packages/pycodec2/pycodec2.so - # patchelf --replace-needed libcodec2.so.1.2 libcodec2.so .buildozer/android/platform/build-arm64-v8a/dists/sideband/_python_bundle__armeabi-v7a/_python_bundle/site-packages/pycodec2/pycodec2.so + patchelf --replace-needed libcodec2.so.1.2 libcodec2.so .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/_python_bundle__arm64-v8a/_python_bundle/site-packages/pycodec2/pycodec2.so + patchelf --replace-needed libcodec2.so.1.2 libcodec2.so .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/_python_bundle__armeabi-v7a/_python_bundle/site-packages/pycodec2/pycodec2.so injectxml: # mkdir /home/markqvist/.local/lib/python3.11/site-packages/pythonforandroid/bootstraps/sdl2/build/src/main/xml # Inject XML on arm64-v8a - mkdir -p .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/res/xml - mkdir -p .buildozer/android/platform/build-arm64-v8a/dists/sideband/templates - cp patches/device_filter.xml .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/res/xml/ - cp patches/file_paths.xml .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/res/xml/ - cp patches/AndroidManifest.tmpl.xml .buildozer/android/platform/build-arm64-v8a/dists/sideband/templates/ - cp patches/p4a_build.py .buildozer/android/platform/build-arm64-v8a/dists/sideband/build.py + mkdir -p .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/src/main/res/xml + mkdir -p .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/templates + cp patches/device_filter.xml .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/src/main/res/xml/ + cp patches/file_paths.xml .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/src/main/res/xml/ + cp patches/AndroidManifest.tmpl.xml .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/templates/ + cp patches/p4a_build.py .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/build.py -injectlibs: - @(echo Injecting native libs...) - cp .buildozer/android/platform/build-arm64-v8a/dists/sideband/_python_bundle__arm64-v8a/_python_bundle/site-packages/LXST/filterlib.so LXST/ - @(sleep 1) - debug: buildozer android debug prebake: -ifneq (,$(wildcard .buildozer/android/platform/build-arm64-v8a/dists/sideband/src/main/res/xml/device_filter.xml)) +ifneq (,$(wildcard .buildozer/android/platform/build-arm64-v8a_armeabi-v7a/dists/sideband/src/main/res/xml/device_filter.xml)) @echo Device filter XML exists, skipping prebake else @(echo Prebaking build before patching files...) @@ -69,16 +59,18 @@ endif fetchshare: -(rm ./share/pkg/*) -(rm ./share/mirrors/* -r) - cp ../../dist_archive/rns-1.0.4-py3-none-any.whl ./share/pkg/ - cp ../../dist_archive/rnspure-1.0.4-py3-none-any.whl ./share/pkg/ - cp ../../dist_archive/lxmf-0.9.3-py3-none-any.whl ./share/pkg/ - cp ../../dist_archive/nomadnet-0.9.1-py3-none-any.whl ./share/pkg/ - cp ../../dist_archive/rnsh-0.1.7-py3-none-any.whl ./share/pkg/ + cp ../../dist_archive/rns-*-py3-none-any.whl ./share/pkg/ + cp ../../dist_archive/rnspure-*-py3-none-any.whl ./share/pkg/ + cp ../../dist_archive/lxmf-*-py3-none-any.whl ./share/pkg/ + cp ../../dist_archive/nomadnet-*-py3-none-any.whl ./share/pkg/ + cp ../../dist_archive/rnsh-*-py3-none-any.whl ./share/pkg/ cp ../../dist_archive/RNode_Firmware_*_Source.zip ./share/pkg/ zip --junk-paths ./share/pkg/example_plugins.zip ../docs/example_plugins/*.py cp -r ../../dist_archive/reticulum.network ./share/mirrors/ - mkdir ./share/mirrors/rnode-flasher - cp ../../rnode-flasher/RNode_Flasher.html ./share/mirrors/rnode-flasher + cp -r ../../dist_archive/unsigned.io ./share/mirrors/ + cp ../../dist_archive/Reticulum\ Manual.pdf ./share/mirrors/Reticulum_Manual.pdf + cp ../../dist_archive/Reticulum\ Manual.epub ./share/mirrors/Reticulum_Manual.epub + cp -r ../../rnode-flasher ./share/mirrors/ -(rm ./share/mirrors/rnode-flasher/.git -rf) release: @@ -86,30 +78,26 @@ release: postbuild: $(MAKE) cleanrns - -(rm ../LXST/filterlib.so) -apk: prepare prebake pacthfiles fetchshare injectlibs release postbuild +apk: prepare prebake pacthfiles fetchshare release postbuild -devapk: prepare prebake pacthfiles fetchshare injectlibs debug postbuild +devapk: prepare prebake pacthfiles fetchshare debug postbuild version: @(echo $$(python ./gv.py)) install: - adb install bin/sideband-$$(python ./gv.py)-arm64-v8a-release.apk + adb install bin/sideband-$$(python ./gv.py)-arm64-v8a_armeabi-v7a-release.apk console: (adb logcat | grep "python\|sidebandservice") getrns: - (cp -r ../../Reticulum/RNS ./) - -(rm ./RNS/Utilities/RNS) + (cp -rv ../../Reticulum/RNS ./;rm ./RNS/Utilities/RNS) -(rm ./RNS/__pycache__ -r) - (cp -r ../../LXMF/LXMF ./) - -(rm ./LXMF/Utilities/LXMF) + (cp -rv ../../LXMF/LXMF ./;rm ./LXMF/Utilities/LXMF) -(rm ./LXMF/__pycache__ -r) - (cp -r ../../LXST/LXST ./) - -(rm ./LXST/Utilities/LXST) + (cp -rv ../../LXST/LXST ./;rm ./LXST/Utilities/LXST) -(rm ./LXST/__pycache__ -r) -(rm ./LXST/Utilities/__pycache__ -r) diff --git a/sbapp/buildozer.spec b/sbapp/buildozer.spec index 83e9631..b6b8594 100644 --- a/sbapp/buildozer.spec +++ b/sbapp/buildozer.spec @@ -4,15 +4,15 @@ package.name = sideband package.domain = io.unsigned source.dir = . -source.include_exts = py,png,jpg,jpeg,webp,ttf,kv,pyi,typed,so,0,1,2,3,atlas,frag,html,css,js,whl,zip,gz,woff2,pdf,epub,pgm,opus,h,c -source.include_patterns = assets/*,assets/fonts/*,assets/audio/notifications/*,share/* +source.include_exts = py,png,jpg,jpeg,webp,ttf,kv,pyi,typed,so,0,1,2,3,atlas,frag,html,css,js,whl,zip,gz,woff2,pdf,epub,pgm +source.include_patterns = assets/*,assets/fonts/*,share/* source.exclude_patterns = app_storage/*,venv/*,Makefile,./Makefil*,requirements,precompiled/*,parked/*,./setup.py,Makef*,./Makefile,Makefile,bin/*,build/*,dist/*,__pycache__/* version.regex = __version__ = ['"](.*)['"] version.filename = %(source.dir)s/main.py -android.numeric_version = 20251128 +android.numeric_version = 20250220 -requirements = kivy==2.3.0,libbz2,sqlite3,pillow==10.2.0,qrcode==7.3.1,usb4a,usbserial4a,able_recipe,libwebp,libogg,libopus,opusfile,numpy,cryptography,codec2,pycodec2,sh,pynacl,typing-extensions,mistune>=3.0.2,beautifulsoup4,lxst +requirements = kivy==2.3.0,libbz2,pillow==10.2.0,qrcode==7.3.1,usb4a,usbserial4a,able_recipe,libwebp,libogg,libopus,opusfile,numpy,cryptography,ffpyplayer,codec2,pycodec2,sh,pynacl,typing-extensions,mistune>=3.0.2,beautifulsoup4 android.gradle_dependencies = com.android.support:support-compat:28.0.0 #android.enable_androidx = True @@ -29,16 +29,15 @@ android.presplash_color = #00000000 orientation = portrait fullscreen = 0 -android.permissions = INTERNET,POST_NOTIFICATIONS,WAKE_LOCK,FOREGROUND_SERVICE,CHANGE_WIFI_MULTICAST_STATE,BLUETOOTH_SCAN,BLUETOOTH_ADVERTISE,BLUETOOTH_CONNECT,ACCESS_NETWORK_STATE,ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATION,MANAGE_EXTERNAL_STORAGE,ACCESS_BACKGROUND_LOCATION,RECORD_AUDIO,REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,FOREGROUND_SERVICE_CONNECTED_DEVICE,MODIFY_AUDIO_SETTINGS,RECEIVE_BOOT_COMPLETED +android.permissions = INTERNET,POST_NOTIFICATIONS,WAKE_LOCK,FOREGROUND_SERVICE,CHANGE_WIFI_MULTICAST_STATE,BLUETOOTH_SCAN,BLUETOOTH_ADVERTISE,BLUETOOTH_CONNECT,ACCESS_NETWORK_STATE,ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATION,MANAGE_EXTERNAL_STORAGE,ACCESS_BACKGROUND_LOCATION,RECORD_AUDIO,REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,FOREGROUND_SERVICE_CONNECTED_DEVICE -android.api = 33 +android.api = 31 android.minapi = 24 android.ndk = 25b android.skip_update = False android.accept_sdk_license = True android.release_artifact = apk -android.archs = arm64-v8a -#android.archs = arm64-v8a,armeabi-v7a +android.archs = arm64-v8a,armeabi-v7a #android.logcat_filters = *:S python:D services = sidebandservice:services/sidebandservice.py:foreground diff --git a/sbapp/kivymd/toast/__init__.py b/sbapp/kivymd/toast/__init__.py index 4e73ac1..d5270c6 100755 --- a/sbapp/kivymd/toast/__init__.py +++ b/sbapp/kivymd/toast/__init__.py @@ -2,14 +2,10 @@ __all__ = ("toast",) from kivy.utils import platform -use_native_toast = False - if platform == "android": - if use_native_toast: - try: from .androidtoast import toast - except ModuleNotFoundError: from .kivytoast import toast - else: + try: + from .androidtoast import toast + except ModuleNotFoundError: from .kivytoast import toast - else: from .kivytoast import toast diff --git a/sbapp/kivymd/uix/textfield/textfield.py b/sbapp/kivymd/uix/textfield/textfield.py index 666ec33..a351580 100755 --- a/sbapp/kivymd/uix/textfield/textfield.py +++ b/sbapp/kivymd/uix/textfield/textfield.py @@ -306,7 +306,6 @@ from kivymd.font_definitions import theme_font_styles from kivymd.theming import ThemableBehavior from kivymd.uix.behaviors import DeclarativeBehavior from kivymd.uix.label import MDIcon -from kivy.utils import platform with open( os.path.join(uix_path, "textfield", "textfield.kv"), encoding="utf-8" @@ -1484,8 +1483,7 @@ class MDTextField( Animation(_hint_y=y, duration=0.2, t="out_quad").start(self) if self.mode == "rectangle": if not self.icon_left: - if platform == "android": _hint_x = x+dp(7) - else: _hint_x = x + _hint_x = x else: if y == dp(10): _hint_x = dp(-4) diff --git a/sbapp/main.py b/sbapp/main.py index e11465a..37d1d3d 100644 --- a/sbapp/main.py +++ b/sbapp/main.py @@ -1,6 +1,6 @@ __debug_build__ = False __disable_shaders__ = False -__version__ = "1.8.3" +__version__ = "1.5.0" __variant__ = "" import sys @@ -8,9 +8,7 @@ import argparse parser = argparse.ArgumentParser(description="Sideband LXMF Client") parser.add_argument("-v", "--verbose", action='store_true', default=False, help="increase logging verbosity") parser.add_argument("-c", "--config", action='store', default=None, help="specify path of config directory") -parser.add_argument("-r", "--rnsconfig", action='store', default=None, help="specify path of RNS config directory") parser.add_argument("-d", "--daemon", action='store_true', default=False, help="run as a daemon, without user interface") -parser.add_argument("-i", "--interactive", action='store_true', default=False, help="connect interactive console after daemon init") parser.add_argument("--export-settings", action='store', default=None, help="export application settings to file") parser.add_argument("--import-settings", action='store', default=None, help="import application settings from file") parser.add_argument("--version", action="version", version="sideband {version}".format(version=__version__)) @@ -29,88 +27,28 @@ import RNS.vendor.umsgpack as msgpack WINDOW_DEFAULT_WIDTH = 494 WINDOW_DEFAULT_HEIGHT = 800 -WINDOW_HEIGHT_MARGIN = 0 - -WINDOW_MIN_WIDTH = 415 -WINDOW_MIN_HEIGHT = 550 - -app_ui_scaling_path = None -app_ui_wcfg_path = None -app_ui_window_config = None -app_ui_dsp_width = None -app_ui_dsp_height = None -app_init_window_state = "normal" - -def get_display_res(): - global app_ui_dsp_width, app_ui_dsp_height - if not RNS.vendor.platformutils.is_linux(): return None, None - else: - try: - import subprocess - # Try to get connected and primary display - cmd_xrandr = subprocess.Popen(["xrandr"], stdout=subprocess.PIPE) - cmd_grep = subprocess.Popen(["grep", " connected primary"], stdin=cmd_xrandr.stdout, stdout=subprocess.PIPE) - cmd_xrandr.stdout.close(); res_bytes, _ = cmd_grep.communicate() - if not (len(res_bytes) > 25 and b"x" in res_bytes): - # Try to get connected display - cmd_xrandr = subprocess.Popen(["xrandr"], stdout=subprocess.PIPE) - cmd_grep = subprocess.Popen(["grep", " connected"], stdin=cmd_xrandr.stdout, stdout=subprocess.PIPE) - cmd_xrandr.stdout.close(); res_bytes, _ = cmd_grep.communicate() - - resolution_es = res_bytes.split() - for e in resolution_es: - if b"x" in e: - if b"+" in e: e = e.split(b"+")[0] - cs = e.split(b"x") - if len(cs) == 2: - resolution = e - break - - width, height = resolution.split(b"x") - app_ui_dsp_width = int(width) - app_ui_dsp_height = int(height) - return app_ui_dsp_width, app_ui_dsp_height - except Exception as e: - RNS.log(f"Could not get display resolution: {e}", RNS.LOG_WARNING) - RNS.trace_exception(e) - return None, None - +app_ui_scaling_path = None def apply_ui_scale(): global app_ui_scaling_path - global app_ui_wcfg_path - global app_ui_window_config - - if args.config != None: config_path = os.path.expanduser(args.config) - else: config_path = None - default_scale = os.environ["KIVY_METRICS_DENSITY"] if "KIVY_METRICS_DENSITY" in os.environ else "unknown" + config_path = None ui_scale_path = None - ui_wcfg_path = None - res_ident = "" - dsp_width, dsp_height = get_display_res() - if dsp_width and dsp_height: - RNS.log(f"Got display res: {dsp_width}x{dsp_height}", RNS.LOG_DEBUG) - res_ident = f"_{dsp_width}_{dsp_height}" try: if RNS.vendor.platformutils.is_android(): import plyer - ui_scale_path = os.path.join(plyer.storagepath.get_application_dir(), "io.unsigned.sideband", "files", "app_storage", "ui_scale") + ui_scale_path = plyer.storagepath.get_application_dir()+"/io.unsigned.sideband/files/app_storage/ui_scale" else: if config_path == None: import sbapp.plyer as plyer - ui_scale_path = os.path.join(plyer.storagepath.get_home_dir(), ".config", "sideband", "app_storage", "ui_scale") - if ui_scale_path.startswith("file://"): ui_scale_path = ui_scale_path.replace("file://", "") + ui_scale_path = plyer.storagepath.get_home_dir()+"/.config/sideband/app_storage/ui_scale" + if ui_scale_path.startswith("file://"): + ui_scale_path = ui_scale_path.replace("file://", "") else: - ui_scale_path = os.path.join(config_path, "app_storage", "ui_scale") + ui_scale_path = config_path+"/app_storage/ui_scale" - if ui_scale_path: - ui_scale_path = f"{ui_scale_path}{res_ident}" - ui_wcfg_path = f"{ui_scale_path}_windowcfg" - app_ui_scaling_path = ui_scale_path - app_ui_wcfg_path = ui_wcfg_path except Exception as e: RNS.log(f"Error while locating UI scale file: {e}", RNS.LOG_ERROR) @@ -118,10 +56,11 @@ def apply_ui_scale(): if ui_scale_path != None: RNS.log("Default scaling factor on this platform is "+str(default_scale), RNS.LOG_NOTICE) try: - RNS.log("Looking for scaling info in "+str(ui_scale_path), RNS.LOG_NOTICE) + RNS.log("Looking for scaling info in "+str(ui_scale_path)) if os.path.isfile(ui_scale_path): scale_factor = None - with open(ui_scale_path, "r") as sf: scale_factor = float(sf.readline()) + with open(ui_scale_path, "r") as sf: + scale_factor = float(sf.readline()) if scale_factor != None: if scale_factor >= 0.3 and scale_factor <= 5.0: @@ -134,54 +73,6 @@ def apply_ui_scale(): except Exception as e: RNS.log(f"Error while reading UI scaling factor: {e}", RNS.LOG_ERROR) - if ui_scale_path != None: - try: - RNS.log("Looking for saved window configuration in "+str(ui_wcfg_path), RNS.LOG_NOTICE) - if os.path.isfile(ui_wcfg_path): - scale_factor = None - with open(ui_wcfg_path, "r") as sf: window_config = sf.readline().split() - - if window_config != None: - if type(window_config) == list and len(window_config) >= 5: - app_ui_window_config = window_config - - - except Exception as e: - RNS.log(f"Error while reading saved window configuration: {e}", RNS.LOG_ERROR) - -################################################### -# Kivy/SDL2 run-time patch to fix horribly slow -# window resize updates on Linux. For more info: -# https://github.com/kivy/kivy/issues/9106 -# -_sdl2_window_event_filter_original = None -_sdl2_window_event_filter_instance = None -def _sdl2_window_event_filter_proxy(action, *largs): - global _sdl2_window_event_filter_original - global _sdl2_window_event_filter_instance - if not action == 'windowresized': return _sdl2_window_event_filter_original(action, *largs) - else: - _sdl2_window_event_filter_instance._size = largs - _sdl2_window_event_filter_instance._win.resize_window(*_sdl2_window_event_filter_instance._size) - # The only change this patched method makes is to - # remove the offending "EventLoop.idle()" statement - # EventLoop.idle() - return 0 - -def patch_sdl_window_events(patch_target): - if RNS.vendor.platformutils.is_linux(): - global _sdl2_window_event_filter_original - global _sdl2_window_event_filter_instance - _sdl2_window_event_filter_original = patch_target._event_filter - _sdl2_window_event_filter_instance = patch_target - patch_target._event_filter = _sdl2_window_event_filter_proxy - patch_target._win.set_event_filter(patch_target._event_filter) -# -# End of Kivy/SDL2 patch ########################## - -window_x_offset = 0 -window_y_offset = 0 - if args.export_settings: from .sideband.core import SidebandCore sideband = SidebandCore( @@ -251,12 +142,6 @@ elif args.import_settings: exit(1) if not args.daemon: - from LXST._version import __version__ as lxst_version - from LXST.Primitives.Recorders import FileRecorder - from LXST.Primitives.Players import FilePlayer - from LXST.Codecs import Opus - from LXST.Filters import BandPass, AGC - from kivy.logger import Logger, LOG_LEVELS from PIL import Image as PilImage import io @@ -290,6 +175,27 @@ if not args.daemon: local = os.path.dirname(__file__) sys.path.append(local) + if not RNS.vendor.platformutils.is_android(): + model = None + max_width = WINDOW_DEFAULT_WIDTH + max_height = WINDOW_DEFAULT_HEIGHT + + try: + if os.path.isfile("/sys/firmware/devicetree/base/model"): + with open("/sys/firmware/devicetree/base/model", "r") as mf: + model = mf.read() + except: pass + + if model: + if model.startswith("Raspberry Pi "): max_height = 625 + + window_width = min(WINDOW_DEFAULT_WIDTH, max_width) + window_height = min(WINDOW_DEFAULT_HEIGHT, max_height) + + from kivy.config import Config + Config.set("graphics", "width", str(window_width)) + Config.set("graphics", "height", str(window_height)) + if args.daemon: from .sideband.core import SidebandCore class DaemonElement(): @@ -312,91 +218,6 @@ if args.daemon: else: apply_ui_scale() - if not RNS.vendor.platformutils.is_android(): - # Set default scaling factor and position - scaling_factor = 1.0 - window_target_x = None - window_target_y = None - window_state = "normal" - - # Attempt to read configured scaling factor - # from environment variable - if not RNS.vendor.platformutils.is_windows() and not RNS.vendor.platformutils.is_darwin(): - try: scaling_factor = float(os.environ["KIVY_METRICS_DENSITY"]) - except Exception as e: pass - - # Bound scaling factor to reasonable values - if scaling_factor < 0.75: scaling_factor = 0.75 - if scaling_factor > 2: scaling_factor = 2 - - # Get reasonable maximum window bounds - if app_ui_dsp_width and app_ui_dsp_height: - # Use display resolution if available - max_width = app_ui_dsp_width - max_height = app_ui_dsp_height-WINDOW_HEIGHT_MARGIN - else: - # Assume bounds from default size * scaling - max_width = WINDOW_DEFAULT_WIDTH*scaling_factor - max_height = WINDOW_DEFAULT_HEIGHT*scaling_factor - - # Try to find device model to apply reasonable - # bounds on window sizes - model = None - try: - if os.path.isfile("/sys/firmware/devicetree/base/model"): - with open("/sys/firmware/devicetree/base/model", "r") as mf: - model = mf.read() - except: pass - - # Apply window sizing based on model - if model: - # Decrease default height for Raspberry Pi - # if screen resolution is unavailable, to - # avoid overflow on small screens - if model.startswith("Raspberry Pi ") and not app_ui_dsp_height: max_height = 625 - - # Initialize size to defaults - window_width_target = int(WINDOW_DEFAULT_WIDTH) - window_height_target = int(WINDOW_DEFAULT_HEIGHT) - - # But use saved window configuration if possible - if type(app_ui_window_config) == list and len(app_ui_window_config) >= 5: - window_width_target = int(app_ui_window_config[0]) - window_height_target = int(app_ui_window_config[1]) - window_target_x = int(app_ui_window_config[2]) - window_target_y = int(app_ui_window_config[3]) - window_state = app_ui_window_config[4] - - if len(app_ui_window_config) > 5: window_x_offset = int(app_ui_window_config[5]) - if len(app_ui_window_config) > 6: window_y_offset = int(app_ui_window_config[6]) - - if not window_state in ["maximized", "minimized", "normal"]: window_state = "normal" - if window_target_x < 0: window_target_x = 0 - if window_target_y < 0: window_target_y = 0 - - # Calculate final window size - window_width = int(max(min(window_width_target, max_width), WINDOW_MIN_WIDTH)) - window_height = int(max(min(window_height_target, max_height), WINDOW_MIN_HEIGHT)) - - if app_ui_dsp_width and app_ui_dsp_height and window_target_x and window_target_y: - if window_target_x > (app_ui_dsp_width - window_width): window_target_x = app_ui_dsp_width - window_width - if window_target_y > (app_ui_dsp_height - window_height): window_target_y = app_ui_dsp_height - window_height - - from kivy.config import Config - Config.set("graphics", "width", str(window_width)) - Config.set("graphics", "height", str(window_height)) - - if window_target_x and window_target_y: - Config.set('graphics', 'position', 'custom') - Config.set("graphics", "left", str(window_target_x+window_x_offset)) - Config.set("graphics", "top", str(window_target_y+window_y_offset)) - _window_init_x = window_target_x - _window_init_y = window_target_y - - if window_state == "maximized": - Config.set("graphics", "window_state", "maximized") - app_init_window_state = window_state - from kivymd.app import MDApp app_superclass = MDApp from kivy.core.window import Window @@ -435,14 +256,11 @@ else: from ui.telemetry import Telemetry from ui.utilities import Utilities from ui.voice import Voice - from ui.guide import Guide - from ui.keys import Keys - from ui.hardware import Hardware from ui.objectdetails import ObjectDetails from ui.announces import Announces from ui.messages import Messages, ts_format, messages_screen_kv from ui.helpers import ContentNavigationDrawer, DrawerList, IconListItem - from ui.helpers import multilingual_markup, mdc, dark_theme_text_color + from ui.helpers import multilingual_markup, mdc from kivymd.toast import toast from jnius import cast @@ -451,15 +269,12 @@ else: from android.permissions import request_permissions, check_permission from android.storage import primary_external_storage_path, secondary_external_storage_path - import LXST.Codecs.libs.pyogg as pyogg - from LXST.Codecs.libs.pydub import AudioSegment + import pyogg + from pydub import AudioSegment from kivymd.utils.set_bars_colors import set_bars_colors android_api_version = autoclass('android.os.Build$VERSION').SDK_INT - from android.broadcast import BroadcastReceiver - BluetoothAdapter = autoclass('android.bluetooth.BluetoothAdapter') - else: from .sideband.core import SidebandCore import sbapp.plyer as plyer @@ -470,22 +285,21 @@ else: from .ui.telemetry import Telemetry from .ui.utilities import Utilities from .ui.voice import Voice - from .ui.guide import Guide - from .ui.keys import Keys - from .ui.hardware import Hardware from .ui.objectdetails import ObjectDetails from .ui.messages import Messages, ts_format, messages_screen_kv from .ui.helpers import ContentNavigationDrawer, DrawerList, IconListItem - from .ui.helpers import multilingual_markup, mdc, dark_theme_text_color + from .ui.helpers import multilingual_markup, mdc - import LXST.Codecs.libs.pyogg as pyogg - from LXST.Codecs.libs.pydub import AudioSegment + import sbapp.pyogg as pyogg + from sbapp.pydub import AudioSegment from kivymd.toast import toast from kivy.config import Config Config.set('input', 'mouse', 'mouse,disable_multitouch') +dark_theme_text_color = "ddd" + if RNS.vendor.platformutils.get_platform() == "android": from jnius import autoclass from android.runnable import run_on_ui_thread @@ -531,7 +345,7 @@ class SidebandApp(MDApp): if RNS.vendor.platformutils.get_platform() == "android": self.sideband = SidebandCore(self, config_path=self.config_path, is_client=True, android_app_dir=self.app_dir, verbose=__debug_build__) else: - self.sideband = SidebandCore(self, config_path=self.config_path, is_client=False, verbose=(args.verbose or __debug_build__),rns_config_path=args.rnsconfig) + self.sideband = SidebandCore(self, config_path=self.config_path, is_client=False, verbose=(args.verbose or __debug_build__)) self.sideband.version_str = "v"+__version__+" "+__variant__ @@ -548,7 +362,6 @@ class SidebandApp(MDApp): self.map_layer = None self.map_screen = None self.telemetry_screen = None - self.connectivity_screen = None self.map_cache = self.sideband.map_cache self.offline_source = None self.map_settings_screen = None @@ -561,6 +374,9 @@ class SidebandApp(MDApp): self.connectivity_ready = False self.hardware_ready = False self.repository_ready = False + self.hardware_rnode_ready = False + self.hardware_modem_ready = False + self.hardware_serial_ready = False self.hw_error_dialog = None self.final_load_completed = False @@ -573,7 +389,6 @@ class SidebandApp(MDApp): self.attach_dialog = None self.shared_attach_dialog = None self.rec_dialog = None - self.recording_started = None self.last_msg_audio = None self.msg_sound = None self.audio_msg_mode = LXMF.AM_OPUS_OGG @@ -582,20 +397,15 @@ class SidebandApp(MDApp): self.key_ptt_down = False Window.softinput_mode = "below_target" - self.window_state = app_init_window_state - self.icon = os.path.join(self.sideband.asset_dir, "icon.png") - self.notification_icon = os.path.join(self.sideband.asset_dir, "notification_icon.png") + self.icon = self.sideband.asset_dir+"/icon.png" + self.notification_icon = self.sideband.asset_dir+"/notification_icon.png" - self.resume_event_scheduler = None self.connectivity_updater = None self.last_map_update = 0 self.last_telemetry_received = 0 self.repository_url = None self.rnode_flasher_url = None - self.bt_adapter = None - self.discovered_bt_devices = {} - self.bt_bonded_devices = [] ################################################# # Application Startup # @@ -649,86 +459,6 @@ class SidebandApp(MDApp): argument = self.app_dir self.android_service.start(mActivity, argument) - def stop_service(self): - RNS.log("Stopping service...") - self.sideband.setstate("wants.service_stop", True) - while self.sideband.service_available(): time.sleep(0.2) - RNS.log("Service stopped") - - def restart_service_action(self, sender): - if hasattr(self, "service_restarting") and self.service_restarting == True: - toast(f"Service restart already in progress") - else: - toast(f"Restarting RNS service...") - if hasattr(self, "connectivity_screen") and self.connectivity_screen != None: - self.connectivity_screen.ids.button_service_restart.disabled = True - def job(): - if self.restart_service(): - def tj(delta_time): - toast(f"Service restarted successfully!") - if hasattr(self, "connectivity_screen") and self.connectivity_screen != None: - self.connectivity_screen.ids.button_service_restart.disabled = False - Clock.schedule_once(tj, 0.1) - else: - def tj(delta_time): - toast(f"Service restart failed") - if hasattr(self, "connectivity_screen") and self.connectivity_screen != None: - self.connectivity_screen.ids.button_service_restart.disabled = False - Clock.schedule_once(tj, 0.1) - - threading.Thread(target=job, daemon=True).start() - - def restart_service(self): - if hasattr(self, "service_restarting") and self.service_restarting == True: - return False - else: - self.service_restarting = True - self.stop_service() - RNS.log("Waiting for service shutdown", RNS.LOG_DEBUG) - while self.sideband.service_rpc_request({"getstate": "service.heartbeat"}): - time.sleep(1) - time.sleep(4) - - self.final_load_completed = False - self.sideband.service_stopped = True - - RNS.log("Starting service...", RNS.LOG_DEBUG) - self.start_service() - RNS.log("Waiting for service restart...", RNS.LOG_DEBUG) - restart_timeout = time.time() + 45 - while not self.sideband.service_rpc_request({"getstate": "service.heartbeat"}): - self.sideband.rpc_connection = None - time.sleep(1) - if time.time() > restart_timeout: - service_restarting = False - return False - - RNS.log("Service restarted", RNS.LOG_DEBUG) - self.sideband.service_stopped = False - self.final_load_completed = True - self.service_restarting = False - - return True - - def check_launch_intent(self): - try: - if RNS.vendor.platformutils.is_android(): - # Check for pending start intent - if not hasattr(mActivity, "startIntent"): RNS.log("Could not access pending intent from Android activity", RNS.LOG_ERROR) - else: - try: - pending_intent = mActivity.startIntent - RNS.log(f"Passing intent {pending_intent} to intent handler...", RNS.LOG_DEBUG) - self.on_new_intent(pending_intent) - - except Exception as e: - RNS.log("An error occurred while getting pending intent on activity launch: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) - - except Exception as e: - RNS.log(f"An error occurred while checking Android launch intent: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) - def start_final(self): # Start local core instance self.sideband.start() @@ -803,10 +533,7 @@ class SidebandApp(MDApp): self.hw_error_dialog.open() self.hw_error_dialog.is_open = True - def check_intent(dt): self.check_launch_intent() - Clock.schedule_once(check_errors, 1.5) - Clock.schedule_once(check_intent, 0.1) ################################################# @@ -832,40 +559,40 @@ class SidebandApp(MDApp): def font_config(self): from kivy.core.text import LabelBase, DEFAULT_FONT - fb_path = os.path.join(self.sideband.asset_dir, "fonts") + fb_path = self.sideband.asset_dir+"/fonts/" LabelBase.register(name="hebrew", - fn_regular=os.path.join(fb_path, "NotoSansHebrew-Regular.ttf"), - fn_bold=os.path.join(fb_path, "NotoSansHebrew-Bold.ttf")) + fn_regular=fb_path+"NotoSansHebrew-Regular.ttf", + fn_bold=fb_path+"NotoSansHebrew-Bold.ttf",) LabelBase.register(name="japanese", - fn_regular=os.path.join(fb_path, "NotoSansJP-Regular.ttf")) + fn_regular=fb_path+"NotoSansJP-Regular.ttf") LabelBase.register(name="chinese", - fn_regular=os.path.join(fb_path, "NotoSansSC-Regular.ttf")) + fn_regular=fb_path+"NotoSansSC-Regular.ttf") LabelBase.register(name="korean", - fn_regular=os.path.join(fb_path, "NotoSansKR-Regular.ttf")) + fn_regular=fb_path+"NotoSansKR-Regular.ttf") LabelBase.register(name="emoji", - fn_regular=os.path.join(fb_path, "NotoEmoji-Regular.ttf")) + fn_regular=fb_path+"NotoEmoji-Regular.ttf") LabelBase.register(name="defaultinput", - fn_regular=os.path.join(fb_path, "DefaultInput.ttf")) + fn_regular=fb_path+"DefaultInput.ttf") LabelBase.register(name="combined", - fn_regular=os.path.join(fb_path, "NotoSans-Regular.ttf"), - fn_bold=os.path.join(fb_path, "NotoSans-Bold.ttf"), - fn_italic=os.path.join(fb_path, "NotoSans-Italic.ttf"), - fn_bolditalic=os.path.join(fb_path, "NotoSans-BoldItalic.ttf")) + fn_regular=fb_path+"NotoSans-Regular.ttf", + fn_bold=fb_path+"NotoSans-Bold.ttf", + fn_italic=fb_path+"NotoSans-Italic.ttf", + fn_bolditalic=fb_path+"NotoSans-BoldItalic.ttf") LabelBase.register(name="mono", - fn_regular=os.path.join(fb_path, "RobotoMonoNerdFont-Regular.ttf")) + fn_regular=fb_path+"RobotoMonoNerdFont-Regular.ttf") LabelBase.register(name="term", - fn_regular=os.path.join(fb_path, "BigBlueTerm437NerdFont-Regular.ttf")) + fn_regular=fb_path+"BigBlueTerm437NerdFont-Regular.ttf") LabelBase.register(name="nf", - fn_regular=os.path.join(fb_path, "RobotoMonoNerdFont-Regular.ttf")) + fn_regular=fb_path+"RobotoMonoNerdFont-Regular.ttf") def update_input_language(self): language = self.sideband.config["input_language"] @@ -904,18 +631,6 @@ class SidebandApp(MDApp): self.apply_eink_mods() self.set_bars_colors() - def save_window_config(self): - try: - if not RNS.vendor.platformutils.is_android(): - wcfg = f"{Window.width} {Window.height} {Window.left} {Window.top} {self.window_state} {window_x_offset} {window_y_offset}" - if app_ui_wcfg_path == None: RNS.log("No path to UI window config file could be found, cannot save window config", RNS.LOG_ERROR) - else: - try: - with open(app_ui_wcfg_path, "w") as sfile: sfile.write(str(wcfg)) - RNS.log(f"Saved window config to {app_ui_wcfg_path}", RNS.LOG_DEBUG) - except Exception as e: RNS.log(f"Error while saving window config to {app_ui_wcfg_path}: {e}", RNS.LOG_ERROR) - except Exception as e: RNS.log("Error while saving window configuration: {e}", RNS.LOG_ERROR) - def update_ui_theme(self): if self.sideband.config["dark_ui"]: self.theme_cls.theme_style = "Dark" @@ -1003,7 +718,7 @@ class SidebandApp(MDApp): def share_image(self, image, filename): if RNS.vendor.platformutils.get_platform() == "android": save_path = self.sideband.exports_dir - file_path = os.path.join(save_path, filename) + file_path = save_path+"/"+filename try: if not os.path.isdir(save_path): @@ -1046,18 +761,16 @@ class SidebandApp(MDApp): def on_pause(self): if self.sideband: RNS.log("App pausing...", RNS.LOG_DEBUG) - - if RNS.vendor.platformutils.is_android(): - if self.resume_event_scheduler == None: - self.resume_event_scheduler = Clock.schedule_interval(self.perform_paused_check, 0.1) - self.sideband.setstate("app.running", True) self.sideband.setstate("app.foreground", False) self.app_state = SidebandApp.PAUSED self.sideband.should_persist_data() + if self.conversations_view != None: + self.conversations_view.ids.conversations_scrollview.effect_cls = ScrollEffect + self.conversations_view.ids.conversations_scrollview.scroll = 1 + RNS.log("App paused", RNS.LOG_DEBUG) return True - else: return True @@ -1068,10 +781,17 @@ class SidebandApp(MDApp): self.sideband.setstate("app.foreground", True) self.sideband.setstate("wants.clear_notifications", True) self.app_state = SidebandApp.ACTIVE + if self.conversations_view != None: + self.conversations_view.ids.conversations_scrollview.effect_cls = ScrollEffect + self.conversations_view.ids.conversations_scrollview.scroll = 1 + + else: + RNS.log("Conversations view did not exist", RNS.LOG_DEBUG) def ui_update_job(): time.sleep(0.05) - def cb(dt): self.perform_wake_update() + def cb(dt): + self.perform_wake_update() Clock.schedule_once(cb, 0.1) threading.Thread(target=ui_update_job, daemon=True).start() @@ -1084,38 +804,11 @@ class SidebandApp(MDApp): self.app_state = SidebandApp.STOPPING RNS.log("App stopped", RNS.LOG_DEBUG) - def on_maximize(self, sender): self.window_state = "maximized" - - def on_minimize(self, sender): self.window_state = "minimized" - - def on_restore(self, sender): self.window_state = "normal" - def is_in_foreground(self): - if self.app_state == SidebandApp.ACTIVE: return True - else: return False - - def perform_paused_check(self, delta_time): - # This workaround mitigates yet another bug in Kivy - # on Android, where the JNI/Python bridge now for - # Lord knows whatever reason fails to dispatch the - # onResume event from the Android app lifecycle - # management API. So we have to resort to this hack - # of scheduling a manual check that reads a patched- - # in property on the JNI side of the app activity, - # and then "manually" dispatch on_resume. - if self.app_state == SidebandApp.PAUSED: - # Oh hai, we're running, but we should really - # be paused? What gives? Must mean we've been - # woken up again, but someone forgot to inform - # us about that. Let's have a look, shall we... - activity = autoclass('org.kivy.android.PythonActivity').mActivity - if activity.activityPaused == False: - # Who would have thought, the activity was - # resumed! Good thing we can play event- - # dispatch pretend ourselves. - Clock.unschedule(self.resume_event_scheduler) - self.resume_event_scheduler = None - self.on_resume() + if self.app_state == SidebandApp.ACTIVE: + return True + else: + return False def perform_wake_update(self): # This workaround mitigates a bug in Kivy on Android @@ -1131,25 +824,16 @@ class SidebandApp(MDApp): self.root.ids.nav_drawer.set_state("closed") Clock.schedule_once(cb, 0) - def has_location_permissions(self): - if RNS.vendor.platformutils.is_android(): - if RNS.vendor.platformutils.is_android(): - if check_permission("android.permission.ACCESS_COARSE_LOCATION") and check_permission("android.permission.ACCESS_FINE_LOCATION"): return True - else: return False - - def request_location_permissions(self): - if not self.has_location_permissions(): - if RNS.vendor.platformutils.is_android(): - RNS.log("Requesting location permission", RNS.LOG_DEBUG) - request_permissions(["android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION"]) def check_bluetooth_permissions(self): if RNS.vendor.platformutils.get_platform() == "android": mActivity = autoclass('org.kivy.android.PythonActivity').mActivity Context = autoclass('android.content.Context') - if android_api_version > 30: bt_permission_name = "android.permission.BLUETOOTH_CONNECT" - else: bt_permission_name = "android.permission.BLUETOOTH" + if android_api_version > 30: + bt_permission_name = "android.permission.BLUETOOTH_CONNECT" + else: + bt_permission_name = "android.permission.BLUETOOTH" if check_permission(bt_permission_name): RNS.log("Have bluetooth connect permissions", RNS.LOG_DEBUG) @@ -1261,173 +945,82 @@ class SidebandApp(MDApp): self.check_bluetooth_permissions() - def bluetooth_update_bonded_devices(self, sender=None): - if self.bt_adapter == None: self.bt_adapter = BluetoothAdapter.getDefaultAdapter() - self.bt_bonded_devices = [] - for device in self.bt_adapter.getBondedDevices(): - device_addr = device.getAddress() - self.bt_bonded_devices.append(device_addr) - - RNS.log(f"Updated bonded devices: {self.bt_bonded_devices}", RNS.LOG_DEBUG) - - def bluetooth_scan_action(self, sender=None): - self.start_bluetooth_scan() - - def start_bluetooth_scan(self): - if not self.has_location_permissions(): - if not hasattr(self, "permission_dialog") or self.permission_dialog == None: - permission_dialog_text = "[b]Missing Permissions[/b]\n\nOn this version of Android, location permission is required to scan for Bluetooth devices. Yes, this is silly, but there's no way around it.\n\nIf you don't want Sideband to have location access, you can disable this permission after scanning and pairing your RNode, and everything will still work, as it is only the scanning process that requires this." - yes_button = MDRectangleFlatButton(text="Grant Permission",font_size=dp(18), theme_text_color="Custom", line_color=self.color_accept, text_color=self.color_accept) - no_button = MDRectangleFlatButton(text="Cancel",font_size=dp(18)) - self.permission_dialog = MDDialog(text=permission_dialog_text, buttons=[ no_button, yes_button ]) - def dl_no(s): self.permission_dialog.dismiss() - def dl_yes(s): - self.permission_dialog.dismiss() - def cb(dt): self.request_location_permissions() - Clock.schedule_once(cb, 0.15) - - yes_button.bind(on_release=dl_yes) - no_button.bind(on_release=dl_no) - self.permission_dialog.open() - - else: - self.check_bluetooth_permissions() - if not self.sideband.getpersistent("permissions.bluetooth"): self.request_bluetooth_permissions() - else: - if self.root.ids.screen_manager.has_screen("hardware_rnode_screen") and hasattr(self, "hardware_rnode_screen") and self.hardware_rnode_screen != None: - self.hardware_rnode_screen.ids.hardware_rnode_bt_scan_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_scan_button.text = "Scanning..." - - toast("Starting Bluetooth scan...") - RNS.log("Starting bluetooth scan", RNS.LOG_DEBUG) - self.discovered_bt_devices = {} - if self.hardware_view: threading.Thread(target=self.hardware_view.hardware_rnode_scan_job, daemon=True).start() - - BluetoothDevice = autoclass('android.bluetooth.BluetoothDevice') - self.bt_found_action = BluetoothDevice.ACTION_FOUND - self.broadcast_receiver = BroadcastReceiver(self.on_broadcast, actions=[self.bt_found_action]) - self.broadcast_receiver.start() - - self.bt_adapter = BluetoothAdapter.getDefaultAdapter() - self.bluetooth_update_bonded_devices() - self.bt_adapter.startDiscovery() - - def stop_bluetooth_scan(self): - RNS.log("Stopping bluetooth scan", RNS.LOG_DEBUG) - self.check_bluetooth_permissions() - if not self.sideband.getpersistent("permissions.bluetooth"): - self.request_bluetooth_permissions() - else: - self.bt_adapter = BluetoothAdapter.getDefaultAdapter() - self.bt_adapter.cancelDiscovery() - - def on_broadcast(self, context, intent): - try: - BluetoothDevice = autoclass('android.bluetooth.BluetoothDevice') - action = intent.getAction() - extras = intent.getExtras() - - if str(action) == "android.bluetooth.device.action.FOUND": - if extras: - try: - if android_api_version < 33: device = intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") - else: device = intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE", BluetoothDevice) - dev_name = device.getName() - dev_addr = device.getAddress() - if dev_name.startswith("RNode "): - dev_rssi = extras.getShort("android.bluetooth.device.extra.RSSI", -9999) - discovered_device = {"name": dev_name, "address": dev_addr, "rssi": dev_rssi, "discovered": time.time()} - self.discovered_bt_devices[dev_addr] = discovered_device - RNS.log(f"Discovered RNode: {discovered_device}", RNS.LOG_DEBUG) - - except Exception as e: - RNS.log(f"Error while mapping discovered device: {e}", RNS.LOG_ERROR) - - except Exception as e: - RNS.log(f"An error occurred while receiving Android broadcast intent: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) - def on_new_intent(self, intent): - try: - intent_action = intent.getAction() - action = None - data = None + intent_action = intent.getAction() + action = None + data = None - RNS.log(f"Received intent: {intent_action}", RNS.LOG_DEBUG) + RNS.log(f"Received intent: {intent_action}", RNS.LOG_DEBUG) - if intent_action == "android.intent.action.MAIN": - JString = autoclass('java.lang.String') + if intent_action == "android.intent.action.MAIN": + JString = autoclass('java.lang.String') + Intent = autoclass("android.content.Intent") + try: + extras = intent.getExtras() + if extras: + data = extras.getString("intent_action", "undefined") + if data.startswith("conversation."): + conv_hexhash = bytes.fromhex(data.replace("conversation.", "")) + def cb(dt): + self.open_conversation(conv_hexhash) + Clock.schedule_once(cb, 0.2) + + except Exception as e: + RNS.log(f"Error while getting intent action data: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + if intent_action == "android.intent.action.WEB_SEARCH": + SearchManager = autoclass('android.app.SearchManager') + data = intent.getStringExtra(SearchManager.QUERY) + + if data.lower().startswith(LXMF.LXMessage.URI_SCHEMA): + action = "lxm_uri" + + if intent_action == "android.intent.action.VIEW": + data = intent.getData().toString() + if data.lower().startswith(LXMF.LXMessage.URI_SCHEMA): + action = "lxm_uri" + + if intent_action == "android.intent.action.SEND": + try: Intent = autoclass("android.content.Intent") - try: - extras = intent.getExtras() - if extras: - data = extras.getString("intent_action", "undefined") - if data.startswith("conversation."): - conv_hexhash = bytes.fromhex(data.replace("conversation.", "")) - def cb(dt): self.open_conversation(conv_hexhash) - Clock.schedule_once(cb, 0.2) + extras = intent.getExtras() + target = extras.get(Intent.EXTRA_STREAM) + mime_types = extras.get(Intent.EXTRA_MIME_TYPES) + target_uri = target.toString() + target_path = target.getPath() + target_filename = target.getLastPathSegment() - elif data.startswith("incoming_call"): - def cb(dt): self.voice_action() - Clock.schedule_once(cb, 0.2) + RNS.log(f"Received share intent: {target_uri} / {target_path} / {target_filename}", RNS.LOG_DEBUG) + for cf in os.listdir(self.sideband.share_cache): + rt = os.path.join(self.sideband.share_cache, cf) + os.unlink(rt) + RNS.log("Removed previously cached data: "+str(rt), RNS.LOG_DEBUG) - except Exception as e: - RNS.log(f"Error while getting intent action data: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) + ContentResolver = autoclass("android.content.ContentResolver") + cr = mActivity.getContentResolver() + cache_path = os.path.join(self.sideband.share_cache, target_filename) + input_stream = cr.openInputStream(target) + with open(cache_path, "wb") as cache_file: + cache_file.write(bytes(input_stream.readAllBytes())) + RNS.log("Cached shared data from Android intent", RNS.LOG_DEBUG) - if intent_action == "android.intent.action.WEB_SEARCH": - SearchManager = autoclass('android.app.SearchManager') - data = intent.getStringExtra(SearchManager.QUERY) - - if data.lower().startswith(LXMF.LXMessage.URI_SCHEMA): action = "lxm_uri" + action = "shared_data" + data = {"filename": target_filename, "data_path": cache_path} - if intent_action == "android.intent.action.VIEW": - data = intent.getData().toString() - if data.lower().startswith(LXMF.LXMessage.URI_SCHEMA): action = "lxm_uri" + except Exception as e: + RNS.log(f"Error while getting intent action data: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) - if intent_action == "android.intent.action.SEND": - try: - Intent = autoclass("android.content.Intent") - extras = intent.getExtras() - target = extras.get(Intent.EXTRA_STREAM) - mime_types = extras.get(Intent.EXTRA_MIME_TYPES) - target_uri = target.toString() - target_path = target.getPath() - target_filename = target.getLastPathSegment() - - RNS.log(f"Received share intent: {target_uri} / {target_path} / {target_filename}", RNS.LOG_DEBUG) - for cf in os.listdir(self.sideband.share_cache): - rt = os.path.join(self.sideband.share_cache, cf) - os.unlink(rt) - RNS.log("Removed previously cached data: "+str(rt), RNS.LOG_DEBUG) - - ContentResolver = autoclass("android.content.ContentResolver") - cr = mActivity.getContentResolver() - cache_path = os.path.join(self.sideband.share_cache, target_filename) - input_stream = cr.openInputStream(target) - with open(cache_path, "wb") as cache_file: - cache_file.write(bytes(input_stream.readAllBytes())) - RNS.log("Cached shared data from Android intent", RNS.LOG_DEBUG) - - action = "shared_data" - data = {"filename": target_filename, "data_path": cache_path} - - except Exception as e: - RNS.log(f"Error while getting intent action data: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) - - if action != None: self.handle_action(action, data) - - except Exception as e: - RNS.log(f"Error while handling received intent: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) + if action != None: + self.handle_action(action, data) def handle_action(self, action, data): if action == "lxm_uri": self.ingest_lxm_uri(data) if action == "shared_data": - RNS.log("Got shared data from Android intent", RNS.LOG_DEBUG) + RNS.log("Got shared data: "+str(data)) def cb(dt): try: self.shared_attachment_action(data) @@ -1441,14 +1034,13 @@ class SidebandApp(MDApp): self.sideband.lxm_ingest_uri(lxm_uri) def build(self): - FONT_PATH = os.path.join(self.sideband.asset_dir, "fonts") - if RNS.vendor.platformutils.is_darwin(): self.icon = os.path.join(self.sideband.asset_dir, "icon_macos_formed.png") - else: self.icon = os.path.join(self.sideband.asset_dir, "icon.png") + FONT_PATH = self.sideband.asset_dir+"/fonts" + if RNS.vendor.platformutils.is_darwin(): + self.icon = self.sideband.asset_dir+"/icon_macos_formed.png" + else: + self.icon = self.sideband.asset_dir+"/icon.png" self.announces_view = None - self.guide_view = None - self.keys_view = None - self.hardware_view = None if RNS.vendor.platformutils.is_android(): ActivityInfo = autoclass('android.content.pm.ActivityInfo') @@ -1480,8 +1072,9 @@ class SidebandApp(MDApp): dialog = MDDialog( title="Error", text=info_text, - buttons=[ ok_button ]) - + buttons=[ ok_button ], + # elevation=0, + ) def dl_ok(s): dialog.dismiss() self.quit_action(s) @@ -1503,47 +1096,26 @@ class SidebandApp(MDApp): description = rnode_errors["description"] self.sideband.setpersistent("runtime.errors.rnode", None) yes_button = MDRectangleFlatButton( - text="Ignore", - font_size=dp(18), - ) - restart_button = MDRectangleFlatButton( - text="Restart RNS", + text="OK", font_size=dp(18), ) self.hw_error_dialog = MDDialog( title="Hardware Error", text="While communicating with an RNode, Reticulum reported the following error:\n\n[i]"+str(description)+"[/i]", - buttons=[ yes_button, restart_button ], + buttons=[ yes_button ], # elevation=0, ) def dl_yes(s): self.hw_error_dialog.dismiss() self.hw_error_dialog.is_open = False - def dl_restart(s): - self.hw_error_dialog.dismiss() - self.hw_error_dialog.is_open = False - self.restart_service_action(None) yes_button.bind(on_release=dl_yes) - restart_button.bind(on_release=dl_restart) self.hw_error_dialog.open() self.hw_error_dialog.is_open = True - if RNS.vendor.platformutils.is_android(): - service_voice_running = self.sideband.service_voice_running() - if service_voice_running: self.sideband.voice_running = True - else: self.sideband.voice_running = False - - if self.sideband.voice_running: - incoming_call = self.sideband.getstate("voice.incoming_call") - ended_call = self.sideband.getstate("voice.ongoing_ended") - if incoming_call: - self.sideband.setstate("voice.incoming_call", None) - dn = multilingual_markup(escape_markup(str(incoming_call)).encode("utf-8")).decode("utf-8") - toast(f"Call from {dn}", duration=4) - - if ended_call: - self.sideband.setstate("voice.ongoing_ended", False) - toast("Call ended", duration=4) + incoming_call = self.sideband.getstate("voice.incoming_call") + if incoming_call: + self.sideband.setstate("voice.incoming_call", None) + toast(f"Call from {incoming_call}", duration=7) if self.root.ids.screen_manager.current == "messages_screen": self.messages_view.update() @@ -1663,38 +1235,16 @@ class SidebandApp(MDApp): Window.bind(on_request_close=self.close_requested) Window.bind(on_drop_file=self.file_dropped) - Window.bind(on_maximize=self.on_maximize) - Window.bind(on_minimize=self.on_minimize) - Window.bind(on_restore=self.on_restore) - - patch_sdl_window_events(Window) - - if __variant__ != "": variant_str = " "+__variant__ - else: variant_str = "" + if __variant__ != "": + variant_str = " "+__variant__ + else: + variant_str = "" self.root.ids.screen_manager.app = self self.root.ids.app_version_info.text = "Sideband v"+__version__+variant_str self.root.ids.nav_scrollview.effect_cls = ScrollEffect Clock.schedule_once(self.start_core, 0.25) - def close_handler(self): - if self.root.ids.screen_manager.current == "conversations_screen": - if self.include_conversations and not self.include_objects: self.quit_action(self) - else: self.conversations_action(direction="right") - elif self.root.ids.screen_manager.current == "hardware_rnode_screen": self.close_sub_hardware_action() - elif self.root.ids.screen_manager.current == "hardware_modem_screen": self.close_sub_hardware_action() - elif self.root.ids.screen_manager.current == "hardware_serial_screen": self.close_sub_hardware_action() - elif self.root.ids.screen_manager.current == "map_settings_screen": self.close_sub_map_action() - elif self.root.ids.screen_manager.current == "object_details_screen": self.object_details_screen.close_action() - elif self.root.ids.screen_manager.current == "sensors_screen": self.close_sub_telemetry_action() - elif self.root.ids.screen_manager.current == "icons_screen": self.close_sub_telemetry_action() - elif self.root.ids.screen_manager.current == "utilities_screen": self.close_any_action() - elif self.root.ids.screen_manager.current == "rnstatus_screen": self.utilities_screen.close_rnstatus_action() - elif self.root.ids.screen_manager.current == "logviewer_screen": self.close_sub_utilities_action() - elif self.root.ids.screen_manager.current == "advanced_screen": self.close_sub_utilities_action() - elif self.root.ids.screen_manager.current == "voice_settings_screen": self.close_sub_voice_action() - else: self.close_any_action() - def keyup_event(self, instance, keyboard, keycode): if self.keyboard_enabled: if self.root.ids.screen_manager.current == "messages_screen": @@ -1735,12 +1285,15 @@ class SidebandApp(MDApp): if not self.messages_view.ids.message_text.focus: self.messages_view.ids.message_text.write_tab = False self.messages_view.ids.message_text.focus = True - def tab_job(delta): self.messages_view.ids.message_text.write_tab = True + def tab_job(delta): + self.messages_view.ids.message_text.write_tab = True Clock.schedule_once(tab_job, 0.15) elif len(modifiers) == 0 and self.rec_dialog != None and self.rec_dialog_is_open: - if text == " ": self.msg_rec_a_rec(None) - elif keycode == 40: self.msg_rec_a_save(None) + if text == " ": + self.msg_rec_a_rec(None) + elif keycode == 40: + self.msg_rec_a_save(None) elif len(modifiers) == 0 and not self.rec_dialog_is_open and not self.messages_view.ids.message_text.focus and self.messages_view.ptt_enabled and keycode == 44: if not self.key_ptt_down: @@ -1753,9 +1306,12 @@ class SidebandApp(MDApp): self.attach_path = None self.attach_type = None self.update_message_widgets() - if text == "a": clear_att(); self.message_attachment_action(None) - if text == "i": clear_att(); self.message_attach_action(attach_type="defimg") - if text == "f": clear_att(); self.message_attach_action(attach_type="file") + if text == "a": + clear_att(); self.message_attachment_action(None) + if text == "i": + clear_att(); self.message_attach_action(attach_type="defimg") + if text == "f": + clear_att(); self.message_attach_action(attach_type="file") if text == "v": clear_att() self.audio_msg_mode = LXMF.AM_OPUS_OGG @@ -1770,8 +1326,25 @@ class SidebandApp(MDApp): if text == "q": self.quit_action(self) - if text == "w": self.close_handler() - + if text == "w": + if self.root.ids.screen_manager.current == "conversations_screen": + if self.include_conversations and not self.include_objects: + self.quit_action(self) + else: + self.conversations_action(direction="right") + elif self.root.ids.screen_manager.current == "map_settings_screen": + self.close_sub_map_action() + elif self.root.ids.screen_manager.current == "object_details_screen": + self.object_details_screen.close_action() + elif self.root.ids.screen_manager.current == "sensors_screen": + self.close_sub_telemetry_action() + elif self.root.ids.screen_manager.current == "icons_screen": + self.close_sub_telemetry_action() + elif self.root.ids.screen_manager.current == "utilities_screen": + self.close_sub_utilities_action() + else: + self.open_conversations(direction="right") + if text == "s" or text == "d": if self.root.ids.screen_manager.current == "messages_screen": self.message_send_action() @@ -1857,12 +1430,38 @@ class SidebandApp(MDApp): # Handle escape/back if key == 27: if self.root.ids.screen_manager.current == "conversations_screen": - if not self.include_conversations and self.include_objects: self.conversations_action(direction="right") + if not self.include_conversations and self.include_objects: + self.conversations_action(direction="right") else: - if time.time() - self.last_exit_event < 2: self.quit_action(self) - else: self.last_exit_event = time.time() + if time.time() - self.last_exit_event < 2: + self.quit_action(self) + else: + self.last_exit_event = time.time() + + else: + if self.root.ids.screen_manager.current == "hardware_rnode_screen": + self.close_sub_hardware_action() + elif self.root.ids.screen_manager.current == "hardware_modem_screen": + self.close_sub_hardware_action() + elif self.root.ids.screen_manager.current == "hardware_serial_screen": + self.close_sub_hardware_action() + elif self.root.ids.screen_manager.current == "object_details_screen": + self.object_details_screen.close_action() + elif self.root.ids.screen_manager.current == "map_settings_screen": + self.close_sub_map_action() + elif self.root.ids.screen_manager.current == "sensors_screen": + self.close_sub_telemetry_action() + elif self.root.ids.screen_manager.current == "icons_screen": + self.close_sub_telemetry_action() + elif self.root.ids.screen_manager.current == "rnstatus_screen": + self.close_sub_utilities_action() + elif self.root.ids.screen_manager.current == "logviewer_screen": + self.close_sub_utilities_action() + elif self.root.ids.screen_manager.current == "voice_settings_screen": + self.close_sub_voice_action() + else: + self.open_conversations(direction="right") - else: self.close_handler() return True def widget_hide(self, w, hide=True): @@ -2024,24 +1623,11 @@ class SidebandApp(MDApp): def conversation_index_action(self, index): if self.conversations_view != None and self.conversations_view.list != None: i = index-1 - c = self.conversations_view.list.children[0].children + c = self.conversations_view.list.children if len(c) > i: item = c[(len(c)-1)-i] self.conversation_action(item) - def init_confirm_call_dialog(self, call_dialog_text): - yes_button = MDRectangleFlatButton(text="Call",font_size=dp(18), theme_text_color="Custom", line_color=self.color_accept, text_color=self.color_accept) - no_button = MDRectangleFlatButton(text="Cancel",font_size=dp(18)) - self.confirm_call_dialog = MDDialog(text=call_dialog_text, buttons=[ no_button, yes_button ]) - def dl_no(s): self.confirm_call_dialog.dismiss() - def dl_yes(s): - self.confirm_call_dialog.dismiss() - def cb(dt): self.dial_action(self.confirm_call_dialog.dest_identity_hash) - Clock.schedule_once(cb, 0.15) - - yes_button.bind(on_release=dl_yes) - no_button.bind(on_release=dl_no) - def conversation_action(self, sender): if sender.conv_type == self.sideband.CONV_P2P: context_dest = sender.sb_uid @@ -2052,15 +1638,8 @@ class SidebandApp(MDApp): elif sender.conv_type == self.sideband.CONV_VOICE: identity_hash = sender.sb_uid - if not self.sideband.config["confirm_calls"]: - def cb(dt): self.dial_action(identity_hash) - Clock.schedule_once(cb, 0.15) - else: - call_dialog_text = f"[b]Initiate Voice Call?[/b]\n\nDestination Identity:\n{RNS.prettyhexrep(identity_hash)}" - if hasattr(self, "confirm_call_dialog"): self.confirm_call_dialog.text = call_dialog_text - else: self.init_confirm_call_dialog(call_dialog_text) - self.confirm_call_dialog.dest_identity_hash = identity_hash - self.confirm_call_dialog.open() + def cb(dt): self.dial_action(identity_hash) + Clock.schedule_once(cb, 0.15) def open_conversation(self, context_dest, direction="left"): self.rec_dialog_is_open = False @@ -2098,6 +1677,7 @@ class SidebandApp(MDApp): self.update_message_widgets() self.messages_view.ids.message_text.disabled = False + self.root.ids.screen_manager.current = "messages_screen" self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) @@ -2121,12 +1701,14 @@ class SidebandApp(MDApp): if self.outbound_mode_command: return - def cb(dt): self.message_send_dispatch(sender) + def cb(dt): + self.message_send_dispatch(sender) Clock.schedule_once(cb, 0.20) def message_send_dispatch(self, sender=None): self.messages_view.ids.message_send_button.disabled = True - def cb(dt): self.messages_view.ids.message_send_button.disabled = False + def cb(dt): + self.messages_view.ids.message_send_button.disabled = False Clock.schedule_once(cb, 0.5) if self.root.ids.screen_manager.current == "messages_screen": @@ -2409,68 +1991,117 @@ class SidebandApp(MDApp): self.compat_error_dialog.open() def play_audio_field(self, audio_field): - try: - if self.msg_sound != None and self.msg_sound.playing: - RNS.log("Stopping playback", RNS.LOG_DEBUG) - self.msg_sound.stop() - return - - temp_path = None - if self.last_msg_audio != audio_field[1]: - RNS.log("Reloading audio source", RNS.LOG_DEBUG) - if len(audio_field[1]) > 10: self.last_msg_audio = audio_field[1] - else: - self.last_msg_audio = None - return - - if audio_field[0] == LXMF.AM_OPUS_OGG: - temp_path = os.path.join(self.sideband.rec_cache, "msg.ogg") - with open(temp_path, "wb") as af: af.write(self.last_msg_audio) - - elif audio_field[0] >= LXMF.AM_CODEC2_700C and audio_field[0] <= LXMF.AM_CODEC2_3200: - temp_path = os.path.join(self.sideband.rec_cache, "msg.ogg") - from sideband.audioproc import samples_to_ogg, decode_codec2, detect_codec2 - - target_rate = 48000 - if detect_codec2(): - if samples_to_ogg(decode_codec2(audio_field[1], audio_field[0]), temp_path, input_rate=8000, output_rate=target_rate): RNS.log("Wrote OGG file to: "+temp_path, RNS.LOG_DEBUG) - else: RNS.log("OGG write failed", RNS.LOG_DEBUG) + if RNS.vendor.platformutils.is_darwin(): + if self.compat_error_dialog == None: + def cb(sender): + self.compat_error_dialog.dismiss() + self.compat_error_dialog = MDDialog( + title="Unsupported Feature on macOS", + text="Audio message functionality is currently only implemented on Linux and Android. Please support the development if you need this feature on macOS.", + buttons=[ + MDRectangleFlatButton( + text="OK", + font_size=dp(18), + on_release=cb + ) + ], + ) + self.compat_error_dialog.open() + return + elif RNS.vendor.platformutils.is_windows(): + if self.compat_error_dialog == None: + def cb(sender): + self.compat_error_dialog.dismiss() + self.compat_error_dialog = MDDialog( + title="Unsupported Feature on Windows", + text="Audio message functionality is currently only implemented on Linux and Android. Please support the development if you need this feature on Windows.", + buttons=[ + MDRectangleFlatButton( + text="OK", + font_size=dp(18), + on_release=cb + ) + ], + ) + self.compat_error_dialog.open() + return + else: + try: + temp_path = None + if self.last_msg_audio != audio_field[1]: + RNS.log("Reloading audio source", RNS.LOG_DEBUG) + if len(audio_field[1]) > 10: + self.last_msg_audio = audio_field[1] else: self.last_msg_audio = None - self.display_codec2_error() return - - else: raise NotImplementedError(audio_field[0]) - if self.msg_sound == None: - self.msg_sound = FilePlayer() + if audio_field[0] == LXMF.AM_OPUS_OGG: + temp_path = self.sideband.rec_cache+"/msg.ogg" + with open(temp_path, "wb") as af: + af.write(self.last_msg_audio) - self.msg_sound.set_source(temp_path) + elif audio_field[0] >= LXMF.AM_CODEC2_700C and audio_field[0] <= LXMF.AM_CODEC2_3200: + temp_path = self.sideband.rec_cache+"/msg.ogg" + from sideband.audioproc import samples_to_ogg, decode_codec2, detect_codec2 + + target_rate = 8000 + if RNS.vendor.platformutils.is_linux(): + target_rate = 48000 - if self.msg_sound != None: - RNS.log("Starting playback", RNS.LOG_DEBUG) - self.msg_sound.play() - else: - RNS.log("Playback was requested, but no audio data was loaded for playback", RNS.LOG_ERROR) + if detect_codec2(): + if samples_to_ogg(decode_codec2(audio_field[1], audio_field[0]), temp_path, input_rate=8000, output_rate=target_rate): + RNS.log("Wrote OGG file to: "+temp_path, RNS.LOG_DEBUG) + else: + RNS.log("OGG write failed", RNS.LOG_DEBUG) + else: + self.last_msg_audio = None + self.display_codec2_error() + return + + else: + raise NotImplementedError(audio_field[0]) - except Exception as e: - RNS.log("Error while playing message audio:"+str(e)) - RNS.trace_exception(e) + if self.msg_sound == None: + if RNS.vendor.platformutils.is_android(): + from plyer import audio + self.request_microphone_permission() + else: + from sbapp.plyer import audio + + self.msg_sound = audio + + self.msg_sound._file_path = temp_path + self.msg_sound.reload() + + if self.msg_sound != None and self.msg_sound.playing(): + RNS.log("Stopping playback", RNS.LOG_DEBUG) + self.msg_sound.stop() + else: + if self.msg_sound != None: + RNS.log("Starting playback", RNS.LOG_DEBUG) + self.msg_sound.play() + else: + RNS.log("Playback was requested, but no audio data was loaded for playback", RNS.LOG_ERROR) + + except Exception as e: + RNS.log("Error while playing message audio:"+str(e)) + RNS.trace_exception(e) def message_ptt_down_action(self, sender=None): - if self.sideband.ui_recording: return + if self.sideband.ui_recording: + return self.sideband.ui_started_recording() - self.recording_started = time.time() - if self.sideband.config["hq_ptt"]: self.audio_msg_mode = LXMF.AM_OPUS_OGG - else: self.audio_msg_mode = LXMF.AM_CODEC2_2400 - - if not hasattr(self, "ptt_recorder") or self.ptt_recorder == None: - self.ptt_recording_path = os.path.join(self.sideband.rec_cache, "ptt_recording.ogg") - self.ptt_recorder = FileRecorder(self.ptt_recording_path, profile=Opus.PROFILE_VOICE_HIGH, gain=2.0, - skip=0.075, ease_in=0.125, filters=[BandPass(300, 8500), AGC(target_level=-15.0)]) + if self.sideband.config["hq_ptt"]: + self.audio_msg_mode = LXMF.AM_OPUS_OGG + else: + self.audio_msg_mode = LXMF.AM_CODEC2_2400 self.message_attach_action(attach_type="audio", nodialog=True) + if self.rec_dialog == None: + self.message_init_rec_dialog() + self.rec_dialog.recording = True el_button = self.messages_view.ids.message_ptt_button el_icon = self.messages_view.ids.message_ptt_button.children[0].children[1] el_button.theme_text_color="Custom" @@ -2478,12 +2109,16 @@ class SidebandApp(MDApp): el_button.line_color=mdc("Orange","400") el_icon.theme_text_color="Custom" el_icon.text_color=mdc("Orange","400") - self.ptt_recorder.start() + def cb(dt): + self.msg_audio.start() + Clock.schedule_once(cb, 0.15) def message_ptt_up_action(self, sender=None): - if not self.sideband.ui_recording: return + if not self.sideband.ui_recording: + return + self.rec_dialog.recording = False el_button = self.messages_view.ids.message_ptt_button el_icon = self.messages_view.ids.message_ptt_button.children[0].children[1] el_button.theme_text_color="Custom" @@ -2491,47 +2126,41 @@ class SidebandApp(MDApp): el_button.line_color=mdc("BlueGray","500") el_icon.theme_text_color="Custom" el_icon.text_color=mdc("BlueGray","500") - - def job(): + def cb_s(dt): try: - self.ptt_recorder.stop() - self.ptt_recorder = None + self.msg_audio.stop() except Exception as e: RNS.log("An error occurred while stopping recording: "+str(e), RNS.LOG_ERROR) RNS.trace_exception(e) self.sideband.ui_stopped_recording() - if self.recording_started != None: - duration = time.time() - self.recording_started - self.recording_started = None - if duration < 0.6: RNS.log(f"Discarding recording, only {RNS.prettyshorttime(duration)} of audio", RNS.LOG_WARNING) - else: - if os.path.isfile(self.ptt_recording_path): - if self.message_process_audio(self.ptt_recording_path): - if self.outbound_mode_command: self.outbound_mode_reset() - self.message_send_action() + if self.message_process_audio(): + if self.outbound_mode_command: + self.outbound_mode_reset() + self.message_send_action() + Clock.schedule_once(cb_s, 0.35) - threading.Thread(target=job, daemon=True).start() - - def message_process_audio(self, input_path): - from sideband.audioproc import voice_processing + def message_process_audio(self): if self.audio_msg_mode == LXMF.AM_OPUS_OGG: - proc_path = voice_processing(input_path) + from sideband.audioproc import voice_processing + proc_path = voice_processing(self.msg_audio._file_path) if proc_path: self.attach_path = proc_path - os.unlink(input_path) + os.unlink(self.msg_audio._file_path) RNS.log("Using voice-processed OPUS data in OGG container", RNS.LOG_DEBUG) else: - self.attach_path = input_path + self.attach_path = self.msg_audio._file_path RNS.log("Using unmodified OPUS data in OGG container", RNS.LOG_DEBUG) else: ap_start = time.time() - proc_path = voice_processing(input_path) + from sideband.audioproc import voice_processing + proc_path = voice_processing(self.msg_audio._file_path) + if proc_path: opus_file = pyogg.OpusFile(proc_path) RNS.log("Using voice-processed audio for codec2 encoding", RNS.LOG_DEBUG) else: - opus_file = pyogg.OpusFile(input_path) + opus_file = pyogg.OpusFile(self.msg_audio._file_path) RNS.log("Using unprocessed audio data for codec2 encoding", RNS.LOG_DEBUG) RNS.log(f"OPUS LOAD {opus_file.frequency}Hz {opus_file.bytes_per_sample*8}bit {opus_file.channels}ch") @@ -2557,11 +2186,11 @@ class SidebandApp(MDApp): ap_duration = time.time() - ap_start RNS.log("Audio processing complete in "+RNS.prettytime(ap_duration), RNS.LOG_DEBUG) - export_path = os.path.join(self.sideband.rec_cache, "recording.enc") + export_path = self.sideband.rec_cache+"/recording.enc" with open(export_path, "wb") as export_file: export_file.write(encoded) self.attach_path = export_path - os.unlink(input_path) + os.unlink(self.msg_audio._file_path) else: self.display_codec2_error() return False @@ -2570,15 +2199,19 @@ class SidebandApp(MDApp): def message_init_rec_dialog(self): ss = int(dp(18)) - if RNS.vendor.platformutils.is_android(): self.request_microphone_permission() - self.recording_player = FilePlayer() + if RNS.vendor.platformutils.is_android(): + from plyer import audio + self.request_microphone_permission() + else: + from sbapp.plyer import audio + + self.msg_audio = audio + self.msg_audio._file_path = self.sideband.rec_cache+"/recording.ogg" def a_rec_action(sender): - if not self.rec_dialog.recording and not self.rec_dialog.recorder: + if not self.rec_dialog.recording: self.sideband.ui_started_recording() - self.rec_dialog.recorder = FileRecorder(self.rec_dialog.file_path, profile=Opus.PROFILE_VOICE_HIGH, gain=2.0, - skip=0.075, ease_in=0.125, filters=[BandPass(300, 8500), AGC(target_level=-15.0)]) - RNS.log("Starting recording...", RNS.LOG_DEBUG) # TODO: Remove + RNS.log("Starting recording...") # TODO: Remove self.rec_dialog.recording = True el = self.rec_dialog.rec_item.children[0].children[0] el.ttc = el.theme_text_color; el.tc = el.text_color @@ -2586,40 +2219,40 @@ class SidebandApp(MDApp): el.text_color=mdc("Red","400") el.icon = "stop-circle" self.rec_dialog.rec_item.text = "[size="+str(ss)+"]Stop Recording[/size]" - self.rec_dialog.recorder.start() + def cb(dt): + self.msg_audio.start() + Clock.schedule_once(cb, 0.10) else: - RNS.log("Stopping recording...", RNS.LOG_DEBUG) # TODO: Remove - self.rec_dialog.recorder.stop() - self.rec_dialog.recorder = None - RNS.log("Recording stopped", RNS.LOG_DEBUG) # TODO: Remove + self.sideband.ui_stopped_recording() + RNS.log("Stopping recording...") # TODO: Remove + self.rec_dialog.recording = False self.rec_dialog.rec_item.text = "[size="+str(ss)+"]Start Recording[/size]" el = self.rec_dialog.rec_item.children[0].children[0] el.icon = "record" el.text_color = self.theme_cls._get_text_color() self.rec_dialog.play_item.disabled = False self.rec_dialog.save_item.disabled = False - self.rec_dialog.recording = False - self.sideband.ui_stopped_recording() + self.msg_audio.stop() self.msg_rec_a_rec = a_rec_action def a_play(sender): - if self.rec_dialog.recording: a_rec_action(sender) + if self.rec_dialog.recording: + a_rec_action(sender) if not self.rec_dialog.playing: RNS.log("Playing recording...", RNS.LOG_DEBUG) self.rec_dialog.playing = True self.rec_dialog.play_item.children[0].children[0].icon = "stop" self.rec_dialog.play_item.text = "[size="+str(ss)+"]Stop[/size]" - self.recording_player.set_source(self.rec_dialog.file_path) - self.recording_player.play() + self.msg_audio.play() else: RNS.log("Stopping playback...", RNS.LOG_DEBUG) self.rec_dialog.playing = False self.rec_dialog.play_item.children[0].children[0].icon = "play" self.rec_dialog.play_item.text = "[size="+str(ss)+"]Play[/size]" - self.recording_player.stop() + self.msg_audio.stop() self.msg_rec_a_play = a_play @@ -2629,16 +2262,30 @@ class SidebandApp(MDApp): self.rec_dialog.play_item.children[0].children[0].icon = "play" self.rec_dialog.play_item.text = "[size="+str(ss)+"]Play[/size]" - self.recording_player.finished_callback = a_finished + self.msg_audio._finished_callback = a_finished def a_save(sender): - if self.rec_dialog.recording: a_rec_action(sender) + if self.rec_dialog.recording: + a_rec_action(sender) self.rec_dialog_is_open = False self.rec_dialog.dismiss() try: - self.message_process_audio(self.rec_dialog.file_path) - if self.outbound_mode_command: self.outbound_mode_reset() + if self.audio_msg_mode == LXMF.AM_OPUS_OGG: + from sideband.audioproc import voice_processing + proc_path = voice_processing(self.msg_audio._file_path) + if proc_path: + self.attach_path = proc_path + os.unlink(self.msg_audio._file_path) + RNS.log("Using voice-processed OPUS data in OGG container", RNS.LOG_DEBUG) + else: + self.attach_path = self.msg_audio._file_path + RNS.log("Using unmodified OPUS data in OGG container", RNS.LOG_DEBUG) + else: + self.message_process_audio() + + if self.outbound_mode_command: + self.outbound_mode_reset() self.update_message_widgets() toast("Added recorded audio to message") @@ -2654,6 +2301,7 @@ class SidebandApp(MDApp): self.rec_dialog = MDDialog( title="Record Audio", type="simple", + # text="Test\n", items=[ rec_item, play_item, @@ -2663,17 +2311,16 @@ class SidebandApp(MDApp): width_offset=dp(32), ) cancel_button.bind(on_release=self.rec_dialog.dismiss) - self.rec_dialog.recorder = None self.rec_dialog.recording = False self.rec_dialog.playing = False self.rec_dialog.rec_item = rec_item self.rec_dialog.play_item = play_item self.rec_dialog.save_item = save_item - self.rec_dialog.file_path = os.path.join(self.sideband.rec_cache, "recording.ogg") def message_record_audio_action(self): ss = int(dp(18)) - if self.rec_dialog == None: self.message_init_rec_dialog() + if self.rec_dialog == None: + self.message_init_rec_dialog() else: self.rec_dialog.play_item.disabled = True @@ -2734,6 +2381,14 @@ class SidebandApp(MDApp): DialogItem(IconLeftWidget(icon="account-voice", on_release=a_audio_lb), text="[size="+str(ss)+"]Low-bandwidth Voice[/size]", on_release=a_audio_lb), DialogItem(IconLeftWidget(icon="microphone-message", on_release=a_audio_hq), text="[size="+str(ss)+"]High-quality Voice[/size]", on_release=a_audio_hq), DialogItem(IconLeftWidget(icon="file-outline", on_release=a_file), text="[size="+str(ss)+"]File Attachment[/size]", on_release=a_file)] + + if RNS.vendor.platformutils.is_windows(): + ad_items.pop(3) + ad_items.pop(3) + + if RNS.vendor.platformutils.is_darwin(): + ad_items.pop(3) + ad_items.pop(3) if RNS.vendor.platformutils.is_android() and android_api_version < 29: ad_items.pop(3) @@ -2913,6 +2568,12 @@ class SidebandApp(MDApp): if not self.conversations_view: self.conversations_view = Conversations(self) + for child in self.conversations_view.ids.conversations_scrollview.children: + self.conversations_view.ids.conversations_scrollview.remove_widget(child) + + self.conversations_view.ids.conversations_scrollview.effect_cls = ScrollEffect + self.conversations_view.ids.conversations_scrollview.add_widget(self.conversations_view.get_widget()) + self.root.ids.screen_manager.current = "conversations_screen" if self.messages_view: self.messages_view.ids.messages_scrollview.active_conversation = None @@ -2947,17 +2608,20 @@ class SidebandApp(MDApp): dialog = MDDialog( title="Connectivity Status", text=str(self.get_connectivity_text()), - buttons=[full_button, yes_button]) - def cs_updater(dt): dialog.text = str(self.get_connectivity_text()) - + buttons=[full_button, yes_button], + # elevation=0, + ) + def cs_updater(dt): + dialog.text = str(self.get_connectivity_text()) def dl_yes(s): dialog.dismiss() - if self.connectivity_updater != None: self.connectivity_updater.cancel() - + if self.connectivity_updater != None: + self.connectivity_updater.cancel() def cb_rns(sender): dialog.dismiss() - if self.connectivity_updater != None: self.connectivity_updater.cancel() - self.rnstatus_action(from_conversations=True) + if self.connectivity_updater != None: + self.connectivity_updater.cancel() + self.rnstatus_action() yes_button.bind(on_release=dl_yes) full_button.bind(on_release=cb_rns) @@ -2969,11 +2633,12 @@ class SidebandApp(MDApp): self.connectivity_updater = Clock.schedule_interval(cs_updater, 2.0) else: - self.rnstatus_action(from_conversations=True) + self.rnstatus_action() - def rnstatus_action(self, sender=None, from_conversations=False): - if not self.utilities_ready: self.utilities_init() - self.utilities_screen.rnstatus_action(from_conversations=from_conversations) + def rnstatus_action(self, sender=None): + if not self.utilities_ready: + self.utilities_init() + self.utilities_screen.rnstatus_action() def ingest_lxm_action(self, sender): def cb(dt): @@ -3066,10 +2731,12 @@ class SidebandApp(MDApp): dialog.dismiss() self.message_sync_dialog.d_content.ids.sync_progress.value = 0.1 self.message_sync_dialog.d_content.ids.sync_status.text = "" - if self.sideband.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: - self.sideband.message_router.acknowledge_sync_completion(reset_state=True) + + # self.sideband.cancel_lxmf_sync() def dl_stop(s): + # self.sideband.setstate("app.flags.lxmf_sync_dialog_open", False) + # dialog.dismiss() self.sideband.cancel_lxmf_sync() def cb(dt): self.widget_hide(self.sync_dialog.stop_button, True) @@ -3189,14 +2856,14 @@ class SidebandApp(MDApp): threading.Thread(target=lj, daemon=True).start() self.information_screen.ids.information_scrollview.effect_cls = ScrollEffect - self.information_screen.ids.information_logo.icon = os.path.join(self.sideband.asset_dir, "rns_256.png") + self.information_screen.ids.information_logo.icon = self.sideband.asset_dir+"/rns_256.png" - str_comps = " - [b]Reticulum[/b] (Reticulum License)\n - [b]LXMF[/b] (Reticulum License)\n - [b]LXST[/b] (Reticulum License)" - str_comps += "\n - [b]Kivy[/b] (MIT License)\n - [b]KivyMD[/b] (MIT License)" - str_comps += "\n - [b]Codec2[/b] (LGPL License)\n - [b]PyCodec2[/b] (BSD-3 License)\n - [b]Able[/b] (MIT License)" + str_comps = " - [b]Reticulum[/b] (MIT License)\n - [b]LXMF[/b] (MIT License)\n - [b]KivyMD[/b] (MIT License)" + str_comps += "\n - [b]Kivy[/b] (MIT License)\n - [b]Codec2[/b] (LGPL License)\n - [b]PyCodec2[/b] (BSD-3 License)" + str_comps += "\n - [b]PyDub[/b] (MIT License)\n - [b]PyOgg[/b] (Public Domain)\n - [b]FFmpeg[/b] (GPL3 License)" str_comps += "\n - [b]GeoidHeight[/b] (LGPL License)\n - [b]Paho MQTT[/b] (EPL2 License)\n - [b]Python[/b] (PSF License)" str_comps += "\n\nGo to [u][ref=link]https://unsigned.io/donate[/ref][/u] to support the project.\n\nThe Sideband app is Copyright © 2025 Mark Qvist / unsigned.io\n\nPermission is granted to freely share and distribute binary copies of "+self.root.ids.app_version_info.text+", so long as no payment or compensation is charged for said distribution or sharing.\n\nIf you were charged or paid anything for this copy of Sideband, please report it to [b]license@unsigned.io[/b].\n\nTHIS IS EXPERIMENTAL SOFTWARE - SIDEBAND COMES WITH ABSOLUTELY NO WARRANTY - USE AT YOUR OWN RISK AND RESPONSIBILITY" - info = "This is "+self.root.ids.app_version_info.text+"\nRunning on RNS v"+RNS.__version__+", LXMF v"+LXMF.__version__+" and LXST v"+lxst_version+".\n\nHumbly build using the following open components:\n\n"+str_comps + info = "This is "+self.root.ids.app_version_info.text+", on RNS v"+RNS.__version__+" and LXMF v"+LXMF.__version__+".\n\nHumbly build using the following open components:\n\n"+str_comps self.information_screen.ids.information_info.text = info self.information_screen.ids.information_info.bind(on_ref_press=link_exec) @@ -3535,13 +3202,9 @@ class SidebandApp(MDApp): self.sideband.save_configuration() if self.sideband.config["voice_enabled"] == True: - self.request_microphone_permission() self.sideband.start_voice() - else: self.sideband.stop_voice() - - def save_start_at_boot(sender=None, event=None): - self.sideband.config["start_at_boot"] = self.settings_screen.ids.settings_start_at_boot.active - self.sideband.save_configuration() + else: + self.sideband.stop_voice() def save_print_command(sender=None, event=None): if not sender.focus: @@ -3622,10 +3285,11 @@ class SidebandApp(MDApp): else: self.settings_screen.ids.settings_print_command.text = self.sideband.config["print_command"] self.settings_screen.ids.settings_print_command.bind(focus=save_print_command) - self.settings_screen.ids.settings_start_at_boot.disabled = True - if self.sideband.config["lxmf_propagation_node"] == None: prop_node_addr = "" - else: prop_node_addr = RNS.hexrep(self.sideband.config["lxmf_propagation_node"], delimit=False) + if self.sideband.config["lxmf_propagation_node"] == None: + prop_node_addr = "" + else: + prop_node_addr = RNS.hexrep(self.sideband.config["lxmf_propagation_node"], delimit=False) self.settings_screen.ids.settings_propagation_node_address.text = prop_node_addr self.settings_screen.ids.settings_propagation_node_address.bind(focus=save_prop_addr) @@ -3655,9 +3319,6 @@ class SidebandApp(MDApp): self.settings_screen.ids.settings_advanced_statistics.active = self.sideband.config["advanced_stats"] self.settings_screen.ids.settings_advanced_statistics.bind(active=save_advanced_stats) - self.settings_screen.ids.settings_start_at_boot.active = self.sideband.config["start_at_boot"] - self.settings_screen.ids.settings_start_at_boot.bind(active=save_start_at_boot) - self.settings_screen.ids.settings_start_announce.active = self.sideband.config["start_announce"] self.settings_screen.ids.settings_start_announce.bind(active=save_start_announce) @@ -3722,6 +3383,7 @@ class SidebandApp(MDApp): self.settings_screen.ids.settings_voice_enabled.active = self.sideband.config["voice_enabled"] self.settings_screen.ids.settings_voice_enabled.bind(active=save_voice_enabled) + if RNS.vendor.platformutils.is_android(): self.settings_screen.ids.settings_voice_enabled.disabled = True self.settings_screen.ids.settings_debug.active = self.sideband.config["debug"] self.settings_screen.ids.settings_debug.bind(active=save_debug) @@ -3835,9 +3497,7 @@ class SidebandApp(MDApp): self.widget_hide(self.connectivity_screen.ids.connectivity_enable_transport) self.widget_hide(self.connectivity_screen.ids.connectivity_serial_label) self.widget_hide(self.connectivity_screen.ids.connectivity_use_serial) - self.widget_hide(self.connectivity_screen.ids.connectivity_use_weave) self.widget_hide(self.connectivity_screen.ids.connectivity_serial_fields) - self.widget_hide(self.connectivity_screen.ids.connectivity_share_instance) self.widget_hide(self.connectivity_screen.ids.connectivity_shared_access) self.widget_hide(self.connectivity_screen.ids.connectivity_shared_access_label) self.widget_hide(self.connectivity_screen.ids.connectivity_shared_access_fields) @@ -3845,7 +3505,6 @@ class SidebandApp(MDApp): self.widget_hide(self.connectivity_screen.ids.connectivity_enable_transport) self.widget_hide(self.connectivity_screen.ids.connectivity_transport_info) self.widget_hide(self.connectivity_screen.ids.connectivity_transport_fields) - self.widget_hide(self.connectivity_screen.ids.connectivity_service_restart_fields) def con_collapse_local(collapse=True): # self.widget_hide(self.connectivity_screen.ids.connectivity_local_fields, collapse) @@ -3875,17 +3534,12 @@ class SidebandApp(MDApp): # self.widget_hide(self.connectivity_screen.ids.connectivity_serial_fields, collapse) pass - def con_collapse_weave(collapse=True): - # self.widget_hide(self.connectivity_screen.ids.connectivity_serial_fields, collapse) - pass - def con_collapse_transport(collapse=True): # self.widget_hide(self.connectivity_screen.ids.connectivity_transport_fields, collapse) pass def save_connectivity(sender=None, event=None): self.sideband.config["connect_transport"] = self.connectivity_screen.ids.connectivity_enable_transport.active - self.sideband.config["connect_share_instance"] = self.connectivity_screen.ids.connectivity_share_instance.active self.sideband.config["connect_local"] = self.connectivity_screen.ids.connectivity_use_local.active self.sideband.config["connect_local_groupid"] = self.connectivity_screen.ids.connectivity_local_groupid.text self.sideband.config["connect_local_ifac_netname"] = self.connectivity_screen.ids.connectivity_local_ifac_netname.text @@ -3907,10 +3561,6 @@ class SidebandApp(MDApp): self.sideband.config["connect_serial_ifac_netname"] = self.connectivity_screen.ids.connectivity_serial_ifac_netname.text self.sideband.config["connect_serial_ifac_passphrase"] = self.connectivity_screen.ids.connectivity_serial_ifac_passphrase.text - self.sideband.config["connect_weave"] = self.connectivity_screen.ids.connectivity_use_weave.active - self.sideband.config["connect_weave_ifac_netname"] = self.connectivity_screen.ids.connectivity_weave_ifac_netname.text - self.sideband.config["connect_weave_ifac_passphrase"] = self.connectivity_screen.ids.connectivity_weave_ifac_passphrase.text - self.sideband.config["connect_modem"] = self.connectivity_screen.ids.connectivity_use_modem.active self.sideband.config["connect_modem_ifac_netname"] = self.connectivity_screen.ids.connectivity_modem_ifac_netname.text self.sideband.config["connect_modem_ifac_passphrase"] = self.connectivity_screen.ids.connectivity_modem_ifac_passphrase.text @@ -3928,7 +3578,6 @@ class SidebandApp(MDApp): con_collapse_rnode(collapse=not self.connectivity_screen.ids.connectivity_use_rnode.active) con_collapse_modem(collapse=not self.connectivity_screen.ids.connectivity_use_modem.active) con_collapse_serial(collapse=not self.connectivity_screen.ids.connectivity_use_serial.active) - con_collapse_weave(collapse=not self.connectivity_screen.ids.connectivity_use_weave.active) con_collapse_transport(collapse=not self.sideband.config["connect_transport"]) self.sideband.save_configuration() @@ -3954,16 +3603,13 @@ class SidebandApp(MDApp): self.connectivity_screen.ids.connectivity_use_rnode.unbind(active=serial_connectivity_save) self.connectivity_screen.ids.connectivity_use_modem.unbind(active=serial_connectivity_save) self.connectivity_screen.ids.connectivity_use_serial.unbind(active=serial_connectivity_save) - self.connectivity_screen.ids.connectivity_use_weave.unbind(active=serial_connectivity_save) self.connectivity_screen.ids.connectivity_use_rnode.active = False self.connectivity_screen.ids.connectivity_use_modem.active = False self.connectivity_screen.ids.connectivity_use_serial.active = False - self.connectivity_screen.ids.connectivity_use_weave.active = False sender.active = True self.connectivity_screen.ids.connectivity_use_rnode.bind(active=serial_connectivity_save) self.connectivity_screen.ids.connectivity_use_modem.bind(active=serial_connectivity_save) self.connectivity_screen.ids.connectivity_use_serial.bind(active=serial_connectivity_save) - self.connectivity_screen.ids.connectivity_use_weave.bind(active=serial_connectivity_save) save_connectivity(sender, event) def focus_save(sender=None, event=None): @@ -4011,7 +3657,7 @@ class SidebandApp(MDApp): else: info = "By default, Sideband will try to discover and connect to any available Reticulum networks via active WiFi and/or Ethernet interfaces. If any Reticulum Transport Instances are found, Sideband will use these to connect to wider Reticulum networks. You can disable this behaviour if you don't want it.\n\n" info += "You can also connect to a network via a remote or local Reticulum instance using TCP or I2P. [b]Please Note![/b] Connecting via I2P requires that you already have I2P running on your device, and that the SAM API is enabled.\n\n" - info += "For changes to connectivity to take effect, you must either restart the RNS service, or completely shut down and restart Sideband.\n" + info += "For changes to connectivity to take effect, you must shut down and restart Sideband.\n" self.connectivity_screen.ids.connectivity_info.text = info self.connectivity_screen.ids.connectivity_use_local.active = self.sideband.config["connect_local"] @@ -4048,18 +3694,9 @@ class SidebandApp(MDApp): self.connectivity_screen.ids.connectivity_serial_ifac_netname.text = self.sideband.config["connect_serial_ifac_netname"] self.connectivity_screen.ids.connectivity_serial_ifac_passphrase.text = self.sideband.config["connect_serial_ifac_passphrase"] - self.connectivity_screen.ids.connectivity_use_weave.active = self.sideband.config["connect_weave"] - con_collapse_weave(collapse=not self.connectivity_screen.ids.connectivity_use_weave.active) - self.connectivity_screen.ids.connectivity_weave_ifac_netname.text = self.sideband.config["connect_weave_ifac_netname"] - self.connectivity_screen.ids.connectivity_weave_ifac_passphrase.text = self.sideband.config["connect_weave_ifac_passphrase"] - self.connectivity_screen.ids.connectivity_enable_transport.active = self.sideband.config["connect_transport"] con_collapse_transport(collapse=not self.sideband.config["connect_transport"]) self.connectivity_screen.ids.connectivity_enable_transport.bind(active=save_connectivity) - - self.connectivity_screen.ids.connectivity_share_instance.active = self.sideband.config["connect_share_instance"] - self.connectivity_screen.ids.connectivity_share_instance.bind(active=save_connectivity) - self.connectivity_screen.ids.connectivity_local_ifmode.text = self.sideband.config["connect_ifmode_local"].capitalize() self.connectivity_screen.ids.connectivity_tcp_ifmode.text = self.sideband.config["connect_ifmode_tcp"].capitalize() self.connectivity_screen.ids.connectivity_i2p_ifmode.text = self.sideband.config["connect_ifmode_i2p"].capitalize() @@ -4095,10 +3732,6 @@ class SidebandApp(MDApp): self.connectivity_screen.ids.connectivity_serial_ifac_netname.bind(focus=focus_save) self.connectivity_screen.ids.connectivity_serial_ifac_passphrase.bind(focus=focus_save) - self.connectivity_screen.ids.connectivity_use_weave.bind(active=serial_connectivity_save) - self.connectivity_screen.ids.connectivity_weave_ifac_netname.bind(focus=focus_save) - self.connectivity_screen.ids.connectivity_weave_ifac_passphrase.bind(focus=focus_save) - self.connectivity_screen.ids.connectivity_local_ifmode.bind(focus=ifmode_validate) self.connectivity_screen.ids.connectivity_tcp_ifmode.bind(focus=ifmode_validate) self.connectivity_screen.ids.connectivity_i2p_ifmode.bind(focus=ifmode_validate) @@ -4113,14 +3746,12 @@ class SidebandApp(MDApp): info = "Sideband is connected via a shared Reticulum instance running on this system.\n\n" info += "To get connectivity status, use the [b]rnstatus[/b] utility.\n\n" info += "To configure connectivity, edit the configuration file located at:\n\n" - if not RNS.vendor.platformutils.is_windows(): info += str(RNS.Reticulum.configpath) - else: info += str(RNS.Reticulum.configpath.replace("/", "\\")) + info += str(RNS.Reticulum.configpath) else: info = "Sideband is currently running a standalone or master Reticulum instance on this system.\n\n" info += "To get connectivity status, use the [b]rnstatus[/b] utility.\n\n" info += "To configure connectivity, edit the configuration file located at:\n\n" - if not RNS.vendor.platformutils.is_windows(): info += str(RNS.Reticulum.configpath) - else: info += str(RNS.Reticulum.configpath.replace("/", "\\")) + info += str(RNS.Reticulum.configpath) self.connectivity_screen.ids.connectivity_info.text = info @@ -4145,8 +3776,7 @@ class SidebandApp(MDApp): dialog.dismiss() yes_button.bind(on_release=dl_yes) - rpc_string = "shared_instance_type = tcp\n" - rpc_string += "rpc_key = "+RNS.hexrep(self.sideband.reticulum.rpc_key, delimit=False) + rpc_string = "rpc_key = "+RNS.hexrep(self.sideband.reticulum.rpc_key, delimit=False) Clipboard.copy(rpc_string) dialog.open() @@ -4239,7 +3869,7 @@ class SidebandApp(MDApp): ipstr += "[u][ref=link]"+ipurl+"[/ref][u]\n" if self.repository_url == None: self.repository_url = ipurl - self.rnode_flasher_url = ipurl+"mirrors/rnode-flasher/RNode_Flasher.html" + self.rnode_flasher_url = ipurl+"mirrors/rnode-flasher/index.html" ms = "" if len(ips) == 1 else "es" info += "The repository server is running at the following address" + ms +":\n\n"+ipstr @@ -4381,33 +4011,911 @@ class SidebandApp(MDApp): ### Hardware screen ###################################### def hardware_action(self, sender=None, direction="left"): - if self.hardware_ready: self.hardware_open(direction=direction) + if self.hardware_ready: + self.hardware_open(direction=direction) else: self.loader_action(direction=direction) def final(dt): - self.init_hardware_view() - def o(dt): self.hardware_open(no_transition=True) + self.hardware_init() + def o(dt): + self.hardware_open(no_transition=True) Clock.schedule_once(o, ll_ot) Clock.schedule_once(final, ll_ft) def hardware_open(self, sender=None, direction="left", no_transition=False): - if no_transition: self.root.ids.screen_manager.transition = self.no_transition + if no_transition: + self.root.ids.screen_manager.transition = self.no_transition else: self.root.ids.screen_manager.transition = self.slide_transition self.root.ids.screen_manager.transition.direction = direction + self.hardware_init() self.root.ids.screen_manager.transition.direction = direction self.root.ids.screen_manager.current = "hardware_screen" self.root.ids.nav_drawer.set_state("closed") self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) - if no_transition: self.root.ids.screen_manager.transition = self.slide_transition + if no_transition: + self.root.ids.screen_manager.transition = self.slide_transition - def init_hardware_view(self, sender=None): - if not self.hardware_view: self.hardware_view = Hardware(self) + def close_sub_hardware_action(self, sender=None): + self.hardware_action(direction="right") + + def hardware_init(self, sender=None): + if not self.hardware_ready: + if not self.root.ids.screen_manager.has_screen("hardware_screen"): + self.hardware_screen = Builder.load_string(layout_hardware_screen) + self.hardware_screen.app = self + self.root.ids.screen_manager.add_widget(self.hardware_screen) - def close_sub_hardware_action(self, sender=None): self.hardware_action(direction="right") - def close_hardware_action(self, sender=None): self.open_conversations(direction="right") + self.hardware_screen.ids.hardware_scrollview.effect_cls = ScrollEffect + + def con_hide_settings(): + self.widget_hide(self.hardware_screen.ids.hardware_rnode_button) + self.widget_hide(self.hardware_screen.ids.hardware_modem_button) + self.widget_hide(self.hardware_screen.ids.hardware_serial_button) + + if RNS.vendor.platformutils.get_platform() == "android": + if not self.sideband.getpersistent("service.is_controlling_connectivity"): + info = "Sideband is connected via a shared Reticulum instance running on this system.\n\n" + info += "To configure hardware parameters, edit the relevant configuration file for the instance." + self.hardware_screen.ids.hardware_info.text = info + con_hide_settings() + + else: + info = "When using external hardware for communicating, you may configure various parameters, such as channel settings, modulation schemes, interface speeds and access parameters. You can set up these parameters per device type, and Sideband will apply the configuration when opening a device of that type.\n\n" + info += "Hardware configurations can also be exported or imported as [i]config motes[/i], which are self-contained plaintext strings that are easy to share with others. When importing a config mote, Sideband will automatically set all relevant parameters as specified within it.\n\n" + info += "For changes to hardware parameters to take effect, you must shut down and restart Sideband.\n" + self.hardware_screen.ids.hardware_info.text = info + + else: + info = "" + + if self.sideband.reticulum.is_connected_to_shared_instance: + info = "Sideband is connected via a shared Reticulum instance running on this system.\n\n" + info += "To configure hardware parameters, edit the configuration file located at:\n\n" + info += str(RNS.Reticulum.configpath) + else: + info = "Sideband is currently running a standalone or master Reticulum instance on this system.\n\n" + info += "To configure hardware parameters, edit the configuration file located at:\n\n" + info += str(RNS.Reticulum.configpath) + + self.hardware_screen.ids.hardware_info.text = info + + con_hide_settings() + + self.hardware_ready = True + + def close_hardware_action(self, sender=None): + self.open_conversations(direction="right") + + ## RNode hardware screen + def hardware_rnode_action(self, sender=None, direction="left"): + if self.hardware_rnode_ready: + self.hardware_rnode_open(direction=direction) + else: + self.loader_action(direction=direction) + def final(dt): + self.hardware_rnode_init() + def o(dt): + self.hardware_rnode_open(no_transition=True) + Clock.schedule_once(o, ll_ot) + Clock.schedule_once(final, ll_ft) + + def hardware_rnode_open(self, sender=None, direction="left", no_transition=False): + if no_transition: + self.root.ids.screen_manager.transition = self.no_transition + else: + self.root.ids.screen_manager.transition = self.slide_transition + self.root.ids.screen_manager.transition.direction = direction + + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "hardware_rnode_screen" + self.root.ids.nav_drawer.set_state("closed") + self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) + + if no_transition: + self.root.ids.screen_manager.transition = self.slide_transition + + def hardware_rnode_save(self): + try: + self.sideband.config["hw_rnode_frequency"] = int(float(self.hardware_rnode_screen.ids.hardware_rnode_frequency.text)*1000000) + except: + pass + + try: + self.sideband.config["hw_rnode_bandwidth"] = int(float(self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text)*1000) + except: + pass + + try: + self.sideband.config["hw_rnode_tx_power"] = int(self.hardware_rnode_screen.ids.hardware_rnode_txpower.text) + except: + pass + + try: + self.sideband.config["hw_rnode_spreading_factor"] = int(self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text) + except: + pass + + try: + self.sideband.config["hw_rnode_coding_rate"] = int(self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text) + except: + pass + + try: + self.sideband.config["hw_rnode_atl_short"] = float(self.hardware_rnode_screen.ids.hardware_rnode_atl_short.text) + except: + self.sideband.config["hw_rnode_atl_short"] = None + + try: + self.sideband.config["hw_rnode_atl_long"] = float(self.hardware_rnode_screen.ids.hardware_rnode_atl_long.text) + except: + self.sideband.config["hw_rnode_atl_long"] = None + + if self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text == "": + self.sideband.config["hw_rnode_beaconinterval"] = None + else: + try: + self.sideband.config["hw_rnode_beaconinterval"] = int(self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text) + except: + pass + + if self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text == "": + self.sideband.config["hw_rnode_beacondata"] = None + else: + self.sideband.config["hw_rnode_beacondata"] = self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text + + if self.hardware_rnode_screen.ids.hardware_rnode_bt_device.text == "": + self.sideband.config["hw_rnode_bt_device"] = None + else: + self.sideband.config["hw_rnode_bt_device"] = self.hardware_rnode_screen.ids.hardware_rnode_bt_device.text + + self.sideband.save_configuration() + + def hardware_rnode_bt_on_action(self, sender=None): + self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = True + self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = True + self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = True + def re_enable(): + time.sleep(2) + while self.sideband.getstate("executing.bt_on"): + time.sleep(1) + self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = False + self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = False + self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = False + threading.Thread(target=re_enable, daemon=True).start() + self.sideband.setstate("wants.bt_on", True) + + def hardware_rnode_bt_off_action(self, sender=None): + self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = True + self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = True + self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = True + def re_enable(): + time.sleep(2) + while self.sideband.getstate("executing.bt_off"): + time.sleep(1) + self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = False + self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = False + self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = False + threading.Thread(target=re_enable, daemon=True).start() + self.sideband.setstate("wants.bt_off", True) + + def hardware_rnode_bt_pair_action(self, sender=None): + self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = True + self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = True + self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = True + def re_enable(): + time.sleep(2) + while self.sideband.getstate("executing.bt_pair"): + time.sleep(1) + self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = False + self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = False + self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = False + threading.Thread(target=re_enable, daemon=True).start() + self.sideband.setstate("wants.bt_pair", True) + + def hardware_rnode_bt_toggle_action(self, sender=None, event=None): + if sender.active: + self.sideband.config["hw_rnode_bluetooth"] = True + self.request_bluetooth_permissions() + else: + self.sideband.config["hw_rnode_bluetooth"] = False + + self.sideband.save_configuration() + + def hardware_rnode_ble_toggle_action(self, sender=None, event=None): + if sender.active: + self.sideband.config["hw_rnode_ble"] = True + self.request_bluetooth_permissions() + else: + self.sideband.config["hw_rnode_ble"] = False + + self.sideband.save_configuration() + + def hardware_rnode_framebuffer_toggle_action(self, sender=None, event=None): + if sender.active: + self.sideband.config["hw_rnode_enable_framebuffer"] = True + else: + self.sideband.config["hw_rnode_enable_framebuffer"] = False + + self.sideband.save_configuration() + + def hardware_rnode_init(self, sender=None): + if not self.hardware_rnode_ready: + if not self.root.ids.screen_manager.has_screen("hardware_rnode_screen"): + self.hardware_rnode_screen = Builder.load_string(layout_hardware_rnode_screen) + self.hardware_rnode_screen.app = self + self.root.ids.screen_manager.add_widget(self.hardware_rnode_screen) + + self.hardware_rnode_screen.ids.hardware_rnode_scrollview.effect_cls = ScrollEffect + def save_connectivity(sender=None, event=None): + if self.hardware_rnode_validate(): + self.hardware_rnode_save() + + def focus_save(sender=None, event=None): + if sender != None: + if not sender.focus: + save_connectivity(sender=sender) + + if self.sideband.config["hw_rnode_frequency"] != None: + t_freq = str(self.sideband.config["hw_rnode_frequency"]/1000000.0) + else: + t_freq = "" + if self.sideband.config["hw_rnode_bandwidth"] != None: + t_bw = str(self.sideband.config["hw_rnode_bandwidth"]/1000.0) + else: + t_bw = str(62.5) + if self.sideband.config["hw_rnode_tx_power"] != None: + t_p = str(self.sideband.config["hw_rnode_tx_power"]) + else: + t_p = str(0) + if self.sideband.config["hw_rnode_spreading_factor"] != None: + t_sf = str(self.sideband.config["hw_rnode_spreading_factor"]) + else: + t_sf = str(8) + if self.sideband.config["hw_rnode_coding_rate"] != None: + t_cr = str(self.sideband.config["hw_rnode_coding_rate"]) + else: + t_cr = str(6) + if self.sideband.config["hw_rnode_beaconinterval"] != None: + t_bi = str(self.sideband.config["hw_rnode_beaconinterval"]) + else: + t_bi = "" + if self.sideband.config["hw_rnode_beacondata"] != None: + t_bd = str(self.sideband.config["hw_rnode_beacondata"]) + else: + t_bd = "" + if self.sideband.config["hw_rnode_bt_device"] != None: + t_btd = str(self.sideband.config["hw_rnode_bt_device"]) + else: + t_btd = "" + if self.sideband.config["hw_rnode_atl_short"] != None: + t_ats = str(self.sideband.config["hw_rnode_atl_short"]) + else: + t_ats = "" + if self.sideband.config["hw_rnode_atl_long"] != None: + t_atl = str(self.sideband.config["hw_rnode_atl_long"]) + else: + t_atl = "" + + self.hardware_rnode_screen.ids.hardware_rnode_bluetooth.active = self.sideband.config["hw_rnode_bluetooth"] + self.hardware_rnode_screen.ids.hardware_rnode_ble.active = self.sideband.config["hw_rnode_ble"] + self.hardware_rnode_screen.ids.hardware_rnode_framebuffer.active = self.sideband.config["hw_rnode_enable_framebuffer"] + self.hardware_rnode_screen.ids.hardware_rnode_frequency.text = t_freq + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text = t_bw + self.hardware_rnode_screen.ids.hardware_rnode_txpower.text = t_p + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text = t_sf + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text = t_cr + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = t_bi + self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text = t_bd + self.hardware_rnode_screen.ids.hardware_rnode_bt_device.text = t_btd + self.hardware_rnode_screen.ids.hardware_rnode_atl_short.text = t_ats + self.hardware_rnode_screen.ids.hardware_rnode_atl_long.text = t_atl + self.hardware_rnode_screen.ids.hardware_rnode_frequency.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_txpower.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_beacondata.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_bt_device.bind(focus=focus_save) + self.hardware_rnode_screen.ids.hardware_rnode_frequency.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_txpower.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_beacondata.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_atl_short.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_atl_long.bind(on_text_validate=save_connectivity) + self.hardware_rnode_screen.ids.hardware_rnode_bluetooth.bind(active=self.hardware_rnode_bt_toggle_action) + self.hardware_rnode_screen.ids.hardware_rnode_ble.bind(active=self.hardware_rnode_ble_toggle_action) + self.hardware_rnode_screen.ids.hardware_rnode_framebuffer.bind(active=self.hardware_rnode_framebuffer_toggle_action) + + self.hardware_rnode_ready = True + + def hardware_rnode_validate(self, sender=None): + valid = True + try: + val = float(self.hardware_rnode_screen.ids.hardware_rnode_frequency.text) + if not val > 0: + raise ValueError("Invalid frequency") + self.hardware_rnode_screen.ids.hardware_rnode_frequency.error = False + self.hardware_rnode_screen.ids.hardware_rnode_frequency.text = str(val) + except: + self.hardware_rnode_screen.ids.hardware_rnode_frequency.error = True + valid = False + + try: + valid_vals = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500, 203.125, 406.25, 812.5, 1625] + val = float(self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text) + if not val in valid_vals: + raise ValueError("Invalid bandwidth") + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.error = False + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text = str(val) + except: + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.error = True + valid = False + + try: + val = int(self.hardware_rnode_screen.ids.hardware_rnode_txpower.text) + if not val >= 0: + raise ValueError("Invalid TX power") + self.hardware_rnode_screen.ids.hardware_rnode_txpower.error = False + self.hardware_rnode_screen.ids.hardware_rnode_txpower.text = str(val) + except: + self.hardware_rnode_screen.ids.hardware_rnode_txpower.error = True + valid = False + + try: + val = int(self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text) + if val < 7 or val > 12: + raise ValueError("Invalid sf") + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.error = False + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text = str(val) + except: + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.error = True + valid = False + + try: + val = int(self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text) + if val < 5 or val > 8: + raise ValueError("Invalid cr") + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.error = False + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text = str(val) + except: + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.error = True + valid = False + + try: + if self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text != "": + val = int(self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text) + if val < 10: + raise ValueError("Invalid beacon interval") + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = str(val) + + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.error = False + except: + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = "" + valid = False + + return valid + + def hardware_rnode_import(self, sender=None): + mote = None + try: + mote = Clipboard.paste() + except Exception as e: + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog( + title="Import Failed", + text="Could not read data from your clipboard, please check your system permissions.", + buttons=[ yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + dialog.open() + + try: + config = msgpack.unpackb(base64.b32decode(mote)) + self.hardware_rnode_screen.ids.hardware_rnode_frequency.text = str(config["f"]/1000000.0) + self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text = str(config["b"]/1000.0) + self.hardware_rnode_screen.ids.hardware_rnode_txpower.text = str(config["t"]) + self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text = str(config["s"]) + self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text = str(config["c"]) + + if "n" in config and config["n"] != None: + ifn = str(config["n"]) + else: + ifn = "" + if "p" in config and config["p"] != None: + ifp = str(config["p"]) + else: + ifp = "" + + self.connectivity_screen.ids.connectivity_rnode_ifac_netname.text = ifn + self.sideband.config["connect_rnode_ifac_netname"] = ifn + self.connectivity_screen.ids.connectivity_rnode_ifac_passphrase.text = ifp + self.sideband.config["connect_rnode_ifac_passphrase"] = ifp + + if config["i"] != None: + ti = str(config["i"]) + else: + ti = "" + self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = ti + if config["d"] != None: + td = str(config["d"]) + else: + td = "" + self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text = td + + if self.hardware_rnode_validate(): + self.hardware_rnode_save() + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog( + title="Configuration Imported", + text="The config mote was imported and saved as your active configuration.", + buttons=[ yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + dialog.open() + else: + raise ValueError("Invalid mote") + + except Exception as e: + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog( + title="Import Failed", + text="The read data did not contain a valid config mote. If any data was decoded, you may try to correct it by editing the relevant fields. The reported error was:\n\n"+str(e), + buttons=[ yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + dialog.open() + + def hardware_rnode_export(self, sender=None): + mote = None + try: + mote = base64.b32encode(msgpack.packb({ + "f": self.sideband.config["hw_rnode_frequency"], + "b": self.sideband.config["hw_rnode_bandwidth"], + "t": self.sideband.config["hw_rnode_tx_power"], + "s": self.sideband.config["hw_rnode_spreading_factor"], + "c": self.sideband.config["hw_rnode_coding_rate"], + "i": self.sideband.config["hw_rnode_beaconinterval"], + "d": self.sideband.config["hw_rnode_beacondata"], + "n": self.sideband.config["connect_rnode_ifac_netname"], + "p": self.sideband.config["connect_rnode_ifac_passphrase"], + })) + except Exception as e: + pass + + if mote != None: + Clipboard.copy(mote) + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog( + title="Configuration Exported", + text="The config mote was created and copied to your clipboard.", + buttons=[ yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + dialog.open() + else: + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog( + title="Export Failed", + text="The config mote could not be created, please check your settings.", + buttons=[ yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + dialog.open() + + ## Modem hardware screen + + def hardware_modem_action(self, sender=None, direction="left"): + if self.hardware_modem_ready: + self.hardware_modem_open(direction=direction) + else: + self.loader_action(direction=direction) + def final(dt): + self.hardware_modem_init() + def o(dt): + self.hardware_modem_open(no_transition=True) + Clock.schedule_once(o, ll_ot) + Clock.schedule_once(final, ll_ft) + + def hardware_modem_open(self, sender=None, direction="left", no_transition=False): + if no_transition: + self.root.ids.screen_manager.transition = self.no_transition + else: + self.root.ids.screen_manager.transition = self.slide_transition + self.root.ids.screen_manager.transition.direction = direction + + self.hardware_modem_init() + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "hardware_modem_screen" + self.root.ids.nav_drawer.set_state("closed") + self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) + + if no_transition: + self.root.ids.screen_manager.transition = self.slide_transition + + def hardware_modem_init(self, sender=None): + if not self.hardware_modem_ready: + if not self.root.ids.screen_manager.has_screen("hardware_modem_screen"): + self.hardware_modem_screen = Builder.load_string(layout_hardware_modem_screen) + self.hardware_modem_screen.app = self + self.root.ids.screen_manager.add_widget(self.hardware_modem_screen) + + self.hardware_modem_screen.ids.hardware_modem_scrollview.effect_cls = ScrollEffect + def save_connectivity(sender=None, event=None): + if self.hardware_modem_validate(): + self.hardware_modem_save() + + def focus_save(sender=None, event=None): + if sender != None: + if not sender.focus: + save_connectivity(sender=sender) + + if self.sideband.config["hw_modem_baudrate"] != None: + t_b = str(self.sideband.config["hw_modem_baudrate"]) + else: + t_b = "" + + if self.sideband.config["hw_modem_databits"] != None: + t_db = str(self.sideband.config["hw_modem_databits"]) + else: + t_db = "" + + if self.sideband.config["hw_modem_parity"] != None: + t_p = str(self.sideband.config["hw_modem_parity"]) + else: + t_p = "" + + if self.sideband.config["hw_modem_stopbits"] != None: + t_sb = str(self.sideband.config["hw_modem_stopbits"]) + else: + t_sb = "" + + if self.sideband.config["hw_modem_preamble"] != None: + t_pa = str(self.sideband.config["hw_modem_preamble"]) + else: + t_pa = "" + + if self.sideband.config["hw_modem_tail"] != None: + t_t = str(self.sideband.config["hw_modem_tail"]) + else: + t_t = "" + + if self.sideband.config["hw_modem_persistence"] != None: + t_ps = str(self.sideband.config["hw_modem_persistence"]) + else: + t_ps = "" + + if self.sideband.config["hw_modem_slottime"] != None: + t_st = str(self.sideband.config["hw_modem_slottime"]) + else: + t_st = "" + + if self.sideband.config["hw_modem_beaconinterval"] != None: + t_bi = str(self.sideband.config["hw_modem_beaconinterval"]) + else: + t_bi = "" + if self.sideband.config["hw_modem_beacondata"] != None: + t_bd = str(self.sideband.config["hw_modem_beacondata"]) + else: + t_bd = "" + + self.hardware_modem_screen.ids.hardware_modem_baudrate.text = t_b + self.hardware_modem_screen.ids.hardware_modem_databits.text = t_db + self.hardware_modem_screen.ids.hardware_modem_parity.text = t_p + self.hardware_modem_screen.ids.hardware_modem_stopbits.text = t_sb + self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text = t_bi + self.hardware_modem_screen.ids.hardware_modem_beacondata.text = t_bd + self.hardware_modem_screen.ids.hardware_modem_preamble.text = t_pa + self.hardware_modem_screen.ids.hardware_modem_tail.text = t_t + self.hardware_modem_screen.ids.hardware_modem_persistence.text = t_ps + self.hardware_modem_screen.ids.hardware_modem_slottime.text = t_st + self.hardware_modem_screen.ids.hardware_modem_baudrate.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_databits.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_parity.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_stopbits.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_beaconinterval.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_beacondata.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_preamble.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_tail.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_persistence.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_slottime.bind(focus=focus_save) + self.hardware_modem_screen.ids.hardware_modem_baudrate.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_databits.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_parity.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_stopbits.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_beaconinterval.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_beacondata.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_preamble.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_tail.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_persistence.bind(on_text_validate=save_connectivity) + self.hardware_modem_screen.ids.hardware_modem_slottime.bind(on_text_validate=save_connectivity) + + self.hardware_modem_ready = True + + def hardware_modem_save(self): + self.sideband.config["hw_modem_baudrate"] = int(self.hardware_modem_screen.ids.hardware_modem_baudrate.text) + self.sideband.config["hw_modem_databits"] = int(self.hardware_modem_screen.ids.hardware_modem_databits.text) + self.sideband.config["hw_modem_parity"] = self.hardware_modem_screen.ids.hardware_modem_parity.text + self.sideband.config["hw_modem_stopbits"] = int(self.hardware_modem_screen.ids.hardware_modem_stopbits.text) + self.sideband.config["hw_modem_preamble"] = int(self.hardware_modem_screen.ids.hardware_modem_preamble.text) + self.sideband.config["hw_modem_tail"] = int(self.hardware_modem_screen.ids.hardware_modem_tail.text) + self.sideband.config["hw_modem_persistence"] = int(self.hardware_modem_screen.ids.hardware_modem_persistence.text) + self.sideband.config["hw_modem_slottime"] = int(self.hardware_modem_screen.ids.hardware_modem_slottime.text) + + if self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text == "": + self.sideband.config["hw_modem_beaconinterval"] = None + else: + self.sideband.config["hw_modem_beaconinterval"] = int(self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text) + + if self.hardware_modem_screen.ids.hardware_modem_beacondata.text == "": + self.sideband.config["hw_modem_beacondata"] = None + else: + self.sideband.config["hw_modem_beacondata"] = self.hardware_modem_screen.ids.hardware_modem_beacondata.text + + self.sideband.save_configuration() + + def hardware_modem_validate(self, sender=None): + valid = True + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_baudrate.text) + if not val > 0: + raise ValueError("Invalid baudrate") + self.hardware_modem_screen.ids.hardware_modem_baudrate.error = False + self.hardware_modem_screen.ids.hardware_modem_baudrate.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_baudrate.error = True + valid = False + + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_databits.text) + if not val > 0: + raise ValueError("Invalid databits") + self.hardware_modem_screen.ids.hardware_modem_databits.error = False + self.hardware_modem_screen.ids.hardware_modem_databits.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_databits.error = True + valid = False + + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_stopbits.text) + if not val > 0: + raise ValueError("Invalid stopbits") + self.hardware_modem_screen.ids.hardware_modem_stopbits.error = False + self.hardware_modem_screen.ids.hardware_modem_stopbits.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_stopbits.error = True + valid = False + + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_preamble.text) + if not (val >= 0 and val <= 1000): + raise ValueError("Invalid preamble") + self.hardware_modem_screen.ids.hardware_modem_preamble.error = False + self.hardware_modem_screen.ids.hardware_modem_preamble.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_preamble.error = True + valid = False + + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_tail.text) + if not (val > 0 and val <= 500): + raise ValueError("Invalid tail") + self.hardware_modem_screen.ids.hardware_modem_tail.error = False + self.hardware_modem_screen.ids.hardware_modem_tail.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_tail.error = True + valid = False + + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_slottime.text) + if not (val > 0 and val <= 500): + raise ValueError("Invalid slottime") + self.hardware_modem_screen.ids.hardware_modem_slottime.error = False + self.hardware_modem_screen.ids.hardware_modem_slottime.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_slottime.error = True + valid = False + + try: + val = int(self.hardware_modem_screen.ids.hardware_modem_persistence.text) + if not (val > 0 and val <= 255): + raise ValueError("Invalid persistence") + self.hardware_modem_screen.ids.hardware_modem_persistence.error = False + self.hardware_modem_screen.ids.hardware_modem_persistence.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_persistence.error = True + valid = False + + try: + val = self.hardware_modem_screen.ids.hardware_modem_parity.text + nval = val.lower() + if nval in ["e", "ev", "eve", "even"]: + val = "even" + if nval in ["o", "od", "odd"]: + val = "odd" + if nval in ["n", "no", "non", "none", "not", "null", "off"]: + val = "none" + if not val in ["even", "odd", "none"]: + raise ValueError("Invalid parity") + self.hardware_modem_screen.ids.hardware_modem_parity.error = False + self.hardware_modem_screen.ids.hardware_modem_parity.text = str(val) + except: + self.hardware_modem_screen.ids.hardware_modem_parity.error = True + valid = False + + try: + if self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text != "": + val = int(self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text) + if val < 10: + raise ValueError("Invalid bi") + self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text = str(val) + + self.hardware_modem_screen.ids.hardware_modem_beaconinterval.error = False + except: + self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text = "" + valid = False + + return valid + + ## Serial hardware screen + def hardware_serial_action(self, sender=None, direction="left"): + if self.hardware_serial_ready: + self.hardware_serial_open(direction=direction) + else: + self.loader_action(direction=direction) + def final(dt): + self.hardware_serial_init() + def o(dt): + self.hardware_serial_open(no_transition=True) + Clock.schedule_once(o, ll_ot) + Clock.schedule_once(final, ll_ft) + + def hardware_serial_open(self, sender=None, direction="left", no_transition=False): + if no_transition: + self.root.ids.screen_manager.transition = self.no_transition + else: + self.root.ids.screen_manager.transition = self.slide_transition + self.root.ids.screen_manager.transition.direction = direction + + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "hardware_serial_screen" + self.root.ids.nav_drawer.set_state("closed") + self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) + + if no_transition: + self.root.ids.screen_manager.transition = self.slide_transition + + def hardware_serial_init(self, sender=None): + if not self.hardware_serial_ready: + if not self.root.ids.screen_manager.has_screen("hardware_serial_screen"): + self.hardware_serial_screen = Builder.load_string(layout_hardware_serial_screen) + self.hardware_serial_screen.app = self + self.root.ids.screen_manager.add_widget(self.hardware_serial_screen) + + self.hardware_serial_screen.ids.hardware_serial_scrollview.effect_cls = ScrollEffect + def save_connectivity(sender=None, event=None): + if self.hardware_serial_validate(): + self.hardware_serial_save() + + def focus_save(sender=None, event=None): + if sender != None: + if not sender.focus: + save_connectivity(sender=sender) + + if self.sideband.config["hw_serial_baudrate"] != None: + t_b = str(self.sideband.config["hw_serial_baudrate"]) + else: + t_b = "" + + if self.sideband.config["hw_serial_databits"] != None: + t_db = str(self.sideband.config["hw_serial_databits"]) + else: + t_db = "" + + if self.sideband.config["hw_serial_parity"] != None: + t_p = str(self.sideband.config["hw_serial_parity"]) + else: + t_p = "" + + if self.sideband.config["hw_serial_stopbits"] != None: + t_sb = str(self.sideband.config["hw_serial_stopbits"]) + else: + t_sb = "" + + self.hardware_serial_screen.ids.hardware_serial_baudrate.text = t_b + self.hardware_serial_screen.ids.hardware_serial_databits.text = t_db + self.hardware_serial_screen.ids.hardware_serial_parity.text = t_p + self.hardware_serial_screen.ids.hardware_serial_stopbits.text = t_sb + self.hardware_serial_screen.ids.hardware_serial_baudrate.bind(focus=focus_save) + self.hardware_serial_screen.ids.hardware_serial_databits.bind(focus=focus_save) + self.hardware_serial_screen.ids.hardware_serial_parity.bind(focus=focus_save) + self.hardware_serial_screen.ids.hardware_serial_stopbits.bind(focus=focus_save) + self.hardware_serial_screen.ids.hardware_serial_baudrate.bind(on_text_validate=save_connectivity) + self.hardware_serial_screen.ids.hardware_serial_databits.bind(on_text_validate=save_connectivity) + self.hardware_serial_screen.ids.hardware_serial_parity.bind(on_text_validate=save_connectivity) + self.hardware_serial_screen.ids.hardware_serial_stopbits.bind(on_text_validate=save_connectivity) + + self.hardware_serial_ready = True + + def hardware_serial_validate(self, sender=None): + valid = True + try: + val = int(self.hardware_serial_screen.ids.hardware_serial_baudrate.text) + if not val > 0: + raise ValueError("Invalid baudrate") + self.hardware_serial_screen.ids.hardware_serial_baudrate.error = False + self.hardware_serial_screen.ids.hardware_serial_baudrate.text = str(val) + except: + self.hardware_serial_screen.ids.hardware_serial_baudrate.error = True + valid = False + + try: + val = int(self.hardware_serial_screen.ids.hardware_serial_databits.text) + if not val > 0: + raise ValueError("Invalid databits") + self.hardware_serial_screen.ids.hardware_serial_databits.error = False + self.hardware_serial_screen.ids.hardware_serial_databits.text = str(val) + except: + self.hardware_serial_screen.ids.hardware_serial_databits.error = True + valid = False + + try: + val = int(self.hardware_serial_screen.ids.hardware_serial_stopbits.text) + if not val > 0: + raise ValueError("Invalid stopbits") + self.hardware_serial_screen.ids.hardware_serial_stopbits.error = False + self.hardware_serial_screen.ids.hardware_serial_stopbits.text = str(val) + except: + self.hardware_serial_screen.ids.hardware_serial_stopbits.error = True + valid = False + + try: + val = self.hardware_serial_screen.ids.hardware_serial_parity.text + nval = val.lower() + if nval in ["e", "ev", "eve", "even"]: + val = "even" + if nval in ["o", "od", "odd"]: + val = "odd" + if nval in ["n", "no", "non", "none", "not", "null", "off"]: + val = "none" + if not val in ["even", "odd", "none"]: + raise ValueError("Invalid parity") + self.hardware_serial_screen.ids.hardware_serial_parity.error = False + self.hardware_serial_screen.ids.hardware_serial_parity.text = str(val) + except: + self.hardware_serial_screen.ids.hardware_serial_parity.error = True + valid = False + + return valid + + def hardware_serial_save(self): + self.sideband.config["hw_serial_baudrate"] = int(self.hardware_serial_screen.ids.hardware_serial_baudrate.text) + self.sideband.config["hw_serial_databits"] = int(self.hardware_serial_screen.ids.hardware_serial_databits.text) + self.sideband.config["hw_serial_parity"] = self.hardware_serial_screen.ids.hardware_serial_parity.text + self.sideband.config["hw_serial_stopbits"] = int(self.hardware_serial_screen.ids.hardware_serial_stopbits.text) + + self.sideband.save_configuration() ### Announce Stream screen ###################################### @@ -4415,59 +4923,90 @@ class SidebandApp(MDApp): if not self.announces_view: self.announces_view = Announces(self) self.sideband.setstate("app.flags.new_announces", True) - for child in self.announces_view.ids.announces_scrollview.children: self.announces_view.ids.announces_scrollview.remove_widget(child) + + for child in self.announces_view.ids.announces_scrollview.children: + self.announces_view.ids.announces_scrollview.remove_widget(child) + self.announces_view.ids.announces_scrollview.effect_cls = ScrollEffect self.announces_view.ids.announces_scrollview.add_widget(self.announces_view.get_widget()) + self.announces_view.update() def announces_action(self, sender=None, direction="left"): - if self.announces_view: self.announces_open(direction=direction) + if self.announces_view: + self.announces_open(direction=direction) else: self.loader_action(direction=direction) def final(dt): self.init_announces_view() - def o(dt): self.announces_open(no_transition=True) + def o(dt): + self.announces_open(no_transition=True) Clock.schedule_once(o, ll_ot) Clock.schedule_once(final, ll_ft) def announces_open(self, sender=None, direction="left", no_transition=False): - if no_transition: self.root.ids.screen_manager.transition = self.no_transition + if no_transition: + self.root.ids.screen_manager.transition = self.no_transition else: self.root.ids.screen_manager.transition = self.slide_transition self.root.ids.screen_manager.transition.direction = direction self.root.ids.nav_drawer.set_state("closed") - if self.sideband.getstate("app.flags.new_announces"): self.announces_view.update() + + if self.sideband.getstate("app.flags.new_announces"): + self.announces_view.update() + self.root.ids.screen_manager.current = "announces_screen" self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) - if no_transition: self.root.ids.screen_manager.transition = self.slide_transition - def close_announces_action(self, sender=None): self.open_conversations(direction="right") + if no_transition: + self.root.ids.screen_manager.transition = self.slide_transition - def announce_filter_action(self, sender=None): pass + def close_announces_action(self, sender=None): + self.open_conversations(direction="right") + + def announce_filter_action(self, sender=None): + pass def screen_transition_complete(self, sender): - if self.root.ids.screen_manager.current == "announces_screen": pass - if self.root.ids.screen_manager.current == "conversations_screen": pass + if self.root.ids.screen_manager.current == "announces_screen": + pass + if self.root.ids.screen_manager.current == "conversations_screen": + pass ### Keys screen ###################################### def keys_action(self, sender=None, direction="left"): - if self.root.ids.screen_manager.has_screen("keys_screen"): self.keys_open(direction=direction) + if self.root.ids.screen_manager.has_screen("keys_screen"): + self.keys_open(direction=direction) else: self.loader_action(direction=direction) def final(dt): - self.init_keys_view() - def o(dt): self.keys_open(no_transition=True) + self.keys_init() + def o(dt): + self.keys_open(no_transition=True) Clock.schedule_once(o, ll_ot) Clock.schedule_once(final, ll_ft) - def init_keys_view(self, sender=None): - if not self.keys_view: self.keys_view = Keys(self) + def keys_init(self): + if not self.root.ids.screen_manager.has_screen("keys_screen"): + self.keys_screen = Builder.load_string(layout_keys_screen) + self.keys_screen.app = self + self.root.ids.screen_manager.add_widget(self.keys_screen) + self.bind_clipboard_actions(self.keys_screen.ids) + + self.keys_screen.ids.keys_scrollview.effect_cls = ScrollEffect + info = "Your primary encryption keys are stored in a Reticulum Identity within the Sideband app. If you want to backup this Identity for later use on this or another device, you can export it as a plain text blob, with the key data encoded in Base32 format. This will allow you to restore your address in Sideband or other LXMF clients at a later point.\n\n[b]Warning![/b] Anyone that gets access to the key data will be able to control your LXMF address, impersonate you, and read your messages. It is [b]extremely important[/b] that you keep the Identity data secure if you export it.\n\nBefore displaying or exporting your Identity data, make sure that no machine or person in your vicinity is able to see, copy or record your device screen or similar." + + if not RNS.vendor.platformutils.get_platform() == "android": + self.widget_hide(self.keys_screen.ids.keys_share) + + self.keys_screen.ids.keys_info.text = info def keys_open(self, sender=None, direction="left", no_transition=False): - if no_transition: self.root.ids.screen_manager.transition = self.no_transition + if no_transition: + self.root.ids.screen_manager.transition = self.no_transition else: self.root.ids.screen_manager.transition = self.slide_transition self.root.ids.screen_manager.transition.direction = direction @@ -4476,10 +5015,91 @@ class SidebandApp(MDApp): self.root.ids.screen_manager.current = "keys_screen" self.root.ids.nav_drawer.set_state("closed") self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current) - if no_transition: self.root.ids.screen_manager.transition = self.slide_transition - def close_keys_action(self, sender=None): self.open_conversations(direction="right") + if no_transition: + self.root.ids.screen_manager.transition = self.slide_transition + def close_keys_action(self, sender=None): + self.open_conversations(direction="right") + + def identity_display_action(self, sender=None): + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + + dialog = MDDialog( + text="Your Identity key, in base32 format is as follows:\n\n[b]"+str(base64.b32encode(self.sideband.identity.get_private_key()).decode("utf-8"))+"[/b]", + buttons=[ yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + dialog.open() + + def identity_copy_action(self, sender=None): + c_yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.color_reject, text_color=self.color_reject) + c_no_button = MDRectangleFlatButton(text="No, go back",font_size=dp(18)) + c_dialog = MDDialog(text="[b]Caution![/b]\n\nYour Identity key will be copied to the system clipboard. Take extreme care that no untrusted app steals your key by reading the clipboard data. Clear the system clipboard immediately after pasting your key where you need it.\n\nAre you sure that you wish to proceed?", buttons=[ c_no_button, c_yes_button ]) + def c_dl_no(s): + c_dialog.dismiss() + def c_dl_yes(s): + c_dialog.dismiss() + yes_button = MDRectangleFlatButton(text="OK") + dialog = MDDialog(text="Your Identity key was copied to the system clipboard", buttons=[ yes_button ]) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + + Clipboard.copy(str(base64.b32encode(self.sideband.identity.get_private_key()).decode("utf-8"))) + dialog.open() + + c_yes_button.bind(on_release=c_dl_yes) + c_no_button.bind(on_release=c_dl_no) + + c_dialog.open() + + def identity_share_action(self, sender=None): + if RNS.vendor.platformutils.get_platform() == "android": + self.share_text(str(base64.b32encode(self.sideband.identity.get_private_key()).decode("utf-8"))) + + def identity_restore_action(self, sender=None): + c_yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.color_reject, text_color=self.color_reject) + c_no_button = MDRectangleFlatButton(text="No, go back",font_size=dp(18)) + c_dialog = MDDialog(text="[b]Caution![/b]\n\nYou are about to import a new Identity key into Sideband. The currently active key will be irreversibly destroyed, and you will loose your LXMF address if you have not already backed up your current Identity key.\n\nAre you sure that you wish to import the key?", buttons=[ c_no_button, c_yes_button ]) + def c_dl_no(s): + c_dialog.dismiss() + def c_dl_yes(s): + c_dialog.dismiss() + b32_text = self.keys_screen.ids.key_restore_text.text + + try: + key_bytes = base64.b32decode(b32_text) + new_id = RNS.Identity.from_bytes(key_bytes) + + if new_id != None: + new_id.to_file(self.sideband.identity_path) + + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog(text="[b]The provided Identity key data was imported[/b]\n\nThe app will now exit. Please restart Sideband to use the new Identity.", buttons=[ yes_button ]) + def dl_yes(s): + dialog.dismiss() + self.quit_action(sender=self) + yes_button.bind(on_release=dl_yes) + dialog.open() + + except Exception as e: + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog(text="[b]The provided Identity key data was not valid[/b]\n\nThe error reported by Reticulum was:\n\n[i]"+str(e)+"[/i]\n\nNo Identity was imported into Sideband.", buttons=[ yes_button ]) + def dl_yes(s): + dialog.dismiss() + yes_button.bind(on_release=dl_yes) + dialog.open() + + + c_yes_button.bind(on_release=c_dl_yes) + c_no_button.bind(on_release=c_dl_no) + + c_dialog.open() ### Plugins & Services screen ###################################### @@ -4597,10 +5217,6 @@ class SidebandApp(MDApp): perm_ok = True path = self.sideband.config["command_plugins_path"] - if not os.path.isdir(path): - if not RNS.vendor.platformutils.is_android(): path = os.path.expanduser("~") - else: path = primary_external_storage_path() - if perm_ok and path != None: try: self.file_manager = MDFileManager( @@ -4708,8 +5324,6 @@ class SidebandApp(MDApp): self.voice_screen.screen.ids.identity_hash.text = RNS.hexrep(dial_on_complete, delimit=False) Clock.schedule_once(self.voice_screen.dial_action, 0.25) - if self.sideband.config["voice_enabled"] == True: self.request_microphone_permission() - def voice_action(self, sender=None, direction="left", dial_on_complete=None): if self.voice_ready: self.voice_open(direction=direction, dial_on_complete=dial_on_complete) @@ -4730,14 +5344,13 @@ class SidebandApp(MDApp): def voice_answer_action(self, sender=None): if self.sideband.voice_running: - if self.sideband.telephone.is_ringing: - self.sideband.telephone.answer() - toast("Call answered") + if self.sideband.telephone.is_ringing: self.sideband.telephone.answer() def voice_reject_action(self, sender=None): if self.sideband.voice_running: if self.sideband.telephone.is_ringing or self.sideband.telephone.is_in_call: self.sideband.telephone.hangup() + toast("Call ended") ### Telemetry Screen ###################################### @@ -4851,7 +5464,7 @@ class SidebandApp(MDApp): self.telemetry_info_dialog.dismiss() ok_button.bind(on_release=dl_ok) - result = self.sideband.request_latest_telemetry(from_addr=self.sideband.config["telemetry_collector"], is_collector_request=True) + result = self.sideband.request_latest_telemetry(from_addr=self.sideband.config["telemetry_collector"]) if result == "no_address": title_str = "Invalid Address" @@ -4861,10 +5474,10 @@ class SidebandApp(MDApp): info_str = "No keys known for the destination. Connected reticules have been queried for the keys. Try again when an announce for the destination has arrived." elif result == "in_progress": title_str = "Transfer In Progress" - info_str = "There is already a telemetry request transfer in progress to the collector." + info_str = "There is already a telemetry request transfer in progress for this peer." elif result == "sent": title_str = "Request Sent" - info_str = "A telemetry request was sent to the collector. The collector should send any available telemetry shortly." + info_str = "A telemetry request was sent to the peer. The peer should send any available telemetry shortly." elif result == "not_sent": title_str = "Not Sent" info_str = "A telemetry request could not be sent." @@ -5603,17 +6216,148 @@ class SidebandApp(MDApp): self.root.ids.screen_manager.transition = self.slide_transition def guide_action(self, sender=None, direction="left"): - if self.guide_view: self.guide_open() + if self.root.ids.screen_manager.has_screen("guide_screen"): + self.guide_open(direction=direction) else: self.loader_action(direction=direction) def final(dt): - self.init_guide_view() - def o(dt): self.guide_open(no_transition=True) + self.guide_init() + def o(dt): + self.guide_open(no_transition=True) Clock.schedule_once(o, ll_ot) Clock.schedule_once(final, ll_ft) - def init_guide_view(self, sender=None): - if not self.guide_view: self.guide_view = Guide(self) + def guide_init(self): + if not self.root.ids.screen_manager.has_screen("guide_screen"): + self.guide_screen = Builder.load_string(layout_guide_screen) + self.guide_screen.app = self + self.root.ids.screen_manager.add_widget(self.guide_screen) + + def link_exec(sender=None, event=None): + def lj(): + webbrowser.open("https://unsigned.io/donate") + threading.Thread(target=lj, daemon=True).start() + + guide_text1 = """ +[size=18dp][b]Introduction[/b][/size][size=5dp]\n \n[/size]Welcome to [i]Sideband[/i], an LXMF client for Android, Linux, macOS and Windows. With Sideband, you can communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, or anything else Reticulum supports. + +This short guide will give you a basic introduction to the concepts that underpin Sideband and LXMF (which is the protocol that Sideband uses to communicate). If you are not already familiar with LXMF and Reticulum, it is probably a good idea to read this guide, since Sideband is very different from other messaging apps.""" + guide_text2 = """ +[size=18dp][b]Communication Without Subjection[/b][/size][size=5dp]\n \n[/size]Sideband is completely free, permission-less, anonymous and infrastructure-less. Sideband uses the peer-to-peer and distributed messaging system LXMF. There is no sign-up, no service providers, no "end-user license agreements", no data theft and no surveillance. You own the system. + +This also means that Sideband operates differently than what you might be used to. It does not need a connection to a server on the Internet to function, and you do not have an account anywhere.""" + + guide_text3 = """ +[size=18dp][b]Operating Principles[/b][/size][size=5dp]\n \n[/size]When Sideband is started on your device for the first time, it randomly generates a 512-bit Reticulum Identity Key. This cryptographic key is then used to create an LXMF address for your use, and in turn to secure any communication to your address. Any other endpoint in [i]any[/i] Reticulum network will be able to send data to your address, as long as there is [i]some sort of physical connection[/i] between your device and the remote endpoint. You can also move around to other Reticulum networks with this address, even ones that were never connected to the network the address was created on, or that didn't exist when the address was created.\n\nYour LXMF address is yours to keep and control for as long (or short) a time you need it, and you can always delete it and create a new one. You identity keys and corresponding addresses are never registered on or controlled by any external servers or services, and will never leave your device, unless you manually export them for backup.""" + + guide_text10 = """ +[size=18dp][b]Getting Connected[/b][/size][size=5dp]\n \n[/size]If you already have Reticulum connectivity set up on the device you are running Sideband on, no further configuration should be necessary, and Sideband will simply use the available Reticulum connectivity.\n\nIf you are running Sideband on a computer, you can configure interfaces in the Reticulum configuration file ([b]~/.reticulum/config[/b] by default). If you are running Sideband on an Android device, you can configure various interface types in the [b]Connectivity[/b] section. By default, only an [i]AutoInterface[/i] is enabled, which will connect you automatically with any other local devices on the same WiFi and/or Ethernet networks. This may or may not include Reticulum Transport Nodes, which can route your traffic to wider networks.\n\nYou can enable any or all of the other available interface types to gain wider connectivity. For more specific information on interface types, configuration options, and how to effectively build your own Reticulum networks, see the [b]Reticulum Manual[b].""" + + guide_text4 = """ +[size=18dp][b]Becoming Reachable[/b][/size][size=5dp]\n \n[/size]To establish reachability for any Reticulum destination on a network, an [i]announce[/i] must be sent. By default, Sideband will announce automatically when necessary, but if you want to stay silent, automatic announces can be disabled in [b]Preferences[/b].\n\nTo send an announce manually, press the [i]Announce[/i] button in the [i]Conversations[/i] section of the program. When you send an announce, you make your LXMF address reachable for real-time messaging to the entire network you are connected to. Even in very large networks, you can expect global reachability for your address to be established in under a minute. + +If you don't move to other places in the network, and keep connected through the same hubs or gateways, it is generally not necessary to send an announce more often than once every week. If you change your entry point to the network, you may want to send an announce, or you may just want to stay quiet.""" + + guide_text5 = """ +[size=18dp][b]Relax & Disconnect[/b][/size][size=5dp]\n \n[/size]If you are not connected to the network, it is still possible for other people to message you, as long as one or more [i]Propagation Nodes[/i] exist on the network. These nodes pick up and hold encrypted in-transit messages for offline users. Messages are always encrypted before leaving the originators device, and nobody else than the intended recipient can decrypt messages in transit. + +The Propagation Nodes also distribute copies of messages between each other, such that even the failure of almost every node in the network will still allow users to sync their waiting messages. If all Propagation Nodes disappear or are destroyed, users can still communicate directly.\n\nReticulum and LXMF will degrade gracefully all the way down to single users communicating directly via long-range data radios. Anyone can start up new propagation nodes and integrate them into existing networks without permission or coordination. Even a small and cheap device like a Rasperry Pi can handle messages for millions of users. LXMF networks are designed to be quite resilient, as long as there are people using them.""" + + guide_text6 = """ +[size=18dp][b]Packets Find A Way[/b][/size][size=5dp]\n \n[/size]Connections in Reticulum networks can be wired or wireless, span many intermediary hops, run over fast links or ultra-low bandwidth radio, tunnel over the Invisible Internet (I2P), private networks, satellite connections, serial lines or anything else that Reticulum can carry data over.\n\nIn most cases it will not be possible to know what path packets takes in a Reticulum network, and apart from a destination hash, no transmitted packets carries any identifying characteristics. In Reticulum, [i]there is no source addresses[/i].\n\nAs long as you do not reveal any connecting details between your person and your LXMF address, you can remain anonymous. Sending messages to others does not reveal [i]your[/i] address to anyone else than the intended recipient.""" + + guide_text7 = """ +[size=18dp][b]Be Yourself, Be Unknown, Stay Free[/b][/size][size=5dp]\n \n[/size]Even with the above characteristics in mind, you [b]must remember[/b] that LXMF and Reticulum is not a technology that can guarantee anonymising connections that are already de-anonymised! If you use Sideband to connect to TCP Reticulum hubs over the clear Internet, from a network that can be tied to your personal identity, an adversary may learn that you are generating LXMF traffic.\n\nIf you want to avoid this, it is recommended to use I2P to connect to Reticulum hubs on the Internet. Or only connecting from within pure Reticulum networks, that take one or more hops to reach connections that span the Internet. This is a complex topic, with many more nuances than can be covered here. You are encouraged to ask on the various Reticulum discussion forums if you are in doubt. + +If you use Reticulum and LXMF on hardware that does not carry any identifiers tied to you, it is possible to establish a completely free and identification-less communication system with Reticulum and LXMF clients.""" + + guide_text8 = """ +[size=18dp][b]Keyboard Shortcuts[/b][/size][size=5dp]\n \n[/size]To ease navigation and operation of the program, Sideband has keyboard shortcuts mapped to the most common actions. A reference is included below. + +[b]Quick Actions[/b] + - [b]Ctrl-W[/b] Go back + - [b]Ctrl-Q[/b] Shut down Sideband + - [b]Ctrl-R[/b] Start LXMF sync (from Conversations screen) + - [b]Ctrl-N[/b] Create new conversation + + [b]Message Actions[/b] + - [b]Ctrl-Shift-A[/b] add message attachment + - [b]Ctrl-Shift-V[/b] add high-quality voice + - [b]Ctrl-Shift-C[/b] add low-bandwidth voice + - [b]Ctrl-Shift-I[/b] add medium-quality image + - [b]Ctrl-Shift-F[/b] add file + - [b]Ctrl-D[/b] or [b]Ctrl-S[/b] Send message + + [b]Voice & PTT Messages[/b] + - [b]Space[/b] Start/stop recording + - [b]Enter[/b] Save recording to message + - With PTT enabled, hold [b]Space[/b] to talk + + [b]Voice Calls[/b] + - [b]Ctrl-Space[/b] Answer incoming call + - [b]Ctrl-.[/b] Reject incoming call + - [b]Ctrl-.[/b] Hang up active call + + [b]Navigation[/b] + - [b]Ctrl-[i]n[/i][/b] Go to conversation number [i]n[/i] + - [b]Ctrl-R[/b] Go to Conversations + - [b]Ctrl-O[/b] Go to Objects & Devices + - [b]Ctrl-E[/b] Go to Voice + - [b]Ctrl-L[/b] Go to Announce Stream + - [b]Ctrl-M[/b] Go to Situation Map + - [b]Ctrl-U[/b] Go to Utilities + - [b]Ctrl-T[/b] Go to Telemetry configuration + - [b]Ctrl-G[/b] Go to Guide + - [b]Ctrl-Y[/b] Display own telemetry + +[b]Map Controls[/b] + - [b]Up[/b], [b]down[/b], [b]left[/b], [b]right[/b] Navigate + - [b]W[/b], [b]A[/b], [b]S[/b], [b]D[/b] Navigate + - [b]H[/b], [b]J[/b], [b]L[/b], [b]K[/b] Navigate + - [b]E[/b] or [b]+[/b] Zoom in + - [b]Q[/b] or [b]-[/b] Zoom out + - Hold [b]Shift[/b] to navigate more coarsely + - Hold [b]Alt[/b] to navigate more finely""" + + guide_text9 = """ +[size=18dp][b]Please Support This Project[/b][/size][size=5dp]\n \n[/size]It took me more than eight years to design and build the entire ecosystem of software and hardware that makes this possible. If this project is valuable to you, please go to [u][ref=link]https://unsigned.io/donate[/ref][/u] to support the project with a donation. Every donation directly makes the entire Reticulum project possible. + +Thank you very much for using Free Communications Systems. +""" + info1 = guide_text1 + info2 = guide_text8 + info3 = guide_text2 + info4 = guide_text3 + info10 = guide_text10 + info5 = guide_text4 + info6 = guide_text5 + info7 = guide_text6 + info8 = guide_text7 + info9 = guide_text9 + + if self.theme_cls.theme_style == "Dark": + info1 = "[color=#"+dark_theme_text_color+"]"+info1+"[/color]" + info2 = "[color=#"+dark_theme_text_color+"]"+info2+"[/color]" + info3 = "[color=#"+dark_theme_text_color+"]"+info3+"[/color]" + info4 = "[color=#"+dark_theme_text_color+"]"+info4+"[/color]" + info5 = "[color=#"+dark_theme_text_color+"]"+info5+"[/color]" + info6 = "[color=#"+dark_theme_text_color+"]"+info6+"[/color]" + info7 = "[color=#"+dark_theme_text_color+"]"+info7+"[/color]" + info8 = "[color=#"+dark_theme_text_color+"]"+info8+"[/color]" + info9 = "[color=#"+dark_theme_text_color+"]"+info9+"[/color]" + info10 = "[color=#"+dark_theme_text_color+"]"+info10+"[/color]" + self.guide_screen.ids.guide_info1.text = info1 + self.guide_screen.ids.guide_info2.text = info2 + self.guide_screen.ids.guide_info3.text = info3 + self.guide_screen.ids.guide_info4.text = info4 + self.guide_screen.ids.guide_info5.text = info5 + self.guide_screen.ids.guide_info6.text = info6 + self.guide_screen.ids.guide_info7.text = info7 + self.guide_screen.ids.guide_info8.text = info8 + self.guide_screen.ids.guide_info9.text = info9 + self.guide_screen.ids.guide_info10.text = info10 + self.guide_screen.ids.guide_info9.bind(on_ref_press=link_exec) + self.guide_screen.ids.guide_scrollview.effect_cls = ScrollEffect ################################################# @@ -5699,26 +6443,13 @@ def run(): config_path=args.config, is_client=False, verbose=(args.verbose or __debug_build__), - quiet=(args.interactive and not args.verbose), - is_daemon=True, - rns_config_path=args.rnsconfig, + is_daemon=True ) sideband.version_str = "v"+__version__+" "+__variant__ sideband.start() - - if args.interactive: - while not sideband.getstate("core.started") == True: time.sleep(0.1) - import importlib - if importlib.util.find_spec('prompt_toolkit') != None: - from .sideband import console - console.attach(sideband) - else: - print("Could not start Sideband console, since the \"prompt-toolkit\" module is not available") - print("You can install it with \"pip install prompt-toolkit\"") - - else: - while True: time.sleep(5) + while True: + time.sleep(5) else: ExceptionManager.add_handler(SidebandExceptionHandler()) SidebandApp().run() diff --git a/sbapp/patches/AndroidManifest.tmpl.xml b/sbapp/patches/AndroidManifest.tmpl.xml index 9ff83ec..c6cdd64 100644 --- a/sbapp/patches/AndroidManifest.tmpl.xml +++ b/sbapp/patches/AndroidManifest.tmpl.xml @@ -121,12 +121,6 @@ {% endfor %} - - - - - - {% if args.billing_pubkey %} diff --git a/sbapp/patches/PythonActivity.java b/sbapp/patches/PythonActivity.java deleted file mode 100644 index 41b9460..0000000 --- a/sbapp/patches/PythonActivity.java +++ /dev/null @@ -1,651 +0,0 @@ -package org.kivy.android; - -import java.io.InputStream; -import java.io.FileWriter; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.graphics.PixelFormat; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.PowerManager; -import android.util.Log; -import android.view.inputmethod.InputMethodManager; -import android.view.SurfaceView; -import android.view.ViewGroup; -import android.view.View; -import android.widget.ImageView; -import android.widget.Toast; -import android.content.res.Resources.NotFoundException; - -import org.libsdl.app.SDLActivity; - -import org.kivy.android.launcher.Project; - -import org.renpy.android.ResourceManager; - - -public class PythonActivity extends SDLActivity { - private static final String TAG = "PythonActivity"; - - public static PythonActivity mActivity = null; - public boolean activityPaused = false; - - private ResourceManager resourceManager = null; - private Bundle mMetaData = null; - private PowerManager.WakeLock mWakeLock = null; - - public Intent startIntent = null; - - public String getAppRoot() { - String app_root = getFilesDir().getAbsolutePath() + "/app"; - return app_root; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - try { this.startIntent = getIntent(); } - catch (Exception e) { Log.e(TAG, "Failed to get pending intent on activity create"); } - - Log.v(TAG, "PythonActivity onCreate running"); - resourceManager = new ResourceManager(this); - - Log.v(TAG, "About to do super onCreate"); - super.onCreate(savedInstanceState); - Log.v(TAG, "Did super onCreate"); - - this.mActivity = this; - this.showLoadingScreen(this.getLoadingScreen()); - - new UnpackFilesTask().execute(getAppRoot()); - } - - public void loadLibraries() { - String app_root = new String(getAppRoot()); - File app_root_file = new File(app_root); - PythonUtil.loadLibraries(app_root_file, - new File(getApplicationInfo().nativeLibraryDir)); - } - - /** - * Show an error using a toast. (Only makes sense from non-UI - * threads.) - */ - public void toastError(final String msg) { - - final Activity thisActivity = this; - - runOnUiThread(new Runnable () { - public void run() { - Toast.makeText(thisActivity, msg, Toast.LENGTH_LONG).show(); - } - }); - - // Wait to show the error. - synchronized (this) { - try { - this.wait(1000); - } catch (InterruptedException e) { - } - } - } - - private class UnpackFilesTask extends AsyncTask { - @Override - protected String doInBackground(String... params) { - File app_root_file = new File(params[0]); - Log.v(TAG, "Ready to unpack"); - PythonUtil.unpackAsset(mActivity, "private", app_root_file, true); - PythonUtil.unpackPyBundle(mActivity, getApplicationInfo().nativeLibraryDir + "/" + "libpybundle", app_root_file, false); - return null; - } - - @Override - protected void onPostExecute(String result) { - // Figure out the directory where the game is. If the game was - // given to us via an intent, then we use the scheme-specific - // part of that intent to determine the file to launch. We - // also use the android.txt file to determine the orientation. - // - // Otherwise, we use the public data, if we have it, or the - // private data if we do not. - mActivity.finishLoad(); - - // finishLoad called setContentView with the SDL view, which - // removed the loading screen. However, we still need it to - // show until the app is ready to render, so pop it back up - // on top of the SDL view. - mActivity.showLoadingScreen(getLoadingScreen()); - - String app_root_dir = getAppRoot(); - if (getIntent() != null && getIntent().getAction() != null && - getIntent().getAction().equals("org.kivy.LAUNCH")) { - File path = new File(getIntent().getData().getSchemeSpecificPart()); - - Project p = Project.scanDirectory(path); - String entry_point = getEntryPoint(p.dir); - SDLActivity.nativeSetenv("ANDROID_ENTRYPOINT", p.dir + "/" + entry_point); - SDLActivity.nativeSetenv("ANDROID_ARGUMENT", p.dir); - SDLActivity.nativeSetenv("ANDROID_APP_PATH", p.dir); - - if (p != null) { - if (p.landscape) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - } else { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } - } - - // Let old apps know they started. - try { - FileWriter f = new FileWriter(new File(path, ".launch")); - f.write("started"); - f.close(); - } catch (IOException e) { - // pass - } - } else { - String entry_point = getEntryPoint(app_root_dir); - SDLActivity.nativeSetenv("ANDROID_ENTRYPOINT", entry_point); - SDLActivity.nativeSetenv("ANDROID_ARGUMENT", app_root_dir); - SDLActivity.nativeSetenv("ANDROID_APP_PATH", app_root_dir); - } - - String mFilesDirectory = mActivity.getFilesDir().getAbsolutePath(); - Log.v(TAG, "Setting env vars for start.c and Python to use"); - SDLActivity.nativeSetenv("ANDROID_PRIVATE", mFilesDirectory); - SDLActivity.nativeSetenv("ANDROID_UNPACK", app_root_dir); - SDLActivity.nativeSetenv("PYTHONHOME", app_root_dir); - SDLActivity.nativeSetenv("PYTHONPATH", app_root_dir + ":" + app_root_dir + "/lib"); - SDLActivity.nativeSetenv("PYTHONOPTIMIZE", "2"); - - try { - Log.v(TAG, "Access to our meta-data..."); - mActivity.mMetaData = mActivity.getPackageManager().getApplicationInfo( - mActivity.getPackageName(), PackageManager.GET_META_DATA).metaData; - - PowerManager pm = (PowerManager) mActivity.getSystemService(Context.POWER_SERVICE); - if ( mActivity.mMetaData.getInt("wakelock") == 1 ) { - mActivity.mWakeLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "Screen On"); - mActivity.mWakeLock.acquire(); - } - if ( mActivity.mMetaData.getInt("surface.transparent") != 0 ) { - Log.v(TAG, "Surface will be transparent."); - getSurface().setZOrderOnTop(true); - getSurface().getHolder().setFormat(PixelFormat.TRANSPARENT); - } else { - Log.i(TAG, "Surface will NOT be transparent"); - } - } catch (PackageManager.NameNotFoundException e) { - } - - // Launch app if that hasn't been done yet: - if (mActivity.mHasFocus && ( - // never went into proper resume state: - mActivity.mCurrentNativeState == NativeState.INIT || - ( - // resumed earlier but wasn't ready yet - mActivity.mCurrentNativeState == NativeState.RESUMED && - mActivity.mSDLThread == null - ))) { - // Because sometimes the app will get stuck here and never - // actually run, ensure that it gets launched if we're active: - mActivity.resumeNativeThread(); - } - } - - @Override - protected void onPreExecute() { - } - - @Override - protected void onProgressUpdate(Void... values) { - } - } - - public static ViewGroup getLayout() { - return mLayout; - } - - public static SurfaceView getSurface() { - return mSurface; - } - - //---------------------------------------------------------------------------- - // Listener interface for onNewIntent - // - - public interface NewIntentListener { - void onNewIntent(Intent intent); - } - - private List newIntentListeners = null; - - public void registerNewIntentListener(NewIntentListener listener) { - if ( this.newIntentListeners == null ) - this.newIntentListeners = Collections.synchronizedList(new ArrayList()); - this.newIntentListeners.add(listener); - } - - public void unregisterNewIntentListener(NewIntentListener listener) { - if ( this.newIntentListeners == null ) - return; - this.newIntentListeners.remove(listener); - } - - @Override - protected void onNewIntent(Intent intent) { - if ( this.newIntentListeners == null ) - return; - this.onResume(); - synchronized ( this.newIntentListeners ) { - Iterator iterator = this.newIntentListeners.iterator(); - while ( iterator.hasNext() ) { - (iterator.next()).onNewIntent(intent); - } - } - } - - //---------------------------------------------------------------------------- - // Listener interface for onActivityResult - // - - public interface ActivityResultListener { - void onActivityResult(int requestCode, int resultCode, Intent data); - } - - private List activityResultListeners = null; - - public void registerActivityResultListener(ActivityResultListener listener) { - if ( this.activityResultListeners == null ) - this.activityResultListeners = Collections.synchronizedList(new ArrayList()); - this.activityResultListeners.add(listener); - } - - public void unregisterActivityResultListener(ActivityResultListener listener) { - if ( this.activityResultListeners == null ) - return; - this.activityResultListeners.remove(listener); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent intent) { - if ( this.activityResultListeners == null ) - return; - this.onResume(); - synchronized ( this.activityResultListeners ) { - Iterator iterator = this.activityResultListeners.iterator(); - while ( iterator.hasNext() ) - (iterator.next()).onActivityResult(requestCode, resultCode, intent); - } - } - - public static void start_service( - String serviceTitle, - String serviceDescription, - String pythonServiceArgument - ) { - _do_start_service( - serviceTitle, serviceDescription, pythonServiceArgument, true - ); - } - - public static void start_service_not_as_foreground( - String serviceTitle, - String serviceDescription, - String pythonServiceArgument - ) { - _do_start_service( - serviceTitle, serviceDescription, pythonServiceArgument, false - ); - } - - public static void _do_start_service( - String serviceTitle, - String serviceDescription, - String pythonServiceArgument, - boolean showForegroundNotification - ) { - Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class); - String argument = PythonActivity.mActivity.getFilesDir().getAbsolutePath(); - String app_root_dir = PythonActivity.mActivity.getAppRoot(); - String entry_point = PythonActivity.mActivity.getEntryPoint(app_root_dir + "/service"); - serviceIntent.putExtra("androidPrivate", argument); - serviceIntent.putExtra("androidArgument", app_root_dir); - serviceIntent.putExtra("serviceEntrypoint", "service/" + entry_point); - serviceIntent.putExtra("pythonName", "python"); - serviceIntent.putExtra("pythonHome", app_root_dir); - serviceIntent.putExtra("pythonPath", app_root_dir + ":" + app_root_dir + "/lib"); - serviceIntent.putExtra("serviceStartAsForeground", - (showForegroundNotification ? "true" : "false") - ); - serviceIntent.putExtra("serviceTitle", serviceTitle); - serviceIntent.putExtra("serviceDescription", serviceDescription); - serviceIntent.putExtra("pythonServiceArgument", pythonServiceArgument); - PythonActivity.mActivity.startService(serviceIntent); - } - - public static void stop_service() { - Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class); - PythonActivity.mActivity.stopService(serviceIntent); - } - - /** Loading screen view **/ - public static ImageView mImageView = null; - public static View mLottieView = null; - /** Whether main routine/actual app has started yet **/ - protected boolean mAppConfirmedActive = false; - /** Timer for delayed loading screen removal. **/ - protected Timer loadingScreenRemovalTimer = null; - - // Overridden since it's called often, to check whether to remove the - // loading screen: - @Override - protected boolean sendCommand(int command, Object data) { - boolean result = super.sendCommand(command, data); - considerLoadingScreenRemoval(); - return result; - } - - /** Confirm that the app's main routine has been launched. - **/ - @Override - public void appConfirmedActive() { - if (!mAppConfirmedActive) { - Log.v(TAG, "appConfirmedActive() -> preparing loading screen removal"); - mAppConfirmedActive = true; - considerLoadingScreenRemoval(); - } - } - - /** This is called from various places to check whether the app's main - * routine has been launched already, and if it has, then the loading - * screen will be removed. - **/ - public void considerLoadingScreenRemoval() { - if (loadingScreenRemovalTimer != null) - return; - runOnUiThread(new Runnable() { - public void run() { - if (((PythonActivity)PythonActivity.mSingleton).mAppConfirmedActive && - loadingScreenRemovalTimer == null) { - // Remove loading screen but with a delay. - // (app can use p4a's android.loadingscreen module to - // do it quicker if it wants to) - // get a handler (call from main thread) - // this will run when timer elapses - TimerTask removalTask = new TimerTask() { - @Override - public void run() { - // post a runnable to the handler - runOnUiThread(new Runnable() { - @Override - public void run() { - PythonActivity activity = - ((PythonActivity)PythonActivity.mSingleton); - if (activity != null) - activity.removeLoadingScreen(); - } - }); - } - }; - loadingScreenRemovalTimer = new Timer(); - loadingScreenRemovalTimer.schedule(removalTask, 5000); - } - } - }); - } - - public void removeLoadingScreen() { - runOnUiThread(new Runnable() { - public void run() { - View view = mLottieView != null ? mLottieView : mImageView; - if (view != null && view.getParent() != null) { - ((ViewGroup)view.getParent()).removeView(view); - mLottieView = null; - mImageView = null; - } - } - }); - } - - public String getEntryPoint(String search_dir) { - /* Get the main file (.pyc|.py) depending on if we - * have a compiled version or not. - */ - List entryPoints = new ArrayList(); - entryPoints.add("main.pyc"); // python 3 compiled files - for (String value : entryPoints) { - File mainFile = new File(search_dir + "/" + value); - if (mainFile.exists()) { - return value; - } - } - return "main.py"; - } - - protected void showLoadingScreen(View view) { - try { - if (mLayout == null) { - setContentView(view); - } else if (view.getParent() == null) { - mLayout.addView(view); - } - } catch (IllegalStateException e) { - // The loading screen can be attempted to be applied twice if app - // is tabbed in/out, quickly. - // (Gives error "The specified child already has a parent. - // You must call removeView() on the child's parent first.") - } - } - - protected void setBackgroundColor(View view) { - /* - * Set the presplash loading screen background color - * https://developer.android.com/reference/android/graphics/Color.html - * Parse the color string, and return the corresponding color-int. - * If the string cannot be parsed, throws an IllegalArgumentException exception. - * Supported formats are: #RRGGBB #AARRGGBB or one of the following names: - * 'red', 'blue', 'green', 'black', 'white', 'gray', 'cyan', 'magenta', 'yellow', - * 'lightgray', 'darkgray', 'grey', 'lightgrey', 'darkgrey', 'aqua', 'fuchsia', - * 'lime', 'maroon', 'navy', 'olive', 'purple', 'silver', 'teal'. - */ - String backgroundColor = resourceManager.getString("presplash_color"); - if (backgroundColor != null) { - try { - view.setBackgroundColor(Color.parseColor(backgroundColor)); - } catch (IllegalArgumentException e) {} - } - } - - protected View getLoadingScreen() { - // If we have an mLottieView or mImageView already, then do - // nothing because it will have already been made the content - // view or added to the layout. - if (mLottieView != null || mImageView != null) { - // we already have a splash screen - return mLottieView != null ? mLottieView : mImageView; - } - - // first try to load the lottie one - try { - mLottieView = getLayoutInflater().inflate( - this.resourceManager.getIdentifier("lottie", "layout"), - mLayout, - false - ); - try { - if (mLayout == null) { - setContentView(mLottieView); - } else if (PythonActivity.mLottieView.getParent() == null) { - mLayout.addView(mLottieView); - } - } catch (IllegalStateException e) { - // The loading screen can be attempted to be applied twice if app - // is tabbed in/out, quickly. - // (Gives error "The specified child already has a parent. - // You must call removeView() on the child's parent first.") - } - setBackgroundColor(mLottieView); - return mLottieView; - } - catch (NotFoundException e) { - Log.v("SDL", "couldn't find lottie layout or animation, trying static splash"); - } - - // no lottie asset, try to load the static image then - int presplashId = this.resourceManager.getIdentifier("presplash", "drawable"); - InputStream is = this.getResources().openRawResource(presplashId); - Bitmap bitmap = null; - try { - bitmap = BitmapFactory.decodeStream(is); - } finally { - try { - is.close(); - } catch (IOException e) {}; - } - - mImageView = new ImageView(this); - mImageView.setImageBitmap(bitmap); - setBackgroundColor(mImageView); - - mImageView.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.FILL_PARENT, - ViewGroup.LayoutParams.FILL_PARENT)); - mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); - return mImageView; - } - - @Override - protected void onPause() { - if (this.mWakeLock != null && mWakeLock.isHeld()) { - this.mWakeLock.release(); - } - - this.activityPaused = true; - Log.v(TAG, "onPause()"); - try { - super.onPause(); - } catch (UnsatisfiedLinkError e) { - // Catch pause while still in loading screen failing to - // call native function (since it's not yet loaded) - } - } - - @Override - protected void onResume() { - if (this.mWakeLock != null) { - this.mWakeLock.acquire(); - } - this.activityPaused = false; - Log.v(TAG, "onResume() in PythonActivity"); - try { - super.onResume(); - } catch (UnsatisfiedLinkError e) { - // Catch resume while still in loading screen failing to - // call native function (since it's not yet loaded) - } - considerLoadingScreenRemoval(); - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - try { - super.onWindowFocusChanged(hasFocus); - } catch (UnsatisfiedLinkError e) { - // Catch window focus while still in loading screen failing to - // call native function (since it's not yet loaded) - } - considerLoadingScreenRemoval(); - } - - /** - * Used by android.permissions p4a module to register a call back after - * requesting runtime permissions - **/ - public interface PermissionsCallback { - void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults); - } - - private PermissionsCallback permissionCallback; - private boolean havePermissionsCallback = false; - - public void addPermissionsCallback(PermissionsCallback callback) { - permissionCallback = callback; - havePermissionsCallback = true; - Log.v(TAG, "addPermissionsCallback(): Added callback for onRequestPermissionsResult"); - } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - Log.v(TAG, "onRequestPermissionsResult()"); - if (havePermissionsCallback) { - Log.v(TAG, "onRequestPermissionsResult passed to callback"); - permissionCallback.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - - /** - * Used by android.permissions p4a module to check a permission - **/ - public boolean checkCurrentPermission(String permission) { - if (android.os.Build.VERSION.SDK_INT < 23) - return true; - - try { - java.lang.reflect.Method methodCheckPermission = - Activity.class.getMethod("checkSelfPermission", String.class); - Object resultObj = methodCheckPermission.invoke(this, permission); - int result = Integer.parseInt(resultObj.toString()); - if (result == PackageManager.PERMISSION_GRANTED) - return true; - } catch (IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - } - return false; - } - - /** - * Used by android.permissions p4a module to request runtime permissions - **/ - public void requestPermissionsWithRequestCode(String[] permissions, int requestCode) { - if (android.os.Build.VERSION.SDK_INT < 23) - return; - try { - java.lang.reflect.Method methodRequestPermission = - Activity.class.getMethod("requestPermissions", - String[].class, int.class); - methodRequestPermission.invoke(this, permissions, requestCode); - } catch (IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - } - } - - public void requestPermissions(String[] permissions) { - requestPermissionsWithRequestCode(permissions, 1); - } - - public static void changeKeyboard(int inputType) { - if (SDLActivity.keyboardInputType != inputType){ - SDLActivity.keyboardInputType = inputType; - InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.restartInput(mTextEdit); - } - } -} diff --git a/sbapp/patches/PythonService.java b/sbapp/patches/PythonService.java index 72e853c..ad7142c 100644 --- a/sbapp/patches/PythonService.java +++ b/sbapp/patches/PythonService.java @@ -25,32 +25,8 @@ import android.graphics.drawable.Icon; import android.net.wifi.WifiManager; import android.net.wifi.WifiManager.MulticastLock; -import java.util.Objects; -import android.content.BroadcastReceiver; - -import io.unsigned.sideband.ServiceSidebandservice; - public class PythonService extends Service implements Runnable { - public static class ServiceBootReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - try { - if (Objects.equals(intent.getAction(), "android.intent.action.BOOT_COMPLETED")) { - boolean should_start = false; - String app_dir = context.getFilesDir().getParentFile().getParent(); - String toggle_path = context.getFilesDir()+"/app_storage/"; - File file = new File(toggle_path, "boot_toggle"); - if (file.exists()) { should_start = true; } - if (should_start) { - ServiceSidebandservice svc = new ServiceSidebandservice(); - svc.start(context, app_dir); - } - } - } catch (Exception e) { Log.e("sidebandservice", "Could not start Sideband service at boot: "+e); } - } - } - // Thread for Python code private Thread pythonThread = null; diff --git a/sbapp/plyer/__init__.py b/sbapp/plyer/__init__.py index 9859034..9bfb190 100644 --- a/sbapp/plyer/__init__.py +++ b/sbapp/plyer/__init__.py @@ -5,7 +5,7 @@ Plyer ''' __all__ = ( - 'accelerometer', 'barometer', 'battery', 'bluetooth', + 'accelerometer', 'audio', 'barometer', 'battery', 'bluetooth', 'brightness', 'call', 'camera', 'compass', 'cpu', 'email', 'filechooser', 'flash', 'gps', 'gravity', 'gyroscope', 'humidity', 'irblaster', 'keystore', 'light', 'notification', 'orientation', 'processors', @@ -29,6 +29,9 @@ accelerometer = Proxy('accelerometer', facades.Accelerometer) #: Keyring proxy to :class::`plyer.facades.Keystore` keystore = Proxy('keystore', facades.Keystore) +#: Audio proxy to :class:`plyer.facades.Audio` +audio = Proxy('audio', facades.Audio) + #: Barometer proxy to :class:`plyer.facades.Barometer` barometer = Proxy('barometer', facades.Barometer) diff --git a/sbapp/plyer/facades/__init__.py b/sbapp/plyer/facades/__init__.py index 88f3985..81a0f63 100644 --- a/sbapp/plyer/facades/__init__.py +++ b/sbapp/plyer/facades/__init__.py @@ -6,7 +6,7 @@ Interface of all the features available. ''' -__all__ = ('Accelerometer', 'Barometer', 'Battery', 'Call', 'Camera', +__all__ = ('Accelerometer', 'Audio', 'Barometer', 'Battery', 'Call', 'Camera', 'Compass', 'Email', 'FileChooser', 'GPS', 'Gravity', 'Gyroscope', 'IrBlaster', 'Light', 'Orientation', 'Notification', 'Proximity', 'Sms', 'TTS', 'UniqueID', 'Vibrator', 'Wifi', 'Flash', 'CPU', @@ -17,6 +17,7 @@ __all__ = ('Accelerometer', 'Barometer', 'Battery', 'Call', 'Camera', import RNS if RNS.vendor.platformutils.is_android(): from plyer.facades.accelerometer import Accelerometer + from plyer.facades.audio import Audio from plyer.facades.barometer import Barometer from plyer.facades.battery import Battery from plyer.facades.call import Call @@ -52,6 +53,7 @@ if RNS.vendor.platformutils.is_android(): from plyer.facades.devicename import DeviceName else: from sbapp.plyer.facades.accelerometer import Accelerometer + from sbapp.plyer.facades.audio import Audio from sbapp.plyer.facades.barometer import Barometer from sbapp.plyer.facades.battery import Battery from sbapp.plyer.facades.call import Call diff --git a/sbapp/plyer/facades/audio.py b/sbapp/plyer/facades/audio.py new file mode 100644 index 0000000..0394037 --- /dev/null +++ b/sbapp/plyer/facades/audio.py @@ -0,0 +1,104 @@ +''' +Audio +===== + +The :class:`Audio` is used for recording audio. + +Default path for recording is set in platform implementation. + +.. note:: + On Android the `RECORD_AUDIO`, `WAKE_LOCK` permissions are needed. + +Simple Examples +--------------- + +To get the file path:: + + >>> audio.file_path + '/sdcard/testrecorder.3gp' + +To set the file path:: + + >>> import os + >>> current_list = os.listdir('.') + ['/sdcard/testrecorder.3gp', '/sdcard/testrecorder1.3gp', + '/sdcard/testrecorder2.3gp', '/sdcard/testrecorder3.3gp'] + >>> file_path = current_list[2] + >>> audio.file_path = file_path + +To start recording:: + + >>> from plyer import audio + >>> audio.start() + +To stop recording:: + + >>> audio.stop() + +To play recording:: + + >>> audio.play() + +Supported Platforms +------------------- +Android, Windows, macOS + +''' + + +class Audio: + ''' + Audio facade. + ''' + + state = 'ready' + _file_path = '' + + def __init__(self, file_path=None): + super().__init__() + self._file_path = file_path or self._file_path + + def start(self): + ''' + Start record. + ''' + self._start() + self.state = 'recording' + + def stop(self): + ''' + Stop record. + ''' + self._stop() + self.state = 'ready' + + def play(self): + ''' + Play current recording. + ''' + self._play() + self.state = 'playing' + + @property + def file_path(self): + return self._file_path + + @file_path.setter + def file_path(self, location): + ''' + Location of the recording. + ''' + assert isinstance(location, str), 'Location must be string or unicode' + self._file_path = location + + # private + + def _start(self): + raise IOError("JUICE") + raise NotImplementedError() + + def _stop(self): + raise NotImplementedError() + + def _play(self): + raise NotImplementedError() diff --git a/sbapp/plyer/platforms/android/audio.py b/sbapp/plyer/platforms/android/audio.py new file mode 100644 index 0000000..33a0a0d --- /dev/null +++ b/sbapp/plyer/platforms/android/audio.py @@ -0,0 +1,108 @@ +import time +import threading +from jnius import autoclass + +from plyer.facades.audio import Audio + +# Recorder Classes +MediaRecorder = autoclass('android.media.MediaRecorder') +AudioSource = autoclass('android.media.MediaRecorder$AudioSource') +OutputFormat = autoclass('android.media.MediaRecorder$OutputFormat') +AudioEncoder = autoclass('android.media.MediaRecorder$AudioEncoder') + +# Player Classes +MediaPlayer = autoclass('android.media.MediaPlayer') + + +class AndroidAudio(Audio): + '''Audio for android. + + For recording audio we use MediaRecorder Android class. + For playing audio we use MediaPlayer Android class. + ''' + + def __init__(self, file_path=None): + default_path = None + super().__init__(file_path or default_path) + + self._recorder = None + self._player = None + self._check_thread = None + self._finished_callback = None + self._format = "opus" + self.is_playing = False + + def _check_playback(self): + while self._player and self._player.isPlaying(): + time.sleep(0.25) + + self.is_playing = False + + if self._finished_callback and callable(self._finished_callback): + self._check_thread = None + self._finished_callback(self) + + + def _start(self): + self._recorder = MediaRecorder() + if self._format == "aac": + self._recorder.setAudioSource(AudioSource.DEFAULT) + self._recorder.setAudioSamplingRate(48000) + self._recorder.setAudioEncodingBitRate(64000) + self._recorder.setAudioChannels(1) + self._recorder.setOutputFormat(OutputFormat.MPEG_4) + self._recorder.setAudioEncoder(AudioEncoder.AAC) + + else: + self._recorder.setAudioSource(AudioSource.DEFAULT) + self._recorder.setAudioSamplingRate(48000) + self._recorder.setAudioEncodingBitRate(12000) + self._recorder.setAudioChannels(1) + self._recorder.setOutputFormat(OutputFormat.OGG) + self._recorder.setAudioEncoder(AudioEncoder.OPUS) + + self._recorder.setOutputFile(self.file_path) + + self._recorder.prepare() + self._recorder.start() + + def _stop(self): + if self._recorder: + try: + self._recorder.stop() + self._recorder.release() + except Exception as e: + print("Could not stop recording: "+str(e)) + + self._recorder = None + + if self._player: + try: + self._player.stop() + self._player.release() + except Exception as e: + print("Could not stop playback: "+str(e)) + + self._player = None + + self.is_playing = False + + def _play(self): + self._player = MediaPlayer() + self._player.setDataSource(self.file_path) + self._player.prepare() + self._player.start() + self.is_playing = True + + self._check_thread = threading.Thread(target=self._check_playback, daemon=True) + self._check_thread.start() + + def reload(self): + self._stop() + + def playing(self): + return self.is_playing + + +def instance(): + return AndroidAudio() diff --git a/libs/able/able/android/__init__.py b/sbapp/plyer/platforms/ios/__init__.py similarity index 100% rename from libs/able/able/android/__init__.py rename to sbapp/plyer/platforms/ios/__init__.py diff --git a/sbapp/plyer/platforms/ios/accelerometer.py b/sbapp/plyer/platforms/ios/accelerometer.py new file mode 100644 index 0000000..0f61f2b --- /dev/null +++ b/sbapp/plyer/platforms/ios/accelerometer.py @@ -0,0 +1,34 @@ +''' +iOS accelerometer +----------------- + +Taken from: http://pyobjus.readthedocs.org/en/latest/pyobjus_ios.html \ + #accessing-accelerometer +''' + +from sbapp.plyer.facades import Accelerometer +from pyobjus import autoclass + + +class IosAccelerometer(Accelerometer): + + def __init__(self): + super().__init__() + self.bridge = autoclass('bridge').alloc().init() + self.bridge.motionManager.setAccelerometerUpdateInterval_(0.1) + + def _enable(self): + self.bridge.startAccelerometer() + + def _disable(self): + self.bridge.stopAccelerometer() + + def _get_acceleration(self): + return ( + self.bridge.ac_x, + self.bridge.ac_y, + self.bridge.ac_z) + + +def instance(): + return IosAccelerometer() diff --git a/sbapp/plyer/platforms/ios/barometer.py b/sbapp/plyer/platforms/ios/barometer.py new file mode 100644 index 0000000..9ec8d6e --- /dev/null +++ b/sbapp/plyer/platforms/ios/barometer.py @@ -0,0 +1,31 @@ +''' +iOS Barometer +------------- +''' + +from sbapp.plyer.facades import Barometer +from pyobjus import autoclass + + +class iOSBarometer(Barometer): + + def __init__(self): + super().__init__() + self.bridge = autoclass('bridge').alloc().init() + + def _enable(self): + self.bridge.startRelativeAltitude() + + def _disable(self): + self.bridge.stopRelativeAltitude() + + def _get_pressure(self): + ''' + 1 kPa = 10 hPa + ''' + return ( + self.bridge.pressure * 10) + + +def instance(): + return iOSBarometer() diff --git a/sbapp/plyer/platforms/ios/battery.py b/sbapp/plyer/platforms/ios/battery.py new file mode 100644 index 0000000..d7fa5d8 --- /dev/null +++ b/sbapp/plyer/platforms/ios/battery.py @@ -0,0 +1,47 @@ +''' +Module of iOS API for plyer.battery. +''' + +from pyobjus import autoclass +from pyobjus.dylib_manager import load_framework +from sbapp.plyer.facades import Battery + +load_framework('/System/Library/Frameworks/UIKit.framework') +UIDevice = autoclass('UIDevice') + + +class IOSBattery(Battery): + ''' + Implementation of iOS battery API. + ''' + + def __init__(self): + super().__init__() + self.device = UIDevice.currentDevice() + + def _get_state(self): + status = {"isCharging": None, "percentage": None} + + if not self.device.batteryMonitoringEnabled: + self.device.setBatteryMonitoringEnabled_(True) + + if self.device.batteryState == 0: + is_charging = None + elif self.device.batteryState == 2: + is_charging = True + else: + is_charging = False + + percentage = self.device.batteryLevel * 100. + + status['isCharging'] = is_charging + status['percentage'] = percentage + + return status + + +def instance(): + ''' + Instance for facade proxy. + ''' + return IOSBattery() diff --git a/sbapp/plyer/platforms/ios/brightness.py b/sbapp/plyer/platforms/ios/brightness.py new file mode 100644 index 0000000..f4a70f4 --- /dev/null +++ b/sbapp/plyer/platforms/ios/brightness.py @@ -0,0 +1,27 @@ +''' +iOS Brightness +-------------- +''' + +from pyobjus import autoclass +from sbapp.plyer.facades import Brightness +from pyobjus.dylib_manager import load_framework + +load_framework('/System/Library/Frameworks/UIKit.framework') +UIScreen = autoclass('UIScreen') + + +class iOSBrightness(Brightness): + + def __init__(self): + self.screen = UIScreen.mainScreen() + + def _current_level(self): + return self.screen.brightness * 100 + + def set_level(self, level): + self.screen.brightness = level / 100 + + +def instance(): + return iOSBrightness() diff --git a/sbapp/plyer/platforms/ios/call.py b/sbapp/plyer/platforms/ios/call.py new file mode 100644 index 0000000..9999d6a --- /dev/null +++ b/sbapp/plyer/platforms/ios/call.py @@ -0,0 +1,29 @@ +''' +IOS Call +---------- +''' + +from sbapp.plyer.facades import Call +from pyobjus import autoclass, objc_str + +NSURL = autoclass('NSURL') +NSString = autoclass('NSString') +UIApplication = autoclass('UIApplication') + + +class IOSCall(Call): + + def _makecall(self, **kwargs): + tel = kwargs.get('tel') + url = "tel://" + tel + nsurl = NSURL.alloc().initWithString_(objc_str(url)) + + UIApplication.sharedApplication().openURL_(nsurl) + + def _dialcall(self, **kwargs): + pass + # Not possible, Access not provided by iPhone SDK + + +def instance(): + return IOSCall() diff --git a/sbapp/plyer/platforms/ios/camera.py b/sbapp/plyer/platforms/ios/camera.py new file mode 100644 index 0000000..a61bd96 --- /dev/null +++ b/sbapp/plyer/platforms/ios/camera.py @@ -0,0 +1,52 @@ +from os import remove +from sbapp.plyer.facades import Camera + +from sbapp.plyer.utils import reify + + +class iOSCamera(Camera): + + @reify + def photos(self): + # pyPhotoLibrary is a ios recipe/module that + # interacts with the gallery and the camera on ios. + from photolibrary import PhotosLibrary + return PhotosLibrary() + + def _take_picture(self, on_complete, filename=None): + assert on_complete is not None + self.on_complete = on_complete + self.filename = filename + photos = self.photos + + if not photos.isCameraAvailable(): + # no camera hardware + return False + + photos.bind(on_image_captured=self.capture_callback) + self._capture_filename = filename + photos.capture_image(filename) + return True + + def capture_callback(self, photolibrary): + # Image was chosen + + # unbind + self.photos.unbind(on_image_captured=self.capture_callback) + + if self.on_complete(self.filename): + self._remove(self.filename) + + def _take_video(self, on_complete, filename=None): + assert on_complete is not None + raise NotImplementedError + + def _remove(self, fn): + try: + remove(fn) + except OSError: + print('Could not remove photo!') + + +def instance(): + return iOSCamera() diff --git a/sbapp/plyer/platforms/ios/compass.py b/sbapp/plyer/platforms/ios/compass.py new file mode 100644 index 0000000..a5c865f --- /dev/null +++ b/sbapp/plyer/platforms/ios/compass.py @@ -0,0 +1,43 @@ +''' +iOS Compass +----------- +''' + +from sbapp.plyer.facades import Compass +from pyobjus import autoclass + + +class IosCompass(Compass): + + def __init__(self): + super().__init__() + self.bridge = autoclass('bridge').alloc().init() + self.bridge.motionManager.setMagnetometerUpdateInterval_(0.1) + self.bridge.motionManager.setDeviceMotionUpdateInterval_(0.1) + + def _enable(self): + self.bridge.startMagnetometer() + self.bridge.startDeviceMotionWithReferenceFrame() + + def _disable(self): + self.bridge.stopMagnetometer() + self.bridge.stopDeviceMotion() + + def _get_orientation(self): + return ( + self.bridge.mf_x, + self.bridge.mf_y, + self.bridge.mf_z) + + def _get_field_uncalib(self): + return ( + self.bridge.mg_x, + self.bridge.mg_y, + self.bridge.mg_z, + self.bridge.mg_x - self.bridge.mf_x, + self.bridge.mg_y - self.bridge.mf_y, + self.bridge.mg_z - self.bridge.mf_z) + + +def instance(): + return IosCompass() diff --git a/sbapp/plyer/platforms/ios/email.py b/sbapp/plyer/platforms/ios/email.py new file mode 100644 index 0000000..ec54860 --- /dev/null +++ b/sbapp/plyer/platforms/ios/email.py @@ -0,0 +1,52 @@ +''' +Module of iOS API for plyer.email. +''' + +try: + from urllib.parse import quote +except ImportError: + from urllib import quote + +from sbapp.plyer.facades import Email +from pyobjus import autoclass, objc_str +from pyobjus.dylib_manager import load_framework + +load_framework('/System/Library/Frameworks/UIKit.framework') + +NSURL = autoclass('NSURL') +NSString = autoclass('NSString') +UIApplication = autoclass('UIApplication') + + +class IOSEmail(Email): + ''' + Implementation of iOS battery API. + ''' + + def _send(self, **kwargs): + recipient = kwargs.get('recipient') + subject = kwargs.get('subject') + text = kwargs.get('text') + + uri = "mailto:" + if recipient: + uri += str(recipient) + if subject: + uri += "?" if "?" not in uri else "&" + uri += "subject=" + uri += quote(str(subject)) + if text: + uri += "?" if "?" not in uri else "&" + uri += "body=" + uri += quote(str(text)) + + nsurl = NSURL.alloc().initWithString_(objc_str(uri)) + + UIApplication.sharedApplication().openURL_(nsurl) + + +def instance(): + ''' + Instance for facade proxy. + ''' + return IOSEmail() diff --git a/sbapp/plyer/platforms/ios/filechooser.py b/sbapp/plyer/platforms/ios/filechooser.py new file mode 100644 index 0000000..0320d6c --- /dev/null +++ b/sbapp/plyer/platforms/ios/filechooser.py @@ -0,0 +1,81 @@ +''' +IOS file chooser +-------------------- + +This module houses the iOS implementation of the plyer FileChooser. + +.. versionadded:: 1.4.4 +''' + +from sbapp.plyer.facades import FileChooser +from pyobjus import autoclass, protocol +from pyobjus.dylib_manager import load_framework + + +load_framework('/System/Library/Frameworks/Photos.framework') + + +class IOSFileChooser(FileChooser): + ''' + FileChooser implementation for IOS using + the built-in file browser via UIImagePickerController. + + .. versionadded:: 1.4.0 + ''' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._on_selection = None + + def _file_selection_dialog(self, *args, **kwargs): + """ + Function called when action is required, A "mode" parameter specifies + which and is one of "open", "save" or "dir". + """ + self._on_selection = kwargs["on_selection"] + if kwargs["mode"] == "open": + self._open() + else: + raise NotImplementedError() + + def _get_picker(self): + """ + Return an instantiated and configured UIImagePickerController. + """ + picker = autoclass("UIImagePickerController") + po = picker.alloc().init() + po.sourceType = 0 + po.delegate = self + return po + + def _open(self): + """ + Launch the native iOS file browser. Upon selection, the + `imagePickerController_didFinishPickingMediaWithInfo_` delegate is + called where we close the file browser and handle the result. + """ + picker = self._get_picker() + UIApplication = autoclass('UIApplication') + vc = UIApplication.sharedApplication().keyWindow.rootViewController() + vc.presentViewController_animated_completion_(picker, True, None) + + @protocol('UIImagePickerControllerDelegate') + def imagePickerController_didFinishPickingMediaWithInfo_( + self, image_picker, frozen_dict): + """ + Delegate which handles the result of the image selection process. + """ + image_picker.dismissViewControllerAnimated_completion_(True, None) + + # Note: We need to call this Objective C class as there is currently + # no way to call a non-class function via pyobjus. And here, + # we have to use the `UIImagePNGRepresentation` to get the png + # representation. For this, please ensure you are using an + # appropriate version of kivy-ios. + native_image_picker = autoclass("NativeImagePicker").alloc().init() + path = native_image_picker.writeToPNG_(frozen_dict) + self._on_selection([path.UTF8String()]) + + +def instance(): + return IOSFileChooser() diff --git a/sbapp/plyer/platforms/ios/flash.py b/sbapp/plyer/platforms/ios/flash.py new file mode 100644 index 0000000..17f59f1 --- /dev/null +++ b/sbapp/plyer/platforms/ios/flash.py @@ -0,0 +1,50 @@ +# coding=utf-8 +""" +Flash +----- +""" +from sbapp.plyer.facades import Flash +from pyobjus import autoclass + +NSString = autoclass("NSString") +AVCaptureDevice = autoclass("AVCaptureDevice") +AVMediaTypeVideo = NSString.alloc().initWithUTF8String_("vide") +AVCaptureTorchModeOff = 0 +AVCaptureTorchModeOn = 1 + + +class IosFlash(Flash): + _camera = None + + def _on(self): + if self._camera is None: + self._camera_open() + if not self._camera: + return + self._camera.lockForConfiguration_(None) + try: + self._camera.setTorchMode(AVCaptureTorchModeOn) + finally: + self._camera.unlockForConfiguration() + + def _off(self): + if not self._camera: + return + self._camera.lockForConfiguration_(None) + try: + self._camera.setTorchMode(AVCaptureTorchModeOff) + finally: + self._camera.unlockForConfiguration() + + def _release(self): + pass + + def _camera_open(self): + device = AVCaptureDevice.defaultDeviceWithMediaType_(AVMediaTypeVideo) + if not device: + return + self._camera = device + + +def instance(): + return IosFlash() diff --git a/sbapp/plyer/platforms/ios/gps.py b/sbapp/plyer/platforms/ios/gps.py new file mode 100644 index 0000000..d7ee0a7 --- /dev/null +++ b/sbapp/plyer/platforms/ios/gps.py @@ -0,0 +1,80 @@ +''' +iOS GPS +----------- +''' + +from pyobjus import autoclass, protocol +from pyobjus.dylib_manager import load_framework +from sbapp.plyer.facades import GPS + +load_framework('/System/Library/Frameworks/CoreLocation.framework') +CLLocationManager = autoclass('CLLocationManager') + + +class IosGPS(GPS): + def _configure(self): + if not hasattr(self, '_location_manager'): + self._location_manager = CLLocationManager.alloc().init() + + def _start(self, **kwargs): + self._location_manager.delegate = self + + self._location_manager.requestWhenInUseAuthorization() + # NSLocationWhenInUseUsageDescription key must exist in Info.plist + # file. When the authorization prompt is displayed your app goes + # into pause mode and if your app doesn't support background mode + # it will crash. + self._location_manager.startUpdatingLocation() + + def _stop(self): + self._location_manager.stopUpdatingLocation() + + @protocol('CLLocationManagerDelegate') + def locationManager_didChangeAuthorizationStatus_(self, manager, status): + if self.on_status: + s_status = '' + provider_status = '' + provider = 'standard-ios-provider' + if status == 0: + provider_status = 'provider-disabled' + s_status = 'notDetermined' + elif status == 1: + provider_status = 'provider-enabled' + s_status = 'restricted' + elif status == 2: + provider_status = 'provider-disabled' + s_status = 'denied' + elif status == 3: + provider_status = 'provider-enabled' + s_status = 'authorizedAlways' + elif status == 4: + provider_status = 'provider-enabled' + s_status = 'authorizedWhenInUse' + self.on_status(provider_status, '{}: {}'.format( + provider, s_status)) + + @protocol('CLLocationManagerDelegate') + def locationManager_didUpdateLocations_(self, manager, locations): + location = manager.location + + description = location.description.UTF8String() + split_description = description.split('<')[-1].split('>')[0].split(',') + + lat, lon = [float(coord) for coord in split_description] + acc = float(description.split(' +/- ')[-1].split('m ')[0]) + + speed = location.speed + altitude = location.altitude + course = location.course + + self.on_location( + lat=lat, + lon=lon, + speed=speed, + bearing=course, + altitude=altitude, + accuracy=acc) + + +def instance(): + return IosGPS() diff --git a/sbapp/plyer/platforms/ios/gravity.py b/sbapp/plyer/platforms/ios/gravity.py new file mode 100644 index 0000000..9452bda --- /dev/null +++ b/sbapp/plyer/platforms/ios/gravity.py @@ -0,0 +1,31 @@ +''' +iOS Gravity +----------- + +''' + +from sbapp.plyer.facades import Gravity +from pyobjus import autoclass + + +class iOSGravity(Gravity): + + def __init__(self): + self.bridge = autoclass('bridge').alloc().init() + self.bridge.motionManager.setDeviceMotionUpdateInterval_(0.1) + + def _enable(self): + self.bridge.startDeviceMotion() + + def _disable(self): + self.bridge.stopDeviceMotion() + + def _get_gravity(self): + return ( + self.bridge.g_x, + self.bridge.g_y, + self.bridge.g_z) + + +def instance(): + return iOSGravity() diff --git a/sbapp/plyer/platforms/ios/gyroscope.py b/sbapp/plyer/platforms/ios/gyroscope.py new file mode 100644 index 0000000..367e1bf --- /dev/null +++ b/sbapp/plyer/platforms/ios/gyroscope.py @@ -0,0 +1,55 @@ +''' +iOS Gyroscope +--------------------- +''' + +from sbapp.plyer.facades import Gyroscope +from pyobjus import autoclass + +from pyobjus.dylib_manager import load_framework + +load_framework('/System/Library/Frameworks/UIKit.framework') +UIDevice = autoclass('UIDevice') + +device = UIDevice.currentDevice() + + +class IosGyroscope(Gyroscope): + + def __init__(self): + super().__init__() + self.bridge = autoclass('bridge').alloc().init() + + if int(device.systemVersion.UTF8String().split('.')[0]) <= 4: + self.bridge.motionManager.setGyroscopeUpdateInterval_(0.1) + else: + self.bridge.motionManager.setGyroUpdateInterval_(0.1) + + self.bridge.motionManager.setDeviceMotionUpdateInterval_(0.1) + + def _enable(self): + self.bridge.startGyroscope() + self.bridge.startDeviceMotion() + + def _disable(self): + self.bridge.stopGyroscope() + self.bridge.stopDeviceMotion() + + def _get_orientation(self): + return ( + self.bridge.rotation_rate_x, + self.bridge.rotation_rate_y, + self.bridge.rotation_rate_z) + + def _get_rotation_uncalib(self): + return ( + self.bridge.gy_x, + self.bridge.gy_y, + self.bridge.gy_z, + self.bridge.gy_x - self.bridge.rotation_rate_x, + self.bridge.gy_y - self.bridge.rotation_rate_y, + self.bridge.gy_z - self.bridge.rotation_rate_z) + + +def instance(): + return IosGyroscope() diff --git a/sbapp/plyer/platforms/ios/keystore.py b/sbapp/plyer/platforms/ios/keystore.py new file mode 100644 index 0000000..a18cde2 --- /dev/null +++ b/sbapp/plyer/platforms/ios/keystore.py @@ -0,0 +1,23 @@ +from sbapp.plyer.facades import Keystore +from pyobjus import autoclass, objc_str + +NSUserDefaults = autoclass('NSUserDefaults') + + +class IosKeystore(Keystore): + + def _set_key(self, servicename, key, value, **kwargs): + NSUserDefaults.standardUserDefaults().setObject_forKey_( + objc_str(value), objc_str(key)) + + def _get_key(self, servicename, key, **kwargs): + ret = NSUserDefaults.standardUserDefaults().stringForKey_( + objc_str(key)) + if ret is not None: + return ret.UTF8String() + else: + return ret + + +def instance(): + return IosKeystore() diff --git a/sbapp/plyer/platforms/ios/maps.py b/sbapp/plyer/platforms/ios/maps.py new file mode 100644 index 0000000..dac01d5 --- /dev/null +++ b/sbapp/plyer/platforms/ios/maps.py @@ -0,0 +1,78 @@ +''' +Module of iOS API for plyer.maps. +''' + +import webbrowser +from sbapp.plyer.facades import Maps +from urllib.parse import quote_plus + + +class iOSMaps(Maps): + ''' + Implementation of iOS Maps API. + ''' + + def _open_by_address(self, address, **kwargs): + ''' + :param address: An address string that geolocation can understand. + ''' + + address = quote_plus(address, safe=',') + maps_address = 'http://maps.apple.com/?address=' + address + + webbrowser.open(maps_address) + + def _open_by_lat_long(self, latitude, longitude, **kwargs): + ''' + Open a coordinate span denoting a latitudinal delta and a + longitudinal delta (similar to MKCoordinateSpan) + + :param name: (optional), will set the name of the dropped pin + ''' + + name = kwargs.get("name", "Selected Location") + maps_address = 'http://maps.apple.com/?ll={},{}&q={}'.format( + latitude, longitude, name) + + webbrowser.open(maps_address) + + def _search(self, query, **kwargs): + ''' + :param query: A string that describes the search object (ex. "Pizza") + + :param latitude: (optional), narrow down query within area, + MUST BE USED WITH LONGITUDE + + :param longitude: (optional), narrow down query within area, + MUST BE USED WITH LATITUDE + ''' + + latitude = kwargs.get('latitude') + longitude = kwargs.get('longitude') + + query = quote_plus(query, safe=',') + maps_address = 'http://maps.apple.com/?q=' + query + + if latitude is not None and longitude is not None: + maps_address += '&sll={},{}'.format(latitude, longitude) + + webbrowser.open(maps_address) + + def _route(self, saddr, daddr, **kwargs): + ''' + :param saddr: can be given as 'address' or 'lat,long' + :param daddr: can be given as 'address' or 'lat,long' + ''' + saddr = quote_plus(saddr, safe=',') + daddr = quote_plus(daddr, safe=',') + + maps_address = 'http://maps.apple.com/?saddr={}&daddr={}'.format( + saddr, daddr) + webbrowser.open(maps_address) + + +def instance(): + ''' + Instance for facade proxy. + ''' + return iOSMaps() diff --git a/sbapp/plyer/platforms/ios/sms.py b/sbapp/plyer/platforms/ios/sms.py new file mode 100644 index 0000000..fc6b1b4 --- /dev/null +++ b/sbapp/plyer/platforms/ios/sms.py @@ -0,0 +1,43 @@ +''' +IOS Sms +---------- +''' + +from sbapp.plyer.facades import Sms +from pyobjus import autoclass, objc_str +from pyobjus.dylib_manager import load_framework + +NSURL = autoclass('NSURL') +NSString = autoclass('NSString') +UIApplication = autoclass('UIApplication') +load_framework('/System/Library/Frameworks/MessageUI.framework') + + +class IOSSms(Sms): + + def _send(self, **kwargs): + ''' + This method provides sending messages to recipients. + + Expects 2 parameters in kwargs: + - recipient: String type + - message: String type + + Opens a message interface with recipient and message information. + ''' + recipient = kwargs.get('recipient') + message = kwargs.get('message') + url = "sms:" + if recipient: + # Apple has not supported multiple recipients yet. + url += str(recipient) + if message: + # Apple has to supported it yet. + pass + + nsurl = NSURL.alloc().initWithString_(objc_str(url)) + UIApplication.sharedApplication().openURL_(nsurl) + + +def instance(): + return IOSSms() diff --git a/sbapp/plyer/platforms/ios/spatialorientation.py b/sbapp/plyer/platforms/ios/spatialorientation.py new file mode 100644 index 0000000..7c6d9cc --- /dev/null +++ b/sbapp/plyer/platforms/ios/spatialorientation.py @@ -0,0 +1,31 @@ +''' +iOS Spatial Orientation +----------------------- + +''' + +from sbapp.plyer.facades import SpatialOrientation +from pyobjus import autoclass + + +class iOSSpatialOrientation(SpatialOrientation): + + def __init__(self): + self.bridge = autoclass('bridge').alloc().init() + self.bridge.motionManager.setDeviceMotionUpdateInterval_(0.1) + + def _enable_listener(self): + self.bridge.startDeviceMotion() + + def _disable_listener(self): + self.bridge.stopDeviceMotion() + + def _get_orientation(self): + return ( + self.bridge.sp_yaw, + self.bridge.sp_pitch, + self.bridge.sp_roll) + + +def instance(): + return iOSSpatialOrientation() diff --git a/sbapp/plyer/platforms/ios/storagepath.py b/sbapp/plyer/platforms/ios/storagepath.py new file mode 100644 index 0000000..c69a083 --- /dev/null +++ b/sbapp/plyer/platforms/ios/storagepath.py @@ -0,0 +1,62 @@ +''' +iOS Storage Path +-------------------- +''' + +from sbapp.plyer.facades import StoragePath +from pyobjus import autoclass +import os + +NSFileManager = autoclass('NSFileManager') + +# Directory constants (NSSearchPathDirectory enumeration) +NSApplicationDirectory = 1 +NSDocumentDirectory = 9 +NSDownloadsDirectory = 15 +NSMoviesDirectory = 17 +NSMusicDirectory = 18 +NSPicturesDirectory = 19 + + +class iOSStoragePath(StoragePath): + + def __init__(self): + self.defaultManager = NSFileManager.defaultManager() + + def _get_home_dir(self): + return os.path.expanduser('~/') + + def _get_external_storage_dir(self): + return 'This feature is not implemented for this platform.' + + def _get_root_dir(self): + return 'This feature is not implemented for this platform.' + + def _get_documents_dir(self): + return self.defaultManager.URLsForDirectory_inDomains_( + NSDocumentDirectory, 1).firstObject().absoluteString.UTF8String() + + def _get_downloads_dir(self): + return self.defaultManager.URLsForDirectory_inDomains_( + NSDownloadsDirectory, 1).firstObject().absoluteString.UTF8String() + + def _get_videos_dir(self): + return self.defaultManager.URLsForDirectory_inDomains_( + NSMoviesDirectory, 1).firstObject().absoluteString.UTF8String() + + def _get_music_dir(self): + return self.defaultManager.URLsForDirectory_inDomains_( + NSMusicDirectory, 1).firstObject().absoluteString.UTF8String() + + def _get_pictures_dir(self): + return self.defaultManager.URLsForDirectory_inDomains_( + NSPicturesDirectory, 1).firstObject().absoluteString.UTF8String() + + def _get_application_dir(self): + return self.defaultManager.URLsForDirectory_inDomains_( + NSApplicationDirectory, 1).firstObject().absoluteString.\ + UTF8String() + + +def instance(): + return iOSStoragePath() diff --git a/sbapp/plyer/platforms/ios/tts.py b/sbapp/plyer/platforms/ios/tts.py new file mode 100644 index 0000000..510399d --- /dev/null +++ b/sbapp/plyer/platforms/ios/tts.py @@ -0,0 +1,37 @@ +from pyobjus import autoclass, objc_str +from pyobjus.dylib_manager import load_framework + +from sbapp.plyer.facades import TTS + +load_framework('/System/Library/Frameworks/AVFoundation.framework') +AVSpeechUtterance = autoclass('AVSpeechUtterance') +AVSpeechSynthesizer = autoclass('AVSpeechSynthesizer') +AVSpeechSynthesisVoice = autoclass('AVSpeechSynthesisVoice') + + +class iOSTextToSpeech(TTS): + def __init__(self): + super().__init__() + self.synth = AVSpeechSynthesizer.alloc().init() + self.voice = None + + def _set_locale(self, locale="en-US"): + self.voice = AVSpeechSynthesisVoice.voiceWithLanguage_( + objc_str(locale) + ) + + def _speak(self, **kwargs): + message = kwargs.get('message') + + if not self.voice: + self._set_locale() + + utterance = \ + AVSpeechUtterance.speechUtteranceWithString_(objc_str(message)) + + utterance.voice = self.voice + self.synth.speakUtterance_(utterance) + + +def instance(): + return iOSTextToSpeech() diff --git a/sbapp/plyer/platforms/ios/uniqueid.py b/sbapp/plyer/platforms/ios/uniqueid.py new file mode 100644 index 0000000..e9fa815 --- /dev/null +++ b/sbapp/plyer/platforms/ios/uniqueid.py @@ -0,0 +1,27 @@ +''' +Module of iOS API for plyer.uniqueid. +''' + +from pyobjus import autoclass +from pyobjus.dylib_manager import load_framework +from sbapp.plyer.facades import UniqueID + +load_framework('/System/Library/Frameworks/UIKit.framework') +UIDevice = autoclass('UIDevice') + + +class IOSUniqueID(UniqueID): + ''' + Implementation of iOS uniqueid API. + ''' + + def _get_uid(self): + uuid = UIDevice.currentDevice().identifierForVendor.UUIDString() + return uuid.UTF8String() + + +def instance(): + ''' + Instance for facade proxy. + ''' + return IOSUniqueID() diff --git a/sbapp/plyer/platforms/ios/vibrator.py b/sbapp/plyer/platforms/ios/vibrator.py new file mode 100644 index 0000000..05c12c5 --- /dev/null +++ b/sbapp/plyer/platforms/ios/vibrator.py @@ -0,0 +1,43 @@ +'''Implementation Vibrator for iOS. + +Install: Add AudioToolbox framework to your application. +''' + +import ctypes +from sbapp.plyer.facades import Vibrator + + +class IosVibrator(Vibrator): + '''iOS Vibrator class. + + iOS doesn't support any feature. + All time, pattern, repetition are ignored. + ''' + + def __init__(self): + super().__init__() + try: + self._func = ctypes.CDLL(None).AudioServicesPlaySystemSound + except AttributeError: + self._func = None + + def _vibrate(self, time=None, **kwargs): + # kSystemSoundID_Vibrate is 0x00000FFF + self._func(0xFFF) + + def _pattern(self, pattern=None, repeat=None, **kwargs): + self._vibrate() + + def _exists(self, **kwargs): + return self._func is not None + + def _cancel(self, **kwargs): + pass + + +def instance(): + '''Returns Vibrator + + :return: instance of class IosVibrator + ''' + return IosVibrator() diff --git a/sbapp/plyer/platforms/linux/audio.py b/sbapp/plyer/platforms/linux/audio.py new file mode 100644 index 0000000..091e9e5 --- /dev/null +++ b/sbapp/plyer/platforms/linux/audio.py @@ -0,0 +1,139 @@ +import time +import threading +import RNS +import io +from sbapp.plyer.facades.audio import Audio +from ffpyplayer.player import MediaPlayer +from sbapp.pyogg import OpusFile, OpusBufferedEncoder, OggOpusWriter +import pyaudio + +class LinuxAudio(Audio): + + def __init__(self, file_path=None): + default_path = None + super().__init__(file_path or default_path) + + self._recorder = None + self._player = None + self._check_thread = None + self._finished_callback = None + self._loaded_path = None + self.sound = None + self.pa = None + self.is_playing = False + self.recorder = None + self.should_record = False + + def _check_playback(self): + run = True + while run and self.sound != None and not self.sound.get_pause(): + time.sleep(0.25) + if self.duration: + pts = self.sound.get_pts() + if pts > self.duration: + run = False + + self.is_playing = False + + if self._finished_callback and callable(self._finished_callback): + self._check_thread = None + self._finished_callback(self) + + def _record_job(self): + samples_per_second = self.default_rate; + bytes_per_sample = 2; frame_duration_ms = 20 + opus_buffered_encoder = OpusBufferedEncoder() + opus_buffered_encoder.set_application("voip") + opus_buffered_encoder.set_sampling_frequency(samples_per_second) + opus_buffered_encoder.set_channels(1) + opus_buffered_encoder.set_frame_size(frame_duration_ms) + ogg_opus_writer = OggOpusWriter(self._file_path, opus_buffered_encoder) + + frame_duration = frame_duration_ms/1000 + frame_size = int(frame_duration * samples_per_second) + bytes_per_frame = frame_size*bytes_per_sample + + read_bytes = 0 + pcm_buf = b"" + should_continue = True + while self.should_record and self.recorder: + samples_available = self.recorder.get_read_available() + bytes_available = samples_available*bytes_per_sample + if bytes_available > 0: + read_req = bytes_per_frame - len(pcm_buf) + read_n = min(bytes_available, read_req) + read_s = read_n//bytes_per_sample + rb = self.recorder.read(read_s); read_bytes += len(rb) + pcm_buf += rb + + if len(pcm_buf) == bytes_per_frame: + ogg_opus_writer.write(memoryview(bytearray(pcm_buf))) + # RNS.log("Wrote frame of "+str(len(pcm_buf))+", expected size "+str(bytes_per_frame)) + pcm_buf = b"" + + # Finish up anything left in buffer + time.sleep(frame_duration) + samples_available = self.recorder.get_read_available() + bytes_available = samples_available*bytes_per_sample + if bytes_available > 0: + read_req = bytes_per_frame - len(pcm_buf) + read_n = min(bytes_available, read_req) + read_s = read_n//bytes_per_sample + rb = self.recorder.read(read_s); read_bytes += len(rb) + pcm_buf += rb + + if len(pcm_buf) == bytes_per_frame: + ogg_opus_writer.write(memoryview(bytearray(pcm_buf))) + # RNS.log("Wrote frame of "+str(len(pcm_buf))+", expected size "+str(bytes_per_frame)) + pcm_buf = b"" + + ogg_opus_writer.close() + if self.recorder: + self.recorder.close() + + def _start(self): + self.should_record = True + if self.pa == None: + self.pa = pyaudio.PyAudio() + self.default_input_device = self.pa.get_default_input_device_info() + self.default_rate = 48000 + # self.default_rate = int(self.default_input_device["defaultSampleRate"]) + if self.recorder: + self.recorder.close() + self.recorder = None + self.recorder = self.pa.open(self.default_rate, 1, pyaudio.paInt16, input=True) + threading.Thread(target=self._record_job, daemon=True).start() + + def _stop(self): + if self.should_record == True: + self.should_record = False + + elif self.sound != None: + self.sound.set_pause(True) + self.sound.seek(0, relative=False) + self.is_playing = False + + def _play(self): + self.sound = MediaPlayer(self._file_path) + self.metadata = self.sound.get_metadata() + self.duration = self.metadata["duration"] + if self.duration == None: + time.sleep(0.15) + self.metadata = self.sound.get_metadata() + self.duration = self.metadata["duration"] + + self._loaded_path = self._file_path + self.is_playing = True + + self._check_thread = threading.Thread(target=self._check_playback, daemon=True) + self._check_thread.start() + + def reload(self): + self._loaded_path = None + + def playing(self): + return self.is_playing + + +def instance(): + return LinuxAudio() diff --git a/sbapp/plyer/platforms/macosx/audio.py b/sbapp/plyer/platforms/macosx/audio.py new file mode 100644 index 0000000..1f2069e --- /dev/null +++ b/sbapp/plyer/platforms/macosx/audio.py @@ -0,0 +1,128 @@ +from os.path import join + +from pyobjus import autoclass +from pyobjus.dylib_manager import INCLUDE, load_framework + +from sbapp.plyer.facades import Audio +from sbapp.plyer.platforms.macosx.storagepath import OSXStoragePath + +import threading + +load_framework(INCLUDE.Foundation) +load_framework(INCLUDE.AVFoundation) + +AVAudioPlayer = autoclass("AVAudioPlayer") +AVAudioRecorder = autoclass("AVAudioRecorder") +AVAudioFormat = autoclass("AVAudioFormat") +NSString = autoclass('NSString') +NSURL = autoclass('NSURL') +NSError = autoclass('NSError').alloc() + + +class OSXAudio(Audio): + def __init__(self, file_path=None): + default_path = None + super().__init__(file_path or default_path) + + self._recorder = None + self._player = None + self._current_file = None + + self._check_thread = None + self._finished_callback = None + self._loaded_path = None + self.is_playing = False + self.sound = None + self.pa = None + self.is_playing = False + self.recorder = None + self.should_record = False + + def _check_playback(self): + while self._player and self._player.isPlaying: + time.sleep(0.25) + + if self._finished_callback and callable(self._finished_callback): + self._check_thread = None + self._finished_callback(self) + + def _start(self): + # Conversion of Python file path string to Objective-C NSString + file_path_NSString = NSString.alloc() + file_path_NSString = file_path_NSString.initWithUTF8String_( + self._file_path + ) + + # Definition of Objective-C NSURL object for the output record file + # specified by NSString file path + file_NSURL = NSURL.alloc() + file_NSURL = file_NSURL.initWithString_(file_path_NSString) + + # Internal audio file format specification + af = AVAudioFormat.alloc() + af = af.initWithCommonFormat_sampleRate_channels_interleaved_( + 1, 44100.0, 1, True + ) + + # Audio recorder instance initialization with specified file NSURL + # and audio file format + self._recorder = AVAudioRecorder.alloc() + self._recorder = self._recorder.initWithURL_format_error_( + file_NSURL, af, NSError + ) + + if not self._recorder: + raise Exception(NSError.code, NSError.domain) + + self._recorder.record() + + # Setting the currently recorded file as current file + # for using it as a parameter in audio player + self._current_file = file_NSURL + + def _stop(self): + if self._recorder: + self._recorder.stop() + self._recorder = None + + if self._player: + self._player.stop() + self._player = None + + def _play(self): + # Conversion of Python file path string to Objective-C NSString + file_path_NSString = NSString.alloc() + file_path_NSString = file_path_NSString.initWithUTF8String_( + self._file_path + ) + + # Definition of Objective-C NSURL object for the output record file + # specified by NSString file path + file_NSURL = NSURL.alloc() + file_NSURL = file_NSURL.initWithString_(file_path_NSString) + self._current_file = file_NSURL + + # Audio player instance initialization with the file NSURL + # of the last recorded audio file + self._player = AVAudioPlayer.alloc() + self._player = self._player.initWithContentsOfURL_error_( + self._current_file, NSError + ) + + if not self._player: + raise Exception(NSError.code, NSError.domain) + + self._player.play() + + self._check_thread = threading.Thread(target=self._check_playback, daemon=True) + self._check_thread.start() + + def reload(self): + self._loaded_path = None + + def playing(self): + return self.is_playing + + +def instance(): + return OSXAudio() diff --git a/sbapp/plyer/platforms/win/audio.py b/sbapp/plyer/platforms/win/audio.py new file mode 100644 index 0000000..c74d97d --- /dev/null +++ b/sbapp/plyer/platforms/win/audio.py @@ -0,0 +1,413 @@ +''' +Documentation: +http://docs.microsoft.com/en-us/windows/desktop/Multimedia + +.. versionadded:: 1.4.0 +''' + +from os.path import join + +from ctypes import windll +from ctypes import ( + sizeof, c_void_p, c_ulonglong, c_ulong, + c_wchar_p, byref, Structure, create_string_buffer +) +from ctypes.wintypes import DWORD, UINT + +from sbapp.plyer.facades import Audio +from sbapp.plyer.platforms.win.storagepath import WinStoragePath + +# DWORD_PTR i.e. ULONG_PTR, 32/64bit +ULONG_PTR = c_ulonglong if sizeof(c_void_p) == 8 else c_ulong + +# device specific symbols +MCI_OPEN = 0x803 +MCI_OPEN_TYPE = 0x2000 +MCI_OPEN_ELEMENT = 512 +MCI_RECORD = 0x80F +MCI_STOP = 0x808 +MCI_SAVE = 0x813 +MCI_PLAY = 0x806 +MCI_CLOSE = 0x804 + +# recorder specific symbols +MCI_FROM = 4 +MCI_TO = 8 +MCI_WAIT = 2 +MCI_SAVE_FILE = 256 + + +class MCI_OPEN_PARMS(Structure): + ''' + Struct for MCI_OPEN message parameters. + + .. versionadded:: 1.4.0 + ''' + + _fields_ = [ + ('mciOpenParms', ULONG_PTR), + ('wDeviceID', UINT), + ('lpstrDeviceType', c_wchar_p), + ('lpstrElementName', c_wchar_p), + ('lpstrAlias', c_wchar_p) + ] + + +class MCI_RECORD_PARMS(Structure): + ''' + Struct for MCI_RECORD message parameters. + + http://docs.microsoft.com/en-us/windows/desktop/Multimedia/mci-record-parms + + .. versionadded:: 1.4.0 + ''' + + _fields_ = [ + ('dwCallback', ULONG_PTR), + ('dwFrom', DWORD), + ('dwTo', DWORD) + ] + + +class MCI_SAVE_PARMS(Structure): + ''' + Struct for MCI_SAVE message parameters. + + http://docs.microsoft.com/en-us/windows/desktop/Multimedia/mci-save-parms + + .. versionadded:: 1.4.0 + ''' + + _fields_ = [ + ('dwCallback', ULONG_PTR), + ('lpfilename', c_wchar_p) + ] + + +class MCI_PLAY_PARMS(Structure): + ''' + Struct for MCI_PLAY message parameters. + + http://docs.microsoft.com/en-us/windows/desktop/Multimedia/mci-play-parms + + .. versionadded:: 1.4.0 + ''' + + _fields_ = [ + ('dwCallback', ULONG_PTR), + ('dwFrom', DWORD), + ('dwTo', DWORD) + ] + + +def send_command(device, msg, flags, params): + ''' + Generic mciSendCommandW() wrapper with error handler. + All parameters are required as for mciSendCommandW(). + In case of no `params` passed, use `None`, that value + won't be dereferenced. + + .. versionadded:: 1.4.0 + ''' + + multimedia = windll.winmm + send_command_w = multimedia.mciSendCommandW + get_error = multimedia.mciGetErrorStringW + + # error text buffer + # by API specification 128 is max, however the API sometimes + # kind of does not respect the documented bounds and returns + # more characters than buffer length...?! + error_len = 128 + + # big enough to prevent API accidentally segfaulting + error_text = create_string_buffer(error_len * 2) + + # open a recording device with a new file + error_code = send_command_w( + device, # device ID + msg, + flags, + + # reference to parameters structure or original value + # in case of params=False/0/None/... + byref(params) if params else params + ) + + # handle error messages if any + if error_code: + # device did not open, raise an exception + get_error(error_code, byref(error_text), error_len) + error_text = error_text.raw.replace(b'\x00', b'').decode('utf-8') + + # either it can close already open device or it will fail because + # the device is in non-closable state, but the end result is the same + # and it makes no sense to parse MCI_CLOSE's error in this case + send_command_w(device, MCI_CLOSE, 0, None) + raise Exception(error_code, error_text) + + # return params struct because some commands write into it + # to pass some values out of the local function scope + return params + + +class WinRecorder: + ''' + Generic wrapper for MCI_RECORD handling the filenames and device closing + in the same approach like it is used for other platforms. + + .. versionadded:: 1.4.0 + ''' + + def __init__(self, device, filename): + self._device = device + self._filename = filename + + @property + def device(self): + ''' + Public property returning device ID. + + .. versionadded:: 1.4.0 + ''' + return self._device + + @property + def filename(self): + ''' + Public property returning filename for current recording. + + .. versionadded:: 1.4.0 + ''' + return self._filename + + def record(self): + ''' + Start recording a WAV sound. + + .. versionadded:: 1.4.0 + ''' + send_command( + device=self.device, + msg=MCI_RECORD, + flags=0, + params=None + ) + + def stop(self): + ''' + Stop recording and save the data to a file path + self.filename. Wait until the file is written. + Close the device afterwards. + + .. versionadded:: 1.4.0 + ''' + + # stop the recording first + send_command( + device=self.device, + msg=MCI_STOP, + flags=MCI_WAIT, + params=None + ) + + # choose filename for the WAV file + save_params = MCI_SAVE_PARMS() + save_params.lpfilename = self.filename + + # save the sound data to a file and wait + # until it ends writing to the file + send_command( + device=self.device, + msg=MCI_SAVE, + flags=MCI_SAVE_FILE | MCI_WAIT, + params=save_params + ) + + # close the recording device + send_command( + device=self.device, + msg=MCI_CLOSE, + flags=0, + params=None + ) + + +class WinPlayer: + ''' + Generic wrapper for MCI_PLAY handling the device closing. + + .. versionadded:: 1.4.0 + ''' + + def __init__(self, device): + self._device = device + + @property + def device(self): + ''' + Public property returning device ID. + + .. versionadded:: 1.4.0 + ''' + return self._device + + def play(self): + ''' + Start playing a WAV sound. + + .. versionadded:: 1.4.0 + ''' + play_params = MCI_PLAY_PARMS() + play_params.dwFrom = 0 + + send_command( + device=self.device, + msg=MCI_PLAY, + flags=MCI_FROM, + params=play_params + ) + + def stop(self): + ''' + Stop playing a WAV sound and close the device. + + .. versionadded:: 1.4.0 + ''' + send_command( + device=self.device, + msg=MCI_STOP, + flags=MCI_WAIT, + params=None + ) + + # close the playing device + send_command( + device=self.device, + msg=MCI_CLOSE, + flags=0, + params=None + ) + + +class WinAudio(Audio): + ''' + Windows implementation of audio recording and audio playing. + + .. versionadded:: 1.4.0 + ''' + + def __init__(self, file_path=None): + # default path unless specified otherwise + default_path = join( + WinStoragePath().get_music_dir(), + 'audio.wav' + ) + super().__init__(file_path or default_path) + + self._recorder = None + self._player = None + self._current_file = None + self._check_thread = None + self._finished_callback = None + self._loaded_path = None + self.is_playing = False + self.sound = None + self.pa = None + self.is_playing = False + self.recorder = None + self.should_record = False + + def _start(self): + ''' + Start recording a WAV sound in the background asynchronously. + + .. versionadded:: 1.4.0 + ''' + + # clean everything before recording in case + # there is a different device open + self._stop() + + # create structure and set device parameters + open_params = MCI_OPEN_PARMS() + open_params.lpstrDeviceType = 'waveaudio' + open_params.lpstrElementName = '' + + # open a new device for recording + open_params = send_command( + device=0, # device ID before opening + msg=MCI_OPEN, + + # empty filename in lpstrElementName + # device type in lpstrDeviceType + flags=MCI_OPEN_ELEMENT | MCI_OPEN_TYPE, + params=open_params + ) + + # get recorder with device id and path for saving + self._recorder = WinRecorder( + device=open_params.wDeviceID, + filename=self._file_path + ) + self._recorder.record() + + # Setting the currently recorded file as current file + # for using it as a parameter in audio player + self._current_file = self._recorder.filename + + def _stop(self): + ''' + Stop recording or playing of a WAV sound. + + .. versionadded:: 1.4.0 + ''' + + if self._recorder: + self._recorder.stop() + self._recorder = None + + if self._player: + self._player.stop() + self._player = None + + def _play(self): + ''' + Play a WAV sound from a file. Prioritize latest recorded file before + default file path from WinAudio. + + .. versionadded:: 1.4.0 + ''' + + # create structure and set device parameters + open_params = MCI_OPEN_PARMS() + open_params.lpstrDeviceType = 'waveaudio' + open_params.lpstrElementName = self._current_file or self._file_path + + # open a new device for playing + open_params = send_command( + device=0, # device ID before opening + msg=MCI_OPEN, + + # existing filename in lpstrElementName + # device type in lpstrDeviceType + flags=MCI_OPEN_ELEMENT | MCI_OPEN_TYPE, + params=open_params + ) + + # get recorder with device id and path for saving + self._player = WinPlayer(device=open_params.wDeviceID) + self._player.play() + + def reload(self): + self._loaded_path = None + + def playing(self): + return self.is_playing + + +def instance(): + ''' + Instance for facade proxy. + ''' + return WinAudio() diff --git a/sbapp/pydub/__init__.py b/sbapp/pydub/__init__.py new file mode 100644 index 0000000..65e30b4 --- /dev/null +++ b/sbapp/pydub/__init__.py @@ -0,0 +1 @@ +from .audio_segment import AudioSegment \ No newline at end of file diff --git a/sbapp/pydub/audio_segment.py b/sbapp/pydub/audio_segment.py new file mode 100644 index 0000000..14ea46e --- /dev/null +++ b/sbapp/pydub/audio_segment.py @@ -0,0 +1,1399 @@ +from __future__ import division + +import array +import os +import subprocess +from tempfile import TemporaryFile, NamedTemporaryFile +import wave +import sys +import struct +from .logging_utils import log_conversion, log_subprocess_output +from .utils import mediainfo_json, fsdecode +import base64 +from collections import namedtuple + +try: + from StringIO import StringIO +except: + from io import StringIO + +from io import BytesIO + +try: + from itertools import izip +except: + izip = zip + +from .utils import ( + _fd_or_path_or_tempfile, + db_to_float, + ratio_to_db, + get_encoder_name, + get_array_type, + audioop, +) +from .exceptions import ( + TooManyMissingFrames, + InvalidDuration, + InvalidID3TagVersion, + InvalidTag, + CouldntDecodeError, + CouldntEncodeError, + MissingAudioParameter, +) + +if sys.version_info >= (3, 0): + basestring = str + xrange = range + StringIO = BytesIO + + +class ClassPropertyDescriptor(object): + + def __init__(self, fget, fset=None): + self.fget = fget + self.fset = fset + + def __get__(self, obj, klass=None): + if klass is None: + klass = type(obj) + return self.fget.__get__(obj, klass)() + + def __set__(self, obj, value): + if not self.fset: + raise AttributeError("can't set attribute") + type_ = type(obj) + return self.fset.__get__(obj, type_)(value) + + def setter(self, func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + self.fset = func + return self + + +def classproperty(func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + + return ClassPropertyDescriptor(func) + + +AUDIO_FILE_EXT_ALIASES = { + "m4a": "mp4", + "wave": "wav", +} + +WavSubChunk = namedtuple('WavSubChunk', ['id', 'position', 'size']) +WavData = namedtuple('WavData', ['audio_format', 'channels', 'sample_rate', + 'bits_per_sample', 'raw_data']) + + +def extract_wav_headers(data): + # def search_subchunk(data, subchunk_id): + pos = 12 # The size of the RIFF chunk descriptor + subchunks = [] + while pos + 8 <= len(data) and len(subchunks) < 10: + subchunk_id = data[pos:pos + 4] + subchunk_size = struct.unpack_from(' 2**32: + raise CouldntDecodeError("Unable to process >4GB files") + + # Set the file size in the RIFF chunk descriptor + data[4:8] = struct.pack(' b'\x7f'[0]]) + old_bytes = struct.pack(pack_fmt, b0, b1, b2) + byte_buffer.write(old_bytes) + + self._data = byte_buffer.getvalue() + self.sample_width = 4 + self.frame_width = self.channels * self.sample_width + + super(AudioSegment, self).__init__(*args, **kwargs) + + @property + def raw_data(self): + """ + public access to the raw audio data as a bytestring + """ + return self._data + + def get_array_of_samples(self, array_type_override=None): + """ + returns the raw_data as an array of samples + """ + if array_type_override is None: + array_type_override = self.array_type + return array.array(array_type_override, self._data) + + @property + def array_type(self): + return get_array_type(self.sample_width * 8) + + def __len__(self): + """ + returns the length of this audio segment in milliseconds + """ + return round(1000 * (self.frame_count() / self.frame_rate)) + + def __eq__(self, other): + try: + return self._data == other._data + except: + return False + + def __hash__(self): + return hash(AudioSegment) ^ hash((self.channels, self.frame_rate, self.sample_width, self._data)) + + def __ne__(self, other): + return not (self == other) + + def __iter__(self): + return (self[i] for i in xrange(len(self))) + + def __getitem__(self, millisecond): + if isinstance(millisecond, slice): + if millisecond.step: + return ( + self[i:i + millisecond.step] + for i in xrange(*millisecond.indices(len(self))) + ) + + start = millisecond.start if millisecond.start is not None else 0 + end = millisecond.stop if millisecond.stop is not None \ + else len(self) + + start = min(start, len(self)) + end = min(end, len(self)) + else: + start = millisecond + end = millisecond + 1 + + start = self._parse_position(start) * self.frame_width + end = self._parse_position(end) * self.frame_width + data = self._data[start:end] + + # ensure the output is as long as the requester is expecting + expected_length = end - start + missing_frames = (expected_length - len(data)) // self.frame_width + if missing_frames: + if missing_frames > self.frame_count(ms=2): + raise TooManyMissingFrames( + "You should never be filling in " + " more than 2 ms with silence here, " + "missing frames: %s" % missing_frames) + silence = audioop.mul(data[:self.frame_width], + self.sample_width, 0) + data += (silence * missing_frames) + + return self._spawn(data) + + def get_sample_slice(self, start_sample=None, end_sample=None): + """ + Get a section of the audio segment by sample index. + + NOTE: Negative indices do *not* address samples backword + from the end of the audio segment like a python list. + This is intentional. + """ + max_val = int(self.frame_count()) + + def bounded(val, default): + if val is None: + return default + if val < 0: + return 0 + if val > max_val: + return max_val + return val + + start_i = bounded(start_sample, 0) * self.frame_width + end_i = bounded(end_sample, max_val) * self.frame_width + + data = self._data[start_i:end_i] + return self._spawn(data) + + def __add__(self, arg): + if isinstance(arg, AudioSegment): + return self.append(arg, crossfade=0) + else: + return self.apply_gain(arg) + + def __radd__(self, rarg): + """ + Permit use of sum() builtin with an iterable of AudioSegments + """ + if rarg == 0: + return self + raise TypeError("Gains must be the second addend after the " + "AudioSegment") + + def __sub__(self, arg): + if isinstance(arg, AudioSegment): + raise TypeError("AudioSegment objects can't be subtracted from " + "each other") + else: + return self.apply_gain(-arg) + + def __mul__(self, arg): + """ + If the argument is an AudioSegment, overlay the multiplied audio + segment. + + If it's a number, just use the string multiply operation to repeat the + audio. + + The following would return an AudioSegment that contains the + audio of audio_seg eight times + + `audio_seg * 8` + """ + if isinstance(arg, AudioSegment): + return self.overlay(arg, position=0, loop=True) + else: + return self._spawn(data=self._data * arg) + + def _spawn(self, data, overrides={}): + """ + Creates a new audio segment using the metadata from the current one + and the data passed in. Should be used whenever an AudioSegment is + being returned by an operation that would alters the current one, + since AudioSegment objects are immutable. + """ + # accept lists of data chunks + if isinstance(data, list): + data = b''.join(data) + + if isinstance(data, array.array): + try: + data = data.tobytes() + except: + data = data.tostring() + + # accept file-like objects + if hasattr(data, 'read'): + if hasattr(data, 'seek'): + data.seek(0) + data = data.read() + + metadata = { + 'sample_width': self.sample_width, + 'frame_rate': self.frame_rate, + 'frame_width': self.frame_width, + 'channels': self.channels + } + metadata.update(overrides) + return self.__class__(data=data, metadata=metadata) + + @classmethod + def _sync(cls, *segs): + channels = max(seg.channels for seg in segs) + frame_rate = max(seg.frame_rate for seg in segs) + sample_width = max(seg.sample_width for seg in segs) + + return tuple( + seg.set_channels(channels).set_frame_rate(frame_rate).set_sample_width(sample_width) + for seg in segs + ) + + def _parse_position(self, val): + if val < 0: + val = len(self) - abs(val) + val = self.frame_count(ms=len(self)) if val == float("inf") else \ + self.frame_count(ms=val) + return int(val) + + @classmethod + def empty(cls): + return cls(b'', metadata={ + "channels": 1, + "sample_width": 1, + "frame_rate": 1, + "frame_width": 1 + }) + + @classmethod + def silent(cls, duration=1000, frame_rate=11025): + """ + Generate a silent audio segment. + duration specified in milliseconds (default duration: 1000ms, default frame_rate: 11025). + """ + frames = int(frame_rate * (duration / 1000.0)) + data = b"\0\0" * frames + return cls(data, metadata={"channels": 1, + "sample_width": 2, + "frame_rate": frame_rate, + "frame_width": 2}) + + @classmethod + def from_mono_audiosegments(cls, *mono_segments): + if not len(mono_segments): + raise ValueError("At least one AudioSegment instance is required") + + segs = cls._sync(*mono_segments) + + if segs[0].channels != 1: + raise ValueError( + "AudioSegment.from_mono_audiosegments requires all arguments are mono AudioSegment instances") + + channels = len(segs) + sample_width = segs[0].sample_width + frame_rate = segs[0].frame_rate + + frame_count = max(int(seg.frame_count()) for seg in segs) + data = array.array( + segs[0].array_type, + b'\0' * (frame_count * sample_width * channels) + ) + + for i, seg in enumerate(segs): + data[i::channels] = seg.get_array_of_samples() + + return cls( + data, + channels=channels, + sample_width=sample_width, + frame_rate=frame_rate, + ) + + @classmethod + def from_file_using_temporary_files(cls, file, format=None, codec=None, parameters=None, start_second=None, duration=None, **kwargs): + orig_file = file + file, close_file = _fd_or_path_or_tempfile(file, 'rb', tempfile=False) + + if format: + format = format.lower() + format = AUDIO_FILE_EXT_ALIASES.get(format, format) + + def is_format(f): + f = f.lower() + if format == f: + return True + if isinstance(orig_file, basestring): + return orig_file.lower().endswith(".{0}".format(f)) + if isinstance(orig_file, bytes): + return orig_file.lower().endswith((".{0}".format(f)).encode('utf8')) + return False + + if is_format("wav"): + try: + obj = cls._from_safe_wav(file) + if close_file: + file.close() + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[start_second*1000:] + elif start_second is None and duration is not None: + return obj[:duration*1000] + else: + return obj[start_second*1000:(start_second+duration)*1000] + except: + file.seek(0) + elif is_format("raw") or is_format("pcm"): + sample_width = kwargs['sample_width'] + frame_rate = kwargs['frame_rate'] + channels = kwargs['channels'] + metadata = { + 'sample_width': sample_width, + 'frame_rate': frame_rate, + 'channels': channels, + 'frame_width': channels * sample_width + } + obj = cls(data=file.read(), metadata=metadata) + if close_file: + file.close() + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[start_second * 1000:] + elif start_second is None and duration is not None: + return obj[:duration * 1000] + else: + return obj[start_second * 1000:(start_second + duration) * 1000] + + input_file = NamedTemporaryFile(mode='wb', delete=False) + try: + input_file.write(file.read()) + except(OSError): + input_file.flush() + input_file.close() + input_file = NamedTemporaryFile(mode='wb', delete=False, buffering=2 ** 31 - 1) + if close_file: + file.close() + close_file = True + file = open(orig_file, buffering=2 ** 13 - 1, mode='rb') + reader = file.read(2 ** 31 - 1) + while reader: + input_file.write(reader) + reader = file.read(2 ** 31 - 1) + input_file.flush() + if close_file: + file.close() + + output = NamedTemporaryFile(mode="rb", delete=False) + + conversion_command = [cls.converter, + '-y', # always overwrite existing files + ] + + # If format is not defined + # ffmpeg/avconv will detect it automatically + if format: + conversion_command += ["-f", format] + + if codec: + # force audio decoder + conversion_command += ["-acodec", codec] + + conversion_command += [ + "-i", input_file.name, # input_file options (filename last) + "-vn", # Drop any video streams if there are any + "-f", "wav" # output options (filename last) + ] + + if start_second is not None: + conversion_command += ["-ss", str(start_second)] + + if duration is not None: + conversion_command += ["-t", str(duration)] + + conversion_command += [output.name] + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + log_conversion(conversion_command) + + with open(os.devnull, 'rb') as devnull: + p = subprocess.Popen(conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p_out, p_err = p.communicate() + + log_subprocess_output(p_out) + log_subprocess_output(p_err) + + try: + if p.returncode != 0: + raise CouldntDecodeError( + "Decoding failed. ffmpeg returned error code: {0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( + p.returncode, p_err.decode(errors='ignore') )) + obj = cls._from_safe_wav(output) + finally: + input_file.close() + output.close() + os.unlink(input_file.name) + os.unlink(output.name) + + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[0:] + elif start_second is None and duration is not None: + return obj[:duration * 1000] + else: + return obj[0:duration * 1000] + + + @classmethod + def from_file(cls, file, format=None, codec=None, parameters=None, start_second=None, duration=None, **kwargs): + orig_file = file + try: + filename = fsdecode(file) + except TypeError: + filename = None + file, close_file = _fd_or_path_or_tempfile(file, 'rb', tempfile=False) + + if format: + format = format.lower() + format = AUDIO_FILE_EXT_ALIASES.get(format, format) + + def is_format(f): + f = f.lower() + if format == f: + return True + + if filename: + return filename.lower().endswith(".{0}".format(f)) + + return False + + if is_format("wav"): + try: + if start_second is None and duration is None: + return cls._from_safe_wav(file) + elif start_second is not None and duration is None: + return cls._from_safe_wav(file)[start_second*1000:] + elif start_second is None and duration is not None: + return cls._from_safe_wav(file)[:duration*1000] + else: + return cls._from_safe_wav(file)[start_second*1000:(start_second+duration)*1000] + except: + file.seek(0) + elif is_format("raw") or is_format("pcm"): + sample_width = kwargs['sample_width'] + frame_rate = kwargs['frame_rate'] + channels = kwargs['channels'] + metadata = { + 'sample_width': sample_width, + 'frame_rate': frame_rate, + 'channels': channels, + 'frame_width': channels * sample_width + } + if start_second is None and duration is None: + return cls(data=file.read(), metadata=metadata) + elif start_second is not None and duration is None: + return cls(data=file.read(), metadata=metadata)[start_second*1000:] + elif start_second is None and duration is not None: + return cls(data=file.read(), metadata=metadata)[:duration*1000] + else: + return cls(data=file.read(), metadata=metadata)[start_second*1000:(start_second+duration)*1000] + + conversion_command = [cls.converter, + '-y', # always overwrite existing files + ] + + # If format is not defined + # ffmpeg/avconv will detect it automatically + if format: + conversion_command += ["-f", format] + + if codec: + # force audio decoder + conversion_command += ["-acodec", codec] + + read_ahead_limit = kwargs.get('read_ahead_limit', -1) + if filename: + conversion_command += ["-i", filename] + stdin_parameter = None + stdin_data = None + else: + if cls.converter == 'ffmpeg': + conversion_command += ["-read_ahead_limit", str(read_ahead_limit), + "-i", "cache:pipe:0"] + else: + conversion_command += ["-i", "-"] + stdin_parameter = subprocess.PIPE + stdin_data = file.read() + + if codec: + info = None + else: + info = mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit) + if info: + audio_streams = [x for x in info['streams'] + if x['codec_type'] == 'audio'] + # This is a workaround for some ffprobe versions that always say + # that mp3/mp4/aac/webm/ogg files contain fltp samples + audio_codec = audio_streams[0].get('codec_name') + if (audio_streams[0].get('sample_fmt') == 'fltp' and + audio_codec in ['mp3', 'mp4', 'aac', 'webm', 'ogg']): + bits_per_sample = 16 + else: + bits_per_sample = audio_streams[0]['bits_per_sample'] + if bits_per_sample == 8: + acodec = 'pcm_u8' + else: + acodec = 'pcm_s%dle' % bits_per_sample + + conversion_command += ["-acodec", acodec] + + conversion_command += [ + "-vn", # Drop any video streams if there are any + "-f", "wav" # output options (filename last) + ] + + if start_second is not None: + conversion_command += ["-ss", str(start_second)] + + if duration is not None: + conversion_command += ["-t", str(duration)] + + conversion_command += ["-"] + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + log_conversion(conversion_command) + + p = subprocess.Popen(conversion_command, stdin=stdin_parameter, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p_out, p_err = p.communicate(input=stdin_data) + + if p.returncode != 0 or len(p_out) == 0: + if close_file: + file.close() + raise CouldntDecodeError( + "Decoding failed. ffmpeg returned error code: {0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( + p.returncode, p_err.decode(errors='ignore') )) + + p_out = bytearray(p_out) + fix_wav_headers(p_out) + p_out = bytes(p_out) + obj = cls(p_out) + + if close_file: + file.close() + + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[0:] + elif start_second is None and duration is not None: + return obj[:duration * 1000] + else: + return obj[0:duration * 1000] + + @classmethod + def from_mp3(cls, file, parameters=None): + return cls.from_file(file, 'mp3', parameters=parameters) + + @classmethod + def from_flv(cls, file, parameters=None): + return cls.from_file(file, 'flv', parameters=parameters) + + @classmethod + def from_ogg(cls, file, parameters=None): + return cls.from_file(file, 'ogg', parameters=parameters) + + @classmethod + def from_wav(cls, file, parameters=None): + return cls.from_file(file, 'wav', parameters=parameters) + + @classmethod + def from_raw(cls, file, **kwargs): + return cls.from_file(file, 'raw', sample_width=kwargs['sample_width'], frame_rate=kwargs['frame_rate'], + channels=kwargs['channels']) + + @classmethod + def _from_safe_wav(cls, file): + file, close_file = _fd_or_path_or_tempfile(file, 'rb', tempfile=False) + file.seek(0) + obj = cls(data=file) + if close_file: + file.close() + return obj + + def export(self, out_f=None, format='mp3', codec=None, bitrate=None, parameters=None, tags=None, id3v2_version='4', + cover=None): + """ + Export an AudioSegment to a file with given options + + out_f (string): + Path to destination audio file. Also accepts os.PathLike objects on + python >= 3.6 + + format (string) + Format for destination audio file. + ('mp3', 'wav', 'raw', 'ogg' or other ffmpeg/avconv supported files) + + codec (string) + Codec used to encode the destination file. + + bitrate (string) + Bitrate used when encoding destination file. (64, 92, 128, 256, 312k...) + Each codec accepts different bitrate arguments so take a look at the + ffmpeg documentation for details (bitrate usually shown as -b, -ba or + -a:b). + + parameters (list of strings) + Aditional ffmpeg/avconv parameters + + tags (dict) + Set metadata information to destination files + usually used as tags. ({title='Song Title', artist='Song Artist'}) + + id3v2_version (string) + Set ID3v2 version for tags. (default: '4') + + cover (file) + Set cover for audio file from image file. (png or jpg) + """ + id3v2_allowed_versions = ['3', '4'] + + if format == "raw" and (codec is not None or parameters is not None): + raise AttributeError( + 'Can not invoke ffmpeg when export format is "raw"; ' + 'specify an ffmpeg raw format like format="s16le" instead ' + 'or call export(format="raw") with no codec or parameters') + + out_f, _ = _fd_or_path_or_tempfile(out_f, 'wb+') + out_f.seek(0) + + if format == "raw": + out_f.write(self._data) + out_f.seek(0) + return out_f + + # wav with no ffmpeg parameters can just be written directly to out_f + easy_wav = format == "wav" and codec is None and parameters is None + + if easy_wav: + data = out_f + else: + data = NamedTemporaryFile(mode="wb", delete=False) + + pcm_for_wav = self._data + if self.sample_width == 1: + # convert to unsigned integers for wav + pcm_for_wav = audioop.bias(self._data, 1, 128) + + wave_data = wave.open(data, 'wb') + wave_data.setnchannels(self.channels) + wave_data.setsampwidth(self.sample_width) + wave_data.setframerate(self.frame_rate) + # For some reason packing the wave header struct with + # a float in python 2 doesn't throw an exception + wave_data.setnframes(int(self.frame_count())) + wave_data.writeframesraw(pcm_for_wav) + wave_data.close() + + # for easy wav files, we're done (wav data is written directly to out_f) + if easy_wav: + out_f.seek(0) + return out_f + + output = NamedTemporaryFile(mode="w+b", delete=False) + + # build converter command to export + conversion_command = [ + self.converter, + '-y', # always overwrite existing files + "-f", "wav", "-i", data.name, # input options (filename last) + ] + + if codec is None: + codec = self.DEFAULT_CODECS.get(format, None) + + if cover is not None: + if cover.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff')) and format == "mp3": + conversion_command.extend(["-i", cover, "-map", "0", "-map", "1", "-c:v", "mjpeg"]) + else: + raise AttributeError( + "Currently cover images are only supported by MP3 files. The allowed image formats are: .tif, .jpg, .bmp, .jpeg and .png.") + + if codec is not None: + # force audio encoder + conversion_command.extend(["-acodec", codec]) + + if bitrate is not None: + conversion_command.extend(["-b:a", bitrate]) + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + if tags is not None: + if not isinstance(tags, dict): + raise InvalidTag("Tags must be a dictionary.") + else: + # Extend converter command with tags + # print(tags) + for key, value in tags.items(): + conversion_command.extend( + ['-metadata', '{0}={1}'.format(key, value)]) + + if format == 'mp3': + # set id3v2 tag version + if id3v2_version not in id3v2_allowed_versions: + raise InvalidID3TagVersion( + "id3v2_version not allowed, allowed versions: %s" % id3v2_allowed_versions) + conversion_command.extend([ + "-id3v2_version", id3v2_version + ]) + + if sys.platform == 'darwin' and codec == 'mp3': + conversion_command.extend(["-write_xing", "0"]) + + conversion_command.extend([ + "-f", format, output.name, # output options (filename last) + ]) + + log_conversion(conversion_command) + + # read stdin / write stdout + with open(os.devnull, 'rb') as devnull: + p = subprocess.Popen(conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p_out, p_err = p.communicate() + + log_subprocess_output(p_out) + log_subprocess_output(p_err) + + if p.returncode != 0: + raise CouldntEncodeError( + "Encoding failed. ffmpeg/avlib returned error code: {0}\n\nCommand:{1}\n\nOutput from ffmpeg/avlib:\n\n{2}".format( + p.returncode, conversion_command, p_err.decode(errors='ignore') )) + + output.seek(0) + out_f.write(output.read()) + + data.close() + output.close() + + os.unlink(data.name) + os.unlink(output.name) + + out_f.seek(0) + return out_f + + def get_frame(self, index): + frame_start = index * self.frame_width + frame_end = frame_start + self.frame_width + return self._data[frame_start:frame_end] + + def frame_count(self, ms=None): + """ + returns the number of frames for the given number of milliseconds, or + if not specified, the number of frames in the whole AudioSegment + """ + if ms is not None: + return ms * (self.frame_rate / 1000.0) + else: + return float(len(self._data) // self.frame_width) + + def set_sample_width(self, sample_width): + if sample_width == self.sample_width: + return self + + frame_width = self.channels * sample_width + + return self._spawn( + audioop.lin2lin(self._data, self.sample_width, sample_width), + overrides={'sample_width': sample_width, 'frame_width': frame_width} + ) + + def set_frame_rate(self, frame_rate): + if frame_rate == self.frame_rate: + return self + + if self._data: + converted, _ = audioop.ratecv(self._data, self.sample_width, + self.channels, self.frame_rate, + frame_rate, None) + else: + converted = self._data + + return self._spawn(data=converted, + overrides={'frame_rate': frame_rate}) + + def set_channels(self, channels): + if channels == self.channels: + return self + + if channels == 2 and self.channels == 1: + fn = audioop.tostereo + frame_width = self.frame_width * 2 + fac = 1 + converted = fn(self._data, self.sample_width, fac, fac) + elif channels == 1 and self.channels == 2: + fn = audioop.tomono + frame_width = self.frame_width // 2 + fac = 0.5 + converted = fn(self._data, self.sample_width, fac, fac) + elif channels == 1: + channels_data = [seg.get_array_of_samples() for seg in self.split_to_mono()] + frame_count = int(self.frame_count()) + converted = array.array( + channels_data[0].typecode, + b'\0' * (frame_count * self.sample_width) + ) + for raw_channel_data in channels_data: + for i in range(frame_count): + converted[i] += raw_channel_data[i] // self.channels + frame_width = self.frame_width // self.channels + elif self.channels == 1: + dup_channels = [self for iChannel in range(channels)] + return AudioSegment.from_mono_audiosegments(*dup_channels) + else: + raise ValueError( + "AudioSegment.set_channels only supports mono-to-multi channel and multi-to-mono channel conversion") + + return self._spawn(data=converted, + overrides={ + 'channels': channels, + 'frame_width': frame_width}) + + def split_to_mono(self): + if self.channels == 1: + return [self] + + samples = self.get_array_of_samples() + + mono_channels = [] + for i in range(self.channels): + samples_for_current_channel = samples[i::self.channels] + + try: + mono_data = samples_for_current_channel.tobytes() + except AttributeError: + mono_data = samples_for_current_channel.tostring() + + mono_channels.append( + self._spawn(mono_data, overrides={"channels": 1, "frame_width": self.sample_width}) + ) + + return mono_channels + + @property + def rms(self): + return audioop.rms(self._data, self.sample_width) + + @property + def dBFS(self): + rms = self.rms + if not rms: + return -float("infinity") + return ratio_to_db(self.rms / self.max_possible_amplitude) + + @property + def max(self): + return audioop.max(self._data, self.sample_width) + + @property + def max_possible_amplitude(self): + bits = self.sample_width * 8 + max_possible_val = (2 ** bits) + + # since half is above 0 and half is below the max amplitude is divided + return max_possible_val / 2 + + @property + def max_dBFS(self): + return ratio_to_db(self.max, self.max_possible_amplitude) + + @property + def duration_seconds(self): + return self.frame_rate and self.frame_count() / self.frame_rate or 0.0 + + def get_dc_offset(self, channel=1): + """ + Returns a value between -1.0 and 1.0 representing the DC offset of a + channel (1 for left, 2 for right). + """ + if not 1 <= channel <= 2: + raise ValueError("channel value must be 1 (left) or 2 (right)") + + if self.channels == 1: + data = self._data + elif channel == 1: + data = audioop.tomono(self._data, self.sample_width, 1, 0) + else: + data = audioop.tomono(self._data, self.sample_width, 0, 1) + + return float(audioop.avg(data, self.sample_width)) / self.max_possible_amplitude + + def remove_dc_offset(self, channel=None, offset=None): + """ + Removes DC offset of given channel. Calculates offset if it's not given. + Offset values must be in range -1.0 to 1.0. If channel is None, removes + DC offset from all available channels. + """ + if channel and not 1 <= channel <= 2: + raise ValueError("channel value must be None, 1 (left) or 2 (right)") + + if offset and not -1.0 <= offset <= 1.0: + raise ValueError("offset value must be in range -1.0 to 1.0") + + if offset: + offset = int(round(offset * self.max_possible_amplitude)) + + def remove_data_dc(data, off): + if not off: + off = audioop.avg(data, self.sample_width) + return audioop.bias(data, self.sample_width, -off) + + if self.channels == 1: + return self._spawn(data=remove_data_dc(self._data, offset)) + + left_channel = audioop.tomono(self._data, self.sample_width, 1, 0) + right_channel = audioop.tomono(self._data, self.sample_width, 0, 1) + + if not channel or channel == 1: + left_channel = remove_data_dc(left_channel, offset) + + if not channel or channel == 2: + right_channel = remove_data_dc(right_channel, offset) + + left_channel = audioop.tostereo(left_channel, self.sample_width, 1, 0) + right_channel = audioop.tostereo(right_channel, self.sample_width, 0, 1) + + return self._spawn(data=audioop.add(left_channel, right_channel, + self.sample_width)) + + def apply_gain(self, volume_change): + return self._spawn(data=audioop.mul(self._data, self.sample_width, + db_to_float(float(volume_change)))) + + def overlay(self, seg, position=0, loop=False, times=None, gain_during_overlay=None): + """ + Overlay the provided segment on to this segment starting at the + specificed position and using the specfied looping beahvior. + + seg (AudioSegment): + The audio segment to overlay on to this one. + + position (optional int): + The position to start overlaying the provided segment in to this + one. + + loop (optional bool): + Loop seg as many times as necessary to match this segment's length. + Overrides loops param. + + times (optional int): + Loop seg the specified number of times or until it matches this + segment's length. 1 means once, 2 means twice, ... 0 would make the + call a no-op + gain_during_overlay (optional int): + Changes this segment's volume by the specified amount during the + duration of time that seg is overlaid on top of it. When negative, + this has the effect of 'ducking' the audio under the overlay. + """ + + if loop: + # match loop=True's behavior with new times (count) mechinism. + times = -1 + elif times is None: + # no times specified, just once through + times = 1 + elif times == 0: + # it's a no-op, make a copy since we never mutate + return self._spawn(self._data) + + output = StringIO() + + seg1, seg2 = AudioSegment._sync(self, seg) + sample_width = seg1.sample_width + spawn = seg1._spawn + + output.write(seg1[:position]._data) + + # drop down to the raw data + seg1 = seg1[position:]._data + seg2 = seg2._data + pos = 0 + seg1_len = len(seg1) + seg2_len = len(seg2) + while times: + remaining = max(0, seg1_len - pos) + if seg2_len >= remaining: + seg2 = seg2[:remaining] + seg2_len = remaining + # we've hit the end, we're done looping (if we were) and this + # is our last go-around + times = 1 + + if gain_during_overlay: + seg1_overlaid = seg1[pos:pos + seg2_len] + seg1_adjusted_gain = audioop.mul(seg1_overlaid, self.sample_width, + db_to_float(float(gain_during_overlay))) + output.write(audioop.add(seg1_adjusted_gain, seg2, sample_width)) + else: + output.write(audioop.add(seg1[pos:pos + seg2_len], seg2, + sample_width)) + pos += seg2_len + + # dec times to break our while loop (eventually) + times -= 1 + + output.write(seg1[pos:]) + + return spawn(data=output) + + def append(self, seg, crossfade=100): + seg1, seg2 = AudioSegment._sync(self, seg) + + if not crossfade: + return seg1._spawn(seg1._data + seg2._data) + elif crossfade > len(self): + raise ValueError("Crossfade is longer than the original AudioSegment ({}ms > {}ms)".format( + crossfade, len(self) + )) + elif crossfade > len(seg): + raise ValueError("Crossfade is longer than the appended AudioSegment ({}ms > {}ms)".format( + crossfade, len(seg) + )) + + xf = seg1[-crossfade:].fade(to_gain=-120, start=0, end=float('inf')) + xf *= seg2[:crossfade].fade(from_gain=-120, start=0, end=float('inf')) + + output = TemporaryFile() + + output.write(seg1[:-crossfade]._data) + output.write(xf._data) + output.write(seg2[crossfade:]._data) + + output.seek(0) + obj = seg1._spawn(data=output) + output.close() + return obj + + def fade(self, to_gain=0, from_gain=0, start=None, end=None, + duration=None): + """ + Fade the volume of this audio segment. + + to_gain (float): + resulting volume_change in db + + start (int): + default = beginning of the segment + when in this segment to start fading in milliseconds + + end (int): + default = end of the segment + when in this segment to start fading in milliseconds + + duration (int): + default = until the end of the audio segment + the duration of the fade + """ + if None not in [duration, end, start]: + raise TypeError('Only two of the three arguments, "start", ' + '"end", and "duration" may be specified') + + # no fade == the same audio + if to_gain == 0 and from_gain == 0: + return self + + start = min(len(self), start) if start is not None else None + end = min(len(self), end) if end is not None else None + + if start is not None and start < 0: + start += len(self) + if end is not None and end < 0: + end += len(self) + + if duration is not None and duration < 0: + raise InvalidDuration("duration must be a positive integer") + + if duration: + if start is not None: + end = start + duration + elif end is not None: + start = end - duration + else: + duration = end - start + + from_power = db_to_float(from_gain) + + output = [] + + # original data - up until the crossfade portion, as is + before_fade = self[:start]._data + if from_gain != 0: + before_fade = audioop.mul(before_fade, + self.sample_width, + from_power) + output.append(before_fade) + + gain_delta = db_to_float(to_gain) - from_power + + # fades longer than 100ms can use coarse fading (one gain step per ms), + # shorter fades will have audible clicks so they use precise fading + # (one gain step per sample) + if duration > 100: + scale_step = gain_delta / duration + + for i in range(duration): + volume_change = from_power + (scale_step * i) + chunk = self[start + i] + chunk = audioop.mul(chunk._data, + self.sample_width, + volume_change) + + output.append(chunk) + else: + start_frame = self.frame_count(ms=start) + end_frame = self.frame_count(ms=end) + fade_frames = end_frame - start_frame + scale_step = gain_delta / fade_frames + + for i in range(int(fade_frames)): + volume_change = from_power + (scale_step * i) + sample = self.get_frame(int(start_frame + i)) + sample = audioop.mul(sample, self.sample_width, volume_change) + + output.append(sample) + + # original data after the crossfade portion, at the new volume + after_fade = self[end:]._data + if to_gain != 0: + after_fade = audioop.mul(after_fade, + self.sample_width, + db_to_float(to_gain)) + output.append(after_fade) + + return self._spawn(data=output) + + def fade_out(self, duration): + return self.fade(to_gain=-120, duration=duration, end=float('inf')) + + def fade_in(self, duration): + return self.fade(from_gain=-120, duration=duration, start=0) + + def reverse(self): + return self._spawn( + data=audioop.reverse(self._data, self.sample_width) + ) + + def _repr_html_(self): + src = """ + + """ + fh = self.export() + data = base64.b64encode(fh.read()).decode('ascii') + return src.format(base64=data) + + +from . import effects diff --git a/sbapp/pydub/effects.py b/sbapp/pydub/effects.py new file mode 100644 index 0000000..0210521 --- /dev/null +++ b/sbapp/pydub/effects.py @@ -0,0 +1,341 @@ +import sys +import math +import array +from .utils import ( + db_to_float, + ratio_to_db, + register_pydub_effect, + make_chunks, + audioop, + get_min_max_value +) +from .silence import split_on_silence +from .exceptions import TooManyMissingFrames, InvalidDuration + +if sys.version_info >= (3, 0): + xrange = range + + +@register_pydub_effect +def apply_mono_filter_to_each_channel(seg, filter_fn): + n_channels = seg.channels + + channel_segs = seg.split_to_mono() + channel_segs = [filter_fn(channel_seg) for channel_seg in channel_segs] + + out_data = seg.get_array_of_samples() + for channel_i, channel_seg in enumerate(channel_segs): + for sample_i, sample in enumerate(channel_seg.get_array_of_samples()): + index = (sample_i * n_channels) + channel_i + out_data[index] = sample + + return seg._spawn(out_data) + + +@register_pydub_effect +def normalize(seg, headroom=0.1): + """ + headroom is how close to the maximum volume to boost the signal up to (specified in dB) + """ + peak_sample_val = seg.max + + # if the max is 0, this audio segment is silent, and can't be normalized + if peak_sample_val == 0: + return seg + + target_peak = seg.max_possible_amplitude * db_to_float(-headroom) + + needed_boost = ratio_to_db(target_peak / peak_sample_val) + return seg.apply_gain(needed_boost) + + +@register_pydub_effect +def speedup(seg, playback_speed=1.5, chunk_size=150, crossfade=25): + # we will keep audio in 150ms chunks since one waveform at 20Hz is 50ms long + # (20 Hz is the lowest frequency audible to humans) + + # portion of AUDIO TO KEEP. if playback speed is 1.25 we keep 80% (0.8) and + # discard 20% (0.2) + atk = 1.0 / playback_speed + + if playback_speed < 2.0: + # throwing out more than half the audio - keep 50ms chunks + ms_to_remove_per_chunk = int(chunk_size * (1 - atk) / atk) + else: + # throwing out less than half the audio - throw out 50ms chunks + ms_to_remove_per_chunk = int(chunk_size) + chunk_size = int(atk * chunk_size / (1 - atk)) + + # the crossfade cannot be longer than the amount of audio we're removing + crossfade = min(crossfade, ms_to_remove_per_chunk - 1) + + # DEBUG + #print("chunk: {0}, rm: {1}".format(chunk_size, ms_to_remove_per_chunk)) + + chunks = make_chunks(seg, chunk_size + ms_to_remove_per_chunk) + if len(chunks) < 2: + raise Exception("Could not speed up AudioSegment, it was too short {2:0.2f}s for the current settings:\n{0}ms chunks at {1:0.1f}x speedup".format( + chunk_size, playback_speed, seg.duration_seconds)) + + # we'll actually truncate a bit less than we calculated to make up for the + # crossfade between chunks + ms_to_remove_per_chunk -= crossfade + + # we don't want to truncate the last chunk since it is not guaranteed to be + # the full chunk length + last_chunk = chunks[-1] + chunks = [chunk[:-ms_to_remove_per_chunk] for chunk in chunks[:-1]] + + out = chunks[0] + for chunk in chunks[1:]: + out = out.append(chunk, crossfade=crossfade) + + out += last_chunk + return out + + +@register_pydub_effect +def strip_silence(seg, silence_len=1000, silence_thresh=-16, padding=100): + if padding > silence_len: + raise InvalidDuration("padding cannot be longer than silence_len") + + chunks = split_on_silence(seg, silence_len, silence_thresh, padding) + crossfade = padding / 2 + + if not len(chunks): + return seg[0:0] + + seg = chunks[0] + for chunk in chunks[1:]: + seg = seg.append(chunk, crossfade=crossfade) + + return seg + + +@register_pydub_effect +def compress_dynamic_range(seg, threshold=-20.0, ratio=4.0, attack=5.0, release=50.0): + """ + Keyword Arguments: + + threshold - default: -20.0 + Threshold in dBFS. default of -20.0 means -20dB relative to the + maximum possible volume. 0dBFS is the maximum possible value so + all values for this argument sould be negative. + + ratio - default: 4.0 + Compression ratio. Audio louder than the threshold will be + reduced to 1/ratio the volume. A ratio of 4.0 is equivalent to + a setting of 4:1 in a pro-audio compressor like the Waves C1. + + attack - default: 5.0 + Attack in milliseconds. How long it should take for the compressor + to kick in once the audio has exceeded the threshold. + + release - default: 50.0 + Release in milliseconds. How long it should take for the compressor + to stop compressing after the audio has falled below the threshold. + + + For an overview of Dynamic Range Compression, and more detailed explanation + of the related terminology, see: + + http://en.wikipedia.org/wiki/Dynamic_range_compression + """ + + thresh_rms = seg.max_possible_amplitude * db_to_float(threshold) + + look_frames = int(seg.frame_count(ms=attack)) + def rms_at(frame_i): + return seg.get_sample_slice(frame_i - look_frames, frame_i).rms + def db_over_threshold(rms): + if rms == 0: return 0.0 + db = ratio_to_db(rms / thresh_rms) + return max(db, 0) + + output = [] + + # amount to reduce the volume of the audio by (in dB) + attenuation = 0.0 + + attack_frames = seg.frame_count(ms=attack) + release_frames = seg.frame_count(ms=release) + for i in xrange(int(seg.frame_count())): + rms_now = rms_at(i) + + # with a ratio of 4.0 this means the volume will exceed the threshold by + # 1/4 the amount (of dB) that it would otherwise + max_attenuation = (1 - (1.0 / ratio)) * db_over_threshold(rms_now) + + attenuation_inc = max_attenuation / attack_frames + attenuation_dec = max_attenuation / release_frames + + if rms_now > thresh_rms and attenuation <= max_attenuation: + attenuation += attenuation_inc + attenuation = min(attenuation, max_attenuation) + else: + attenuation -= attenuation_dec + attenuation = max(attenuation, 0) + + frame = seg.get_frame(i) + if attenuation != 0.0: + frame = audioop.mul(frame, + seg.sample_width, + db_to_float(-attenuation)) + + output.append(frame) + + return seg._spawn(data=b''.join(output)) + + +# Invert the phase of the signal. + +@register_pydub_effect + +def invert_phase(seg, channels=(1, 1)): + """ + channels- specifies which channel (left or right) to reverse the phase of. + Note that mono AudioSegments will become stereo. + """ + if channels == (1, 1): + inverted = audioop.mul(seg._data, seg.sample_width, -1.0) + return seg._spawn(data=inverted) + + else: + if seg.channels == 2: + left, right = seg.split_to_mono() + else: + raise Exception("Can't implicitly convert an AudioSegment with " + str(seg.channels) + " channels to stereo.") + + if channels == (1, 0): + left = left.invert_phase() + else: + right = right.invert_phase() + + return seg.from_mono_audiosegments(left, right) + + + +# High and low pass filters based on implementation found on Stack Overflow: +# http://stackoverflow.com/questions/13882038/implementing-simple-high-and-low-pass-filters-in-c + +@register_pydub_effect +def low_pass_filter(seg, cutoff): + """ + cutoff - Frequency (in Hz) where higher frequency signal will begin to + be reduced by 6dB per octave (doubling in frequency) above this point + """ + RC = 1.0 / (cutoff * 2 * math.pi) + dt = 1.0 / seg.frame_rate + + alpha = dt / (RC + dt) + + original = seg.get_array_of_samples() + filteredArray = array.array(seg.array_type, original) + + frame_count = int(seg.frame_count()) + + last_val = [0] * seg.channels + for i in range(seg.channels): + last_val[i] = filteredArray[i] = original[i] + + for i in range(1, frame_count): + for j in range(seg.channels): + offset = (i * seg.channels) + j + last_val[j] = last_val[j] + (alpha * (original[offset] - last_val[j])) + filteredArray[offset] = int(last_val[j]) + + return seg._spawn(data=filteredArray) + + +@register_pydub_effect +def high_pass_filter(seg, cutoff): + """ + cutoff - Frequency (in Hz) where lower frequency signal will begin to + be reduced by 6dB per octave (doubling in frequency) below this point + """ + RC = 1.0 / (cutoff * 2 * math.pi) + dt = 1.0 / seg.frame_rate + + alpha = RC / (RC + dt) + + minval, maxval = get_min_max_value(seg.sample_width * 8) + + original = seg.get_array_of_samples() + filteredArray = array.array(seg.array_type, original) + + frame_count = int(seg.frame_count()) + + last_val = [0] * seg.channels + for i in range(seg.channels): + last_val[i] = filteredArray[i] = original[i] + + for i in range(1, frame_count): + for j in range(seg.channels): + offset = (i * seg.channels) + j + offset_minus_1 = ((i-1) * seg.channels) + j + + last_val[j] = alpha * (last_val[j] + original[offset] - original[offset_minus_1]) + filteredArray[offset] = int(min(max(last_val[j], minval), maxval)) + + return seg._spawn(data=filteredArray) + + +@register_pydub_effect +def pan(seg, pan_amount): + """ + pan_amount should be between -1.0 (100% left) and +1.0 (100% right) + + When pan_amount == 0.0 the left/right balance is not changed. + + Panning does not alter the *perceived* loundness, but since loudness + is decreasing on one side, the other side needs to get louder to + compensate. When panned hard left, the left channel will be 3dB louder. + """ + if not -1.0 <= pan_amount <= 1.0: + raise ValueError("pan_amount should be between -1.0 (100% left) and +1.0 (100% right)") + + max_boost_db = ratio_to_db(2.0) + boost_db = abs(pan_amount) * max_boost_db + + boost_factor = db_to_float(boost_db) + reduce_factor = db_to_float(max_boost_db) - boost_factor + + reduce_db = ratio_to_db(reduce_factor) + + # Cut boost in half (max boost== 3dB) - in reality 2 speakers + # do not sum to a full 6 dB. + boost_db = boost_db / 2.0 + + if pan_amount < 0: + return seg.apply_gain_stereo(boost_db, reduce_db) + else: + return seg.apply_gain_stereo(reduce_db, boost_db) + + +@register_pydub_effect +def apply_gain_stereo(seg, left_gain=0.0, right_gain=0.0): + """ + left_gain - amount of gain to apply to the left channel (in dB) + right_gain - amount of gain to apply to the right channel (in dB) + + note: mono audio segments will be converted to stereo + """ + if seg.channels == 1: + left = right = seg + elif seg.channels == 2: + left, right = seg.split_to_mono() + + l_mult_factor = db_to_float(left_gain) + r_mult_factor = db_to_float(right_gain) + + left_data = audioop.mul(left._data, left.sample_width, l_mult_factor) + left_data = audioop.tostereo(left_data, left.sample_width, 1, 0) + + right_data = audioop.mul(right._data, right.sample_width, r_mult_factor) + right_data = audioop.tostereo(right_data, right.sample_width, 0, 1) + + output = audioop.add(left_data, right_data, seg.sample_width) + + return seg._spawn(data=output, + overrides={'channels': 2, + 'frame_width': 2 * seg.sample_width}) diff --git a/sbapp/pydub/exceptions.py b/sbapp/pydub/exceptions.py new file mode 100644 index 0000000..79d0743 --- /dev/null +++ b/sbapp/pydub/exceptions.py @@ -0,0 +1,32 @@ +class PydubException(Exception): + """ + Base class for any Pydub exception + """ + + +class TooManyMissingFrames(PydubException): + pass + + +class InvalidDuration(PydubException): + pass + + +class InvalidTag(PydubException): + pass + + +class InvalidID3TagVersion(PydubException): + pass + + +class CouldntDecodeError(PydubException): + pass + + +class CouldntEncodeError(PydubException): + pass + + +class MissingAudioParameter(PydubException): + pass diff --git a/sbapp/pydub/generators.py b/sbapp/pydub/generators.py new file mode 100644 index 0000000..b04cb4c --- /dev/null +++ b/sbapp/pydub/generators.py @@ -0,0 +1,142 @@ +""" +Each generator will return float samples from -1.0 to 1.0, which can be +converted to actual audio with 8, 16, 24, or 32 bit depth using the +SiganlGenerator.to_audio_segment() method (on any of it's subclasses). + +See Wikipedia's "waveform" page for info on some of the generators included +here: http://en.wikipedia.org/wiki/Waveform +""" + +import math +import array +import itertools +import random +from .audio_segment import AudioSegment +from .utils import ( + db_to_float, + get_frame_width, + get_array_type, + get_min_max_value +) + + + +class SignalGenerator(object): + def __init__(self, sample_rate=44100, bit_depth=16): + self.sample_rate = sample_rate + self.bit_depth = bit_depth + + def to_audio_segment(self, duration=1000.0, volume=0.0): + """ + Duration in milliseconds + (default: 1 second) + Volume in DB relative to maximum amplitude + (default 0.0 dBFS, which is the maximum value) + """ + minval, maxval = get_min_max_value(self.bit_depth) + sample_width = get_frame_width(self.bit_depth) + array_type = get_array_type(self.bit_depth) + + gain = db_to_float(volume) + sample_count = int(self.sample_rate * (duration / 1000.0)) + + sample_data = (int(val * maxval * gain) for val in self.generate()) + sample_data = itertools.islice(sample_data, 0, sample_count) + + data = array.array(array_type, sample_data) + + try: + data = data.tobytes() + except: + data = data.tostring() + + return AudioSegment(data=data, metadata={ + "channels": 1, + "sample_width": sample_width, + "frame_rate": self.sample_rate, + "frame_width": sample_width, + }) + + def generate(self): + raise NotImplementedError("SignalGenerator subclasses must implement the generate() method, and *should not* call the superclass implementation.") + + + +class Sine(SignalGenerator): + def __init__(self, freq, **kwargs): + super(Sine, self).__init__(**kwargs) + self.freq = freq + + def generate(self): + sine_of = (self.freq * 2 * math.pi) / self.sample_rate + sample_n = 0 + while True: + yield math.sin(sine_of * sample_n) + sample_n += 1 + + + +class Pulse(SignalGenerator): + def __init__(self, freq, duty_cycle=0.5, **kwargs): + super(Pulse, self).__init__(**kwargs) + self.freq = freq + self.duty_cycle = duty_cycle + + def generate(self): + sample_n = 0 + + # in samples + cycle_length = self.sample_rate / float(self.freq) + pulse_length = cycle_length * self.duty_cycle + + while True: + if (sample_n % cycle_length) < pulse_length: + yield 1.0 + else: + yield -1.0 + sample_n += 1 + + + +class Square(Pulse): + def __init__(self, freq, **kwargs): + kwargs['duty_cycle'] = 0.5 + super(Square, self).__init__(freq, **kwargs) + + + +class Sawtooth(SignalGenerator): + def __init__(self, freq, duty_cycle=1.0, **kwargs): + super(Sawtooth, self).__init__(**kwargs) + self.freq = freq + self.duty_cycle = duty_cycle + + def generate(self): + sample_n = 0 + + # in samples + cycle_length = self.sample_rate / float(self.freq) + midpoint = cycle_length * self.duty_cycle + ascend_length = midpoint + descend_length = cycle_length - ascend_length + + while True: + cycle_position = sample_n % cycle_length + if cycle_position < midpoint: + yield (2 * cycle_position / ascend_length) - 1.0 + else: + yield 1.0 - (2 * (cycle_position - midpoint) / descend_length) + sample_n += 1 + + + +class Triangle(Sawtooth): + def __init__(self, freq, **kwargs): + kwargs['duty_cycle'] = 0.5 + super(Triangle, self).__init__(freq, **kwargs) + + +class WhiteNoise(SignalGenerator): + def generate(self): + while True: + yield (random.random() * 2) - 1.0 diff --git a/sbapp/pydub/logging_utils.py b/sbapp/pydub/logging_utils.py new file mode 100644 index 0000000..a312bd2 --- /dev/null +++ b/sbapp/pydub/logging_utils.py @@ -0,0 +1,14 @@ +""" + +""" +import logging + +converter_logger = logging.getLogger("pydub.converter") + +def log_conversion(conversion_command): + converter_logger.debug("subprocess.call(%s)", repr(conversion_command)) + +def log_subprocess_output(output): + if output: + for line in output.rstrip().splitlines(): + converter_logger.debug('subprocess output: %s', line.rstrip()) diff --git a/sbapp/pydub/playback.py b/sbapp/pydub/playback.py new file mode 100644 index 0000000..72ce4a5 --- /dev/null +++ b/sbapp/pydub/playback.py @@ -0,0 +1,71 @@ +""" +Support for playing AudioSegments. Pyaudio will be used if it's installed, +otherwise will fallback to ffplay. Pyaudio is a *much* nicer solution, but +is tricky to install. See my notes on installing pyaudio in a virtualenv (on +OSX 10.10): https://gist.github.com/jiaaro/9767512210a1d80a8a0d +""" + +import subprocess +from tempfile import NamedTemporaryFile +from .utils import get_player_name, make_chunks + +def _play_with_ffplay(seg): + PLAYER = get_player_name() + with NamedTemporaryFile("w+b", suffix=".wav") as f: + seg.export(f.name, "wav") + subprocess.call([PLAYER, "-nodisp", "-autoexit", "-hide_banner", f.name]) + + +def _play_with_pyaudio(seg): + import pyaudio + + p = pyaudio.PyAudio() + stream = p.open(format=p.get_format_from_width(seg.sample_width), + channels=seg.channels, + rate=seg.frame_rate, + output=True) + + # Just in case there were any exceptions/interrupts, we release the resource + # So as not to raise OSError: Device Unavailable should play() be used again + try: + # break audio into half-second chunks (to allows keyboard interrupts) + for chunk in make_chunks(seg, 500): + stream.write(chunk._data) + finally: + stream.stop_stream() + stream.close() + + p.terminate() + + +def _play_with_simpleaudio(seg): + import simpleaudio + return simpleaudio.play_buffer( + seg.raw_data, + num_channels=seg.channels, + bytes_per_sample=seg.sample_width, + sample_rate=seg.frame_rate + ) + + +def play(audio_segment): + try: + playback = _play_with_simpleaudio(audio_segment) + try: + playback.wait_done() + except KeyboardInterrupt: + playback.stop() + except ImportError: + pass + else: + return + + try: + _play_with_pyaudio(audio_segment) + return + except ImportError: + pass + else: + return + + _play_with_ffplay(audio_segment) diff --git a/sbapp/pydub/pyaudioop.py b/sbapp/pydub/pyaudioop.py new file mode 100644 index 0000000..9b1e2fb --- /dev/null +++ b/sbapp/pydub/pyaudioop.py @@ -0,0 +1,553 @@ +try: + from __builtin__ import max as builtin_max + from __builtin__ import min as builtin_min +except ImportError: + from builtins import max as builtin_max + from builtins import min as builtin_min +import math +import struct +try: + from fractions import gcd +except ImportError: # Python 3.9+ + from math import gcd +from ctypes import create_string_buffer + + +class error(Exception): + pass + + +def _check_size(size): + if size != 1 and size != 2 and size != 4: + raise error("Size should be 1, 2 or 4") + + +def _check_params(length, size): + _check_size(size) + if length % size != 0: + raise error("not a whole number of frames") + + +def _sample_count(cp, size): + return len(cp) / size + + +def _get_samples(cp, size, signed=True): + for i in range(_sample_count(cp, size)): + yield _get_sample(cp, size, i, signed) + + +def _struct_format(size, signed): + if size == 1: + return "b" if signed else "B" + elif size == 2: + return "h" if signed else "H" + elif size == 4: + return "i" if signed else "I" + + +def _get_sample(cp, size, i, signed=True): + fmt = _struct_format(size, signed) + start = i * size + end = start + size + return struct.unpack_from(fmt, buffer(cp)[start:end])[0] + + +def _put_sample(cp, size, i, val, signed=True): + fmt = _struct_format(size, signed) + struct.pack_into(fmt, cp, i * size, val) + + +def _get_maxval(size, signed=True): + if signed and size == 1: + return 0x7f + elif size == 1: + return 0xff + elif signed and size == 2: + return 0x7fff + elif size == 2: + return 0xffff + elif signed and size == 4: + return 0x7fffffff + elif size == 4: + return 0xffffffff + + +def _get_minval(size, signed=True): + if not signed: + return 0 + elif size == 1: + return -0x80 + elif size == 2: + return -0x8000 + elif size == 4: + return -0x80000000 + + +def _get_clipfn(size, signed=True): + maxval = _get_maxval(size, signed) + minval = _get_minval(size, signed) + return lambda val: builtin_max(min(val, maxval), minval) + + +def _overflow(val, size, signed=True): + minval = _get_minval(size, signed) + maxval = _get_maxval(size, signed) + if minval <= val <= maxval: + return val + + bits = size * 8 + if signed: + offset = 2**(bits-1) + return ((val + offset) % (2**bits)) - offset + else: + return val % (2**bits) + + +def getsample(cp, size, i): + _check_params(len(cp), size) + if not (0 <= i < len(cp) / size): + raise error("Index out of range") + return _get_sample(cp, size, i) + + +def max(cp, size): + _check_params(len(cp), size) + + if len(cp) == 0: + return 0 + + return builtin_max(abs(sample) for sample in _get_samples(cp, size)) + + +def minmax(cp, size): + _check_params(len(cp), size) + + max_sample, min_sample = 0, 0 + for sample in _get_samples(cp, size): + max_sample = builtin_max(sample, max_sample) + min_sample = builtin_min(sample, min_sample) + + return min_sample, max_sample + + +def avg(cp, size): + _check_params(len(cp), size) + sample_count = _sample_count(cp, size) + if sample_count == 0: + return 0 + return sum(_get_samples(cp, size)) / sample_count + + +def rms(cp, size): + _check_params(len(cp), size) + + sample_count = _sample_count(cp, size) + if sample_count == 0: + return 0 + + sum_squares = sum(sample**2 for sample in _get_samples(cp, size)) + return int(math.sqrt(sum_squares / sample_count)) + + +def _sum2(cp1, cp2, length): + size = 2 + total = 0 + for i in range(length): + total += getsample(cp1, size, i) * getsample(cp2, size, i) + return total + + +def findfit(cp1, cp2): + size = 2 + + if len(cp1) % 2 != 0 or len(cp2) % 2 != 0: + raise error("Strings should be even-sized") + + if len(cp1) < len(cp2): + raise error("First sample should be longer") + + len1 = _sample_count(cp1, size) + len2 = _sample_count(cp2, size) + + sum_ri_2 = _sum2(cp2, cp2, len2) + sum_aij_2 = _sum2(cp1, cp1, len2) + sum_aij_ri = _sum2(cp1, cp2, len2) + + result = (sum_ri_2 * sum_aij_2 - sum_aij_ri * sum_aij_ri) / sum_aij_2 + + best_result = result + best_i = 0 + + for i in range(1, len1 - len2 + 1): + aj_m1 = _get_sample(cp1, size, i - 1) + aj_lm1 = _get_sample(cp1, size, i + len2 - 1) + + sum_aij_2 += aj_lm1**2 - aj_m1**2 + sum_aij_ri = _sum2(buffer(cp1)[i*size:], cp2, len2) + + result = (sum_ri_2 * sum_aij_2 - sum_aij_ri * sum_aij_ri) / sum_aij_2 + + if result < best_result: + best_result = result + best_i = i + + factor = _sum2(buffer(cp1)[best_i*size:], cp2, len2) / sum_ri_2 + + return best_i, factor + + +def findfactor(cp1, cp2): + size = 2 + + if len(cp1) % 2 != 0: + raise error("Strings should be even-sized") + + if len(cp1) != len(cp2): + raise error("Samples should be same size") + + sample_count = _sample_count(cp1, size) + + sum_ri_2 = _sum2(cp2, cp2, sample_count) + sum_aij_ri = _sum2(cp1, cp2, sample_count) + + return sum_aij_ri / sum_ri_2 + + +def findmax(cp, len2): + size = 2 + sample_count = _sample_count(cp, size) + + if len(cp) % 2 != 0: + raise error("Strings should be even-sized") + + if len2 < 0 or sample_count < len2: + raise error("Input sample should be longer") + + if sample_count == 0: + return 0 + + result = _sum2(cp, cp, len2) + best_result = result + best_i = 0 + + for i in range(1, sample_count - len2 + 1): + sample_leaving_window = getsample(cp, size, i - 1) + sample_entering_window = getsample(cp, size, i + len2 - 1) + + result -= sample_leaving_window**2 + result += sample_entering_window**2 + + if result > best_result: + best_result = result + best_i = i + + return best_i + + +def avgpp(cp, size): + _check_params(len(cp), size) + sample_count = _sample_count(cp, size) + + prevextremevalid = False + prevextreme = None + avg = 0 + nextreme = 0 + + prevval = getsample(cp, size, 0) + val = getsample(cp, size, 1) + + prevdiff = val - prevval + + for i in range(1, sample_count): + val = getsample(cp, size, i) + diff = val - prevval + + if diff * prevdiff < 0: + if prevextremevalid: + avg += abs(prevval - prevextreme) + nextreme += 1 + + prevextremevalid = True + prevextreme = prevval + + prevval = val + if diff != 0: + prevdiff = diff + + if nextreme == 0: + return 0 + + return avg / nextreme + + +def maxpp(cp, size): + _check_params(len(cp), size) + sample_count = _sample_count(cp, size) + + prevextremevalid = False + prevextreme = None + max = 0 + + prevval = getsample(cp, size, 0) + val = getsample(cp, size, 1) + + prevdiff = val - prevval + + for i in range(1, sample_count): + val = getsample(cp, size, i) + diff = val - prevval + + if diff * prevdiff < 0: + if prevextremevalid: + extremediff = abs(prevval - prevextreme) + if extremediff > max: + max = extremediff + prevextremevalid = True + prevextreme = prevval + + prevval = val + if diff != 0: + prevdiff = diff + + return max + + +def cross(cp, size): + _check_params(len(cp), size) + + crossings = 0 + last_sample = 0 + for sample in _get_samples(cp, size): + if sample <= 0 < last_sample or sample >= 0 > last_sample: + crossings += 1 + last_sample = sample + + return crossings + + +def mul(cp, size, factor): + _check_params(len(cp), size) + clip = _get_clipfn(size) + + result = create_string_buffer(len(cp)) + + for i, sample in enumerate(_get_samples(cp, size)): + sample = clip(int(sample * factor)) + _put_sample(result, size, i, sample) + + return result.raw + + +def tomono(cp, size, fac1, fac2): + _check_params(len(cp), size) + clip = _get_clipfn(size) + + sample_count = _sample_count(cp, size) + + result = create_string_buffer(len(cp) / 2) + + for i in range(0, sample_count, 2): + l_sample = getsample(cp, size, i) + r_sample = getsample(cp, size, i + 1) + + sample = (l_sample * fac1) + (r_sample * fac2) + sample = clip(sample) + + _put_sample(result, size, i / 2, sample) + + return result.raw + + +def tostereo(cp, size, fac1, fac2): + _check_params(len(cp), size) + + sample_count = _sample_count(cp, size) + + result = create_string_buffer(len(cp) * 2) + clip = _get_clipfn(size) + + for i in range(sample_count): + sample = _get_sample(cp, size, i) + + l_sample = clip(sample * fac1) + r_sample = clip(sample * fac2) + + _put_sample(result, size, i * 2, l_sample) + _put_sample(result, size, i * 2 + 1, r_sample) + + return result.raw + + +def add(cp1, cp2, size): + _check_params(len(cp1), size) + + if len(cp1) != len(cp2): + raise error("Lengths should be the same") + + clip = _get_clipfn(size) + sample_count = _sample_count(cp1, size) + result = create_string_buffer(len(cp1)) + + for i in range(sample_count): + sample1 = getsample(cp1, size, i) + sample2 = getsample(cp2, size, i) + + sample = clip(sample1 + sample2) + + _put_sample(result, size, i, sample) + + return result.raw + + +def bias(cp, size, bias): + _check_params(len(cp), size) + + result = create_string_buffer(len(cp)) + + for i, sample in enumerate(_get_samples(cp, size)): + sample = _overflow(sample + bias, size) + _put_sample(result, size, i, sample) + + return result.raw + + +def reverse(cp, size): + _check_params(len(cp), size) + sample_count = _sample_count(cp, size) + + result = create_string_buffer(len(cp)) + for i, sample in enumerate(_get_samples(cp, size)): + _put_sample(result, size, sample_count - i - 1, sample) + + return result.raw + + +def lin2lin(cp, size, size2): + _check_params(len(cp), size) + _check_size(size2) + + if size == size2: + return cp + + new_len = (len(cp) / size) * size2 + + result = create_string_buffer(new_len) + + for i in range(_sample_count(cp, size)): + sample = _get_sample(cp, size, i) + if size < size2: + sample = sample << (4 * size2 / size) + elif size > size2: + sample = sample >> (4 * size / size2) + + sample = _overflow(sample, size2) + + _put_sample(result, size2, i, sample) + + return result.raw + + +def ratecv(cp, size, nchannels, inrate, outrate, state, weightA=1, weightB=0): + _check_params(len(cp), size) + if nchannels < 1: + raise error("# of channels should be >= 1") + + bytes_per_frame = size * nchannels + frame_count = len(cp) / bytes_per_frame + + if bytes_per_frame / nchannels != size: + raise OverflowError("width * nchannels too big for a C int") + + if weightA < 1 or weightB < 0: + raise error("weightA should be >= 1, weightB should be >= 0") + + if len(cp) % bytes_per_frame != 0: + raise error("not a whole number of frames") + + if inrate <= 0 or outrate <= 0: + raise error("sampling rate not > 0") + + d = gcd(inrate, outrate) + inrate /= d + outrate /= d + + prev_i = [0] * nchannels + cur_i = [0] * nchannels + + if state is None: + d = -outrate + else: + d, samps = state + + if len(samps) != nchannels: + raise error("illegal state argument") + + prev_i, cur_i = zip(*samps) + prev_i, cur_i = list(prev_i), list(cur_i) + + q = frame_count / inrate + ceiling = (q + 1) * outrate + nbytes = ceiling * bytes_per_frame + + result = create_string_buffer(nbytes) + + samples = _get_samples(cp, size) + out_i = 0 + while True: + while d < 0: + if frame_count == 0: + samps = zip(prev_i, cur_i) + retval = result.raw + + # slice off extra bytes + trim_index = (out_i * bytes_per_frame) - len(retval) + retval = buffer(retval)[:trim_index] + + return (retval, (d, tuple(samps))) + + for chan in range(nchannels): + prev_i[chan] = cur_i[chan] + cur_i[chan] = samples.next() + + cur_i[chan] = ( + (weightA * cur_i[chan] + weightB * prev_i[chan]) + / (weightA + weightB) + ) + + frame_count -= 1 + d += outrate + + while d >= 0: + for chan in range(nchannels): + cur_o = ( + (prev_i[chan] * d + cur_i[chan] * (outrate - d)) + / outrate + ) + _put_sample(result, size, out_i, _overflow(cur_o, size)) + out_i += 1 + d -= inrate + + +def lin2ulaw(cp, size): + raise NotImplementedError() + + +def ulaw2lin(cp, size): + raise NotImplementedError() + + +def lin2alaw(cp, size): + raise NotImplementedError() + + +def alaw2lin(cp, size): + raise NotImplementedError() + + +def lin2adpcm(cp, size, state): + raise NotImplementedError() + + +def adpcm2lin(cp, size, state): + raise NotImplementedError() diff --git a/sbapp/pydub/scipy_effects.py b/sbapp/pydub/scipy_effects.py new file mode 100644 index 0000000..abab2b4 --- /dev/null +++ b/sbapp/pydub/scipy_effects.py @@ -0,0 +1,175 @@ +""" +This module provides scipy versions of high_pass_filter, and low_pass_filter +as well as an additional band_pass_filter. + +Of course, you will need to install scipy for these to work. + +When this module is imported the high and low pass filters from this module +will be used when calling audio_segment.high_pass_filter() and +audio_segment.high_pass_filter() instead of the slower, less powerful versions +provided by pydub.effects. +""" +from scipy.signal import butter, sosfilt +from .utils import (register_pydub_effect,stereo_to_ms,ms_to_stereo) + + +def _mk_butter_filter(freq, type, order): + """ + Args: + freq: The cutoff frequency for highpass and lowpass filters. For + band filters, a list of [low_cutoff, high_cutoff] + type: "lowpass", "highpass", or "band" + order: nth order butterworth filter (default: 5th order). The + attenuation is -6dB/octave beyond the cutoff frequency (for 1st + order). A Higher order filter will have more attenuation, each level + adding an additional -6dB (so a 3rd order butterworth filter would + be -18dB/octave). + + Returns: + function which can filter a mono audio segment + + """ + def filter_fn(seg): + assert seg.channels == 1 + + nyq = 0.5 * seg.frame_rate + try: + freqs = [f / nyq for f in freq] + except TypeError: + freqs = freq / nyq + + sos = butter(order, freqs, btype=type, output='sos') + y = sosfilt(sos, seg.get_array_of_samples()) + + return seg._spawn(y.astype(seg.array_type)) + + return filter_fn + + +@register_pydub_effect +def band_pass_filter(seg, low_cutoff_freq, high_cutoff_freq, order=5): + filter_fn = _mk_butter_filter([low_cutoff_freq, high_cutoff_freq], 'band', order=order) + return seg.apply_mono_filter_to_each_channel(filter_fn) + + +@register_pydub_effect +def high_pass_filter(seg, cutoff_freq, order=5): + filter_fn = _mk_butter_filter(cutoff_freq, 'highpass', order=order) + return seg.apply_mono_filter_to_each_channel(filter_fn) + + +@register_pydub_effect +def low_pass_filter(seg, cutoff_freq, order=5): + filter_fn = _mk_butter_filter(cutoff_freq, 'lowpass', order=order) + return seg.apply_mono_filter_to_each_channel(filter_fn) + + +@register_pydub_effect +def _eq(seg, focus_freq, bandwidth=100, mode="peak", gain_dB=0, order=2): + """ + Args: + focus_freq - middle frequency or known frequency of band (in Hz) + bandwidth - range of the equalizer band + mode - Mode of Equalization(Peak/Notch(Bell Curve),High Shelf, Low Shelf) + order - Rolloff factor(1 - 6dB/Octave 2 - 12dB/Octave) + + Returns: + Equalized/Filtered AudioSegment + """ + filt_mode = ["peak", "low_shelf", "high_shelf"] + if mode not in filt_mode: + raise ValueError("Incorrect Mode Selection") + + if gain_dB >= 0: + if mode == "peak": + sec = band_pass_filter(seg, focus_freq - bandwidth/2, focus_freq + bandwidth/2, order = order) + seg = seg.overlay(sec - (3 - gain_dB)) + return seg + + if mode == "low_shelf": + sec = low_pass_filter(seg, focus_freq, order=order) + seg = seg.overlay(sec - (3 - gain_dB)) + return seg + + if mode == "high_shelf": + sec = high_pass_filter(seg, focus_freq, order=order) + seg = seg.overlay(sec - (3 - gain_dB)) + return seg + + if gain_dB < 0: + if mode == "peak": + sec = high_pass_filter(seg, focus_freq - bandwidth/2, order=order) + seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB + sec = low_pass_filter(seg, focus_freq + bandwidth/2, order=order) + seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB + return seg + + if mode == "low_shelf": + sec = high_pass_filter(seg, focus_freq, order=order) + seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB + return seg + + if mode=="high_shelf": + sec=low_pass_filter(seg, focus_freq, order=order) + seg=seg.overlay(sec - (3 + gain_dB)) +gain_dB + return seg + + +@register_pydub_effect +def eq(seg, focus_freq, bandwidth=100, channel_mode="L+R", filter_mode="peak", gain_dB=0, order=2): + """ + Args: + focus_freq - middle frequency or known frequency of band (in Hz) + bandwidth - range of the equalizer band + channel_mode - Select Channels to be affected by the filter. + L+R - Standard Stereo Filter + L - Only Left Channel is Filtered + R - Only Right Channel is Filtered + M+S - Blumlien Stereo Filter(Mid-Side) + M - Only Mid Channel is Filtered + S - Only Side Channel is Filtered + Mono Audio Segments are completely filtered. + filter_mode - Mode of Equalization(Peak/Notch(Bell Curve),High Shelf, Low Shelf) + order - Rolloff factor(1 - 6dB/Octave 2 - 12dB/Octave) + + Returns: + Equalized/Filtered AudioSegment + """ + channel_modes = ["L+R", "M+S", "L", "R", "M", "S"] + if channel_mode not in channel_modes: + raise ValueError("Incorrect Channel Mode Selection") + + if seg.channels == 1: + return _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order) + + if channel_mode == "L+R": + return _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order) + + if channel_mode == "L": + seg = seg.split_to_mono() + seg = [_eq(seg[0], focus_freq, bandwidth, filter_mode, gain_dB, order), seg[1]] + return AudioSegment.from_mono_audio_segements(seg[0], seg[1]) + + if channel_mode == "R": + seg = seg.split_to_mono() + seg = [seg[0], _eq(seg[1], focus_freq, bandwidth, filter_mode, gain_dB, order)] + return AudioSegment.from_mono_audio_segements(seg[0], seg[1]) + + if channel_mode == "M+S": + seg = stereo_to_ms(seg) + seg = _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order) + return ms_to_stereo(seg) + + if channel_mode == "M": + seg = stereo_to_ms(seg).split_to_mono() + seg = [_eq(seg[0], focus_freq, bandwidth, filter_mode, gain_dB, order), seg[1]] + seg = AudioSegment.from_mono_audio_segements(seg[0], seg[1]) + return ms_to_stereo(seg) + + if channel_mode == "S": + seg = stereo_to_ms(seg).split_to_mono() + seg = [seg[0], _eq(seg[1], focus_freq, bandwidth, filter_mode, gain_dB, order)] + seg = AudioSegment.from_mono_audio_segements(seg[0], seg[1]) + return ms_to_stereo(seg) + + diff --git a/sbapp/pydub/silence.py b/sbapp/pydub/silence.py new file mode 100644 index 0000000..0ad1499 --- /dev/null +++ b/sbapp/pydub/silence.py @@ -0,0 +1,182 @@ +""" +Various functions for finding/manipulating silence in AudioSegments +""" +import itertools + +from .utils import db_to_float + + +def detect_silence(audio_segment, min_silence_len=1000, silence_thresh=-16, seek_step=1): + """ + Returns a list of all silent sections [start, end] in milliseconds of audio_segment. + Inverse of detect_nonsilent() + + audio_segment - the segment to find silence in + min_silence_len - the minimum length for any silent section + silence_thresh - the upper bound for how quiet is silent in dFBS + seek_step - step size for interating over the segment in ms + """ + seg_len = len(audio_segment) + + # you can't have a silent portion of a sound that is longer than the sound + if seg_len < min_silence_len: + return [] + + # convert silence threshold to a float value (so we can compare it to rms) + silence_thresh = db_to_float(silence_thresh) * audio_segment.max_possible_amplitude + + # find silence and add start and end indicies to the to_cut list + silence_starts = [] + + # check successive (1 sec by default) chunk of sound for silence + # try a chunk at every "seek step" (or every chunk for a seek step == 1) + last_slice_start = seg_len - min_silence_len + slice_starts = range(0, last_slice_start + 1, seek_step) + + # guarantee last_slice_start is included in the range + # to make sure the last portion of the audio is searched + if last_slice_start % seek_step: + slice_starts = itertools.chain(slice_starts, [last_slice_start]) + + for i in slice_starts: + audio_slice = audio_segment[i:i + min_silence_len] + if audio_slice.rms <= silence_thresh: + silence_starts.append(i) + + # short circuit when there is no silence + if not silence_starts: + return [] + + # combine the silence we detected into ranges (start ms - end ms) + silent_ranges = [] + + prev_i = silence_starts.pop(0) + current_range_start = prev_i + + for silence_start_i in silence_starts: + continuous = (silence_start_i == prev_i + seek_step) + + # sometimes two small blips are enough for one particular slice to be + # non-silent, despite the silence all running together. Just combine + # the two overlapping silent ranges. + silence_has_gap = silence_start_i > (prev_i + min_silence_len) + + if not continuous and silence_has_gap: + silent_ranges.append([current_range_start, + prev_i + min_silence_len]) + current_range_start = silence_start_i + prev_i = silence_start_i + + silent_ranges.append([current_range_start, + prev_i + min_silence_len]) + + return silent_ranges + + +def detect_nonsilent(audio_segment, min_silence_len=1000, silence_thresh=-16, seek_step=1): + """ + Returns a list of all nonsilent sections [start, end] in milliseconds of audio_segment. + Inverse of detect_silent() + + audio_segment - the segment to find silence in + min_silence_len - the minimum length for any silent section + silence_thresh - the upper bound for how quiet is silent in dFBS + seek_step - step size for interating over the segment in ms + """ + silent_ranges = detect_silence(audio_segment, min_silence_len, silence_thresh, seek_step) + len_seg = len(audio_segment) + + # if there is no silence, the whole thing is nonsilent + if not silent_ranges: + return [[0, len_seg]] + + # short circuit when the whole audio segment is silent + if silent_ranges[0][0] == 0 and silent_ranges[0][1] == len_seg: + return [] + + prev_end_i = 0 + nonsilent_ranges = [] + for start_i, end_i in silent_ranges: + nonsilent_ranges.append([prev_end_i, start_i]) + prev_end_i = end_i + + if end_i != len_seg: + nonsilent_ranges.append([prev_end_i, len_seg]) + + if nonsilent_ranges[0] == [0, 0]: + nonsilent_ranges.pop(0) + + return nonsilent_ranges + + +def split_on_silence(audio_segment, min_silence_len=1000, silence_thresh=-16, keep_silence=100, + seek_step=1): + """ + Returns list of audio segments from splitting audio_segment on silent sections + + audio_segment - original pydub.AudioSegment() object + + min_silence_len - (in ms) minimum length of a silence to be used for + a split. default: 1000ms + + silence_thresh - (in dBFS) anything quieter than this will be + considered silence. default: -16dBFS + + keep_silence - (in ms or True/False) leave some silence at the beginning + and end of the chunks. Keeps the sound from sounding like it + is abruptly cut off. + When the length of the silence is less than the keep_silence duration + it is split evenly between the preceding and following non-silent + segments. + If True is specified, all the silence is kept, if False none is kept. + default: 100ms + + seek_step - step size for interating over the segment in ms + """ + + # from the itertools documentation + def pairwise(iterable): + "s -> (s0,s1), (s1,s2), (s2, s3), ..." + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) + + if isinstance(keep_silence, bool): + keep_silence = len(audio_segment) if keep_silence else 0 + + output_ranges = [ + [ start - keep_silence, end + keep_silence ] + for (start,end) + in detect_nonsilent(audio_segment, min_silence_len, silence_thresh, seek_step) + ] + + for range_i, range_ii in pairwise(output_ranges): + last_end = range_i[1] + next_start = range_ii[0] + if next_start < last_end: + range_i[1] = (last_end+next_start)//2 + range_ii[0] = range_i[1] + + return [ + audio_segment[ max(start,0) : min(end,len(audio_segment)) ] + for start,end in output_ranges + ] + + +def detect_leading_silence(sound, silence_threshold=-50.0, chunk_size=10): + """ + Returns the millisecond/index that the leading silence ends. + + audio_segment - the segment to find silence in + silence_threshold - the upper bound for how quiet is silent in dFBS + chunk_size - chunk size for interating over the segment in ms + """ + trim_ms = 0 # ms + assert chunk_size > 0 # to avoid infinite loop + while sound[trim_ms:trim_ms+chunk_size].dBFS < silence_threshold and trim_ms < len(sound): + trim_ms += chunk_size + + # if there is no end it should return the length of the segment + return min(trim_ms, len(sound)) + + diff --git a/sbapp/pydub/utils.py b/sbapp/pydub/utils.py new file mode 100644 index 0000000..740c500 --- /dev/null +++ b/sbapp/pydub/utils.py @@ -0,0 +1,434 @@ +from __future__ import division + +import json +import os +import re +import sys +from subprocess import Popen, PIPE +from math import log, ceil +from tempfile import TemporaryFile +from warnings import warn +from functools import wraps + +try: + import audioop +except ImportError: + import pyaudioop as audioop + +if sys.version_info >= (3, 0): + basestring = str + +FRAME_WIDTHS = { + 8: 1, + 16: 2, + 32: 4, +} +ARRAY_TYPES = { + 8: "b", + 16: "h", + 32: "i", +} +ARRAY_RANGES = { + 8: (-0x80, 0x7f), + 16: (-0x8000, 0x7fff), + 32: (-0x80000000, 0x7fffffff), +} + + +def get_frame_width(bit_depth): + return FRAME_WIDTHS[bit_depth] + + +def get_array_type(bit_depth, signed=True): + t = ARRAY_TYPES[bit_depth] + if not signed: + t = t.upper() + return t + + +def get_min_max_value(bit_depth): + return ARRAY_RANGES[bit_depth] + + +def _fd_or_path_or_tempfile(fd, mode='w+b', tempfile=True): + close_fd = False + if fd is None and tempfile: + fd = TemporaryFile(mode=mode) + close_fd = True + + if isinstance(fd, basestring): + fd = open(fd, mode=mode) + close_fd = True + + try: + if isinstance(fd, os.PathLike): + fd = open(fd, mode=mode) + close_fd = True + except AttributeError: + # module os has no attribute PathLike, so we're on python < 3.6. + # The protocol we're trying to support doesn't exist, so just pass. + pass + + return fd, close_fd + + +def db_to_float(db, using_amplitude=True): + """ + Converts the input db to a float, which represents the equivalent + ratio in power. + """ + db = float(db) + if using_amplitude: + return 10 ** (db / 20) + else: # using power + return 10 ** (db / 10) + + +def ratio_to_db(ratio, val2=None, using_amplitude=True): + """ + Converts the input float to db, which represents the equivalent + to the ratio in power represented by the multiplier passed in. + """ + ratio = float(ratio) + + # accept 2 values and use the ratio of val1 to val2 + if val2 is not None: + ratio = ratio / val2 + + # special case for multiply-by-zero (convert to silence) + if ratio == 0: + return -float('inf') + + if using_amplitude: + return 20 * log(ratio, 10) + else: # using power + return 10 * log(ratio, 10) + + +def register_pydub_effect(fn, name=None): + """ + decorator for adding pydub effects to the AudioSegment objects. + example use: + @register_pydub_effect + def normalize(audio_segment): + ... + or you can specify a name: + @register_pydub_effect("normalize") + def normalize_audio_segment(audio_segment): + ... + """ + if isinstance(fn, basestring): + name = fn + return lambda fn: register_pydub_effect(fn, name) + + if name is None: + name = fn.__name__ + + from .audio_segment import AudioSegment + setattr(AudioSegment, name, fn) + return fn + + +def make_chunks(audio_segment, chunk_length): + """ + Breaks an AudioSegment into chunks that are milliseconds + long. + if chunk_length is 50 then you'll get a list of 50 millisecond long audio + segments back (except the last one, which can be shorter) + """ + number_of_chunks = ceil(len(audio_segment) / float(chunk_length)) + return [audio_segment[i * chunk_length:(i + 1) * chunk_length] + for i in range(int(number_of_chunks))] + + +def which(program): + """ + Mimics behavior of UNIX which command. + """ + # Add .exe program extension for windows support + if os.name == "nt" and not program.endswith(".exe"): + program += ".exe" + + envdir_list = [os.curdir] + os.environ["PATH"].split(os.pathsep) + + for envdir in envdir_list: + program_path = os.path.join(envdir, program) + if os.path.isfile(program_path) and os.access(program_path, os.X_OK): + return program_path + + +def get_encoder_name(): + """ + Return enconder default application for system, either avconv or ffmpeg + """ + if which("avconv"): + return "avconv" + elif which("ffmpeg"): + return "ffmpeg" + else: + # should raise exception + warn("Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work", RuntimeWarning) + return "ffmpeg" + + +def get_player_name(): + """ + Return enconder default application for system, either avconv or ffmpeg + """ + if which("avplay"): + return "avplay" + elif which("ffplay"): + return "ffplay" + else: + # should raise exception + warn("Couldn't find ffplay or avplay - defaulting to ffplay, but may not work", RuntimeWarning) + return "ffplay" + + +def get_prober_name(): + """ + Return probe application, either avconv or ffmpeg + """ + if which("avprobe"): + return "avprobe" + elif which("ffprobe"): + return "ffprobe" + else: + # should raise exception + warn("Couldn't find ffprobe or avprobe - defaulting to ffprobe, but may not work", RuntimeWarning) + return "ffprobe" + + +def fsdecode(filename): + """Wrapper for os.fsdecode which was introduced in python 3.2 .""" + + if sys.version_info >= (3, 2): + PathLikeTypes = (basestring, bytes) + if sys.version_info >= (3, 6): + PathLikeTypes += (os.PathLike,) + if isinstance(filename, PathLikeTypes): + return os.fsdecode(filename) + else: + if isinstance(filename, bytes): + return filename.decode(sys.getfilesystemencoding()) + if isinstance(filename, basestring): + return filename + + raise TypeError("type {0} not accepted by fsdecode".format(type(filename))) + + +def get_extra_info(stderr): + """ + avprobe sometimes gives more information on stderr than + on the json output. The information has to be extracted + from stderr of the format of: + ' Stream #0:0: Audio: flac, 88200 Hz, stereo, s32 (24 bit)' + or (macOS version): + ' Stream #0:0: Audio: vorbis' + ' 44100 Hz, stereo, fltp, 320 kb/s' + + :type stderr: str + :rtype: list of dict + """ + extra_info = {} + + re_stream = r'(?P +)Stream #0[:\.](?P([0-9]+))(?P.+)\n?(?! *Stream)((?P +)(?P.+))?' + for i in re.finditer(re_stream, stderr): + if i.group('space_end') is not None and len(i.group('space_start')) <= len( + i.group('space_end')): + content_line = ','.join([i.group('content_0'), i.group('content_1')]) + else: + content_line = i.group('content_0') + tokens = [x.strip() for x in re.split('[:,]', content_line) if x] + extra_info[int(i.group('stream_id'))] = tokens + return extra_info + + +def mediainfo_json(filepath, read_ahead_limit=-1): + """Return json dictionary with media info(codec, duration, size, bitrate...) from filepath + """ + prober = get_prober_name() + command_args = [ + "-v", "info", + "-show_format", + "-show_streams", + ] + try: + command_args += [fsdecode(filepath)] + stdin_parameter = None + stdin_data = None + except TypeError: + if prober == 'ffprobe': + command_args += ["-read_ahead_limit", str(read_ahead_limit), + "cache:pipe:0"] + else: + command_args += ["-"] + stdin_parameter = PIPE + file, close_file = _fd_or_path_or_tempfile(filepath, 'rb', tempfile=False) + file.seek(0) + stdin_data = file.read() + if close_file: + file.close() + + command = [prober, '-of', 'json'] + command_args + res = Popen(command, stdin=stdin_parameter, stdout=PIPE, stderr=PIPE) + output, stderr = res.communicate(input=stdin_data) + output = output.decode("utf-8", 'ignore') + stderr = stderr.decode("utf-8", 'ignore') + + info = json.loads(output) + + if not info: + # If ffprobe didn't give any information, just return it + # (for example, because the file doesn't exist) + return info + + extra_info = get_extra_info(stderr) + + audio_streams = [x for x in info['streams'] if x['codec_type'] == 'audio'] + if len(audio_streams) == 0: + return info + + # We just operate on the first audio stream in case there are more + stream = audio_streams[0] + + def set_property(stream, prop, value): + if prop not in stream or stream[prop] == 0: + stream[prop] = value + + for token in extra_info[stream['index']]: + m = re.match('([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$', token) + m2 = re.match('([su]([0-9]{1,2})p?)( \(default\))?$', token) + if m: + set_property(stream, 'sample_fmt', m.group(1)) + set_property(stream, 'bits_per_sample', int(m.group(2))) + set_property(stream, 'bits_per_raw_sample', int(m.group(3))) + elif m2: + set_property(stream, 'sample_fmt', m2.group(1)) + set_property(stream, 'bits_per_sample', int(m2.group(2))) + set_property(stream, 'bits_per_raw_sample', int(m2.group(2))) + elif re.match('(flt)p?( \(default\))?$', token): + set_property(stream, 'sample_fmt', token) + set_property(stream, 'bits_per_sample', 32) + set_property(stream, 'bits_per_raw_sample', 32) + elif re.match('(dbl)p?( \(default\))?$', token): + set_property(stream, 'sample_fmt', token) + set_property(stream, 'bits_per_sample', 64) + set_property(stream, 'bits_per_raw_sample', 64) + return info + + +def mediainfo(filepath): + """Return dictionary with media info(codec, duration, size, bitrate...) from filepath + """ + + prober = get_prober_name() + command_args = [ + "-v", "quiet", + "-show_format", + "-show_streams", + filepath + ] + + command = [prober, '-of', 'old'] + command_args + res = Popen(command, stdout=PIPE) + output = res.communicate()[0].decode("utf-8") + + if res.returncode != 0: + command = [prober] + command_args + output = Popen(command, stdout=PIPE).communicate()[0].decode("utf-8") + + rgx = re.compile(r"(?:(?P.*?):)?(?P.*?)\=(?P.*?)$") + info = {} + + if sys.platform == 'win32': + output = output.replace("\r", "") + + for line in output.split("\n"): + # print(line) + mobj = rgx.match(line) + + if mobj: + # print(mobj.groups()) + inner_dict, key, value = mobj.groups() + + if inner_dict: + try: + info[inner_dict] + except KeyError: + info[inner_dict] = {} + info[inner_dict][key] = value + else: + info[key] = value + + return info + + +def cache_codecs(function): + cache = {} + + @wraps(function) + def wrapper(): + try: + return cache[0] + except: + cache[0] = function() + return cache[0] + + return wrapper + + +@cache_codecs +def get_supported_codecs(): + encoder = get_encoder_name() + command = [encoder, "-codecs"] + res = Popen(command, stdout=PIPE, stderr=PIPE) + output = res.communicate()[0].decode("utf-8") + if res.returncode != 0: + return [] + + if sys.platform == 'win32': + output = output.replace("\r", "") + + + rgx = re.compile(r"^([D.][E.][AVS.][I.][L.][S.]) (\w*) +(.*)") + decoders = set() + encoders = set() + for line in output.split('\n'): + match = rgx.match(line.strip()) + if not match: + continue + flags, codec, name = match.groups() + + if flags[0] == 'D': + decoders.add(codec) + + if flags[1] == 'E': + encoders.add(codec) + + return (decoders, encoders) + + +def get_supported_decoders(): + return get_supported_codecs()[0] + + +def get_supported_encoders(): + return get_supported_codecs()[1] + +def stereo_to_ms(audio_segment): + ''' + Left-Right -> Mid-Side + ''' + channel = audio_segment.split_to_mono() + channel = [channel[0].overlay(channel[1]), channel[0].overlay(channel[1].invert_phase())] + return AudioSegment.from_mono_audiosegments(channel[0], channel[1]) + +def ms_to_stereo(audio_segment): + ''' + Mid-Side -> Left-Right + ''' + channel = audio_segment.split_to_mono() + channel = [channel[0].overlay(channel[1]) - 3, channel[0].overlay(channel[1].invert_phase()) - 3] + return AudioSegment.from_mono_audiosegments(channel[0], channel[1]) + diff --git a/sbapp/pyogg/__init__.py b/sbapp/pyogg/__init__.py new file mode 100644 index 0000000..a97b0d2 --- /dev/null +++ b/sbapp/pyogg/__init__.py @@ -0,0 +1,108 @@ +import ctypes + +from .pyogg_error import PyOggError +from .ogg import PYOGG_OGG_AVAIL +from .vorbis import PYOGG_VORBIS_AVAIL, PYOGG_VORBIS_FILE_AVAIL, PYOGG_VORBIS_ENC_AVAIL +from .opus import PYOGG_OPUS_AVAIL, PYOGG_OPUS_FILE_AVAIL, PYOGG_OPUS_ENC_AVAIL +from .flac import PYOGG_FLAC_AVAIL + + +#: PyOgg version number. Versions should comply with PEP440. +__version__ = '0.7' + + +if (PYOGG_OGG_AVAIL and PYOGG_VORBIS_AVAIL and PYOGG_VORBIS_FILE_AVAIL): + # VorbisFile + from .vorbis_file import VorbisFile + # VorbisFileStream + from .vorbis_file_stream import VorbisFileStream + +else: + class VorbisFile: # type: ignore + def __init__(*args, **kw): + if not PYOGG_OGG_AVAIL: + raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + raise PyOggError("The Vorbis libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + + class VorbisFileStream: # type: ignore + def __init__(*args, **kw): + if not PYOGG_OGG_AVAIL: + raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + raise PyOggError("The Vorbis libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + + + +if (PYOGG_OGG_AVAIL and PYOGG_OPUS_AVAIL and PYOGG_OPUS_FILE_AVAIL): + # OpusFile + from .opus_file import OpusFile + # OpusFileStream + from .opus_file_stream import OpusFileStream + +else: + class OpusFile: # type: ignore + def __init__(*args, **kw): + if not PYOGG_OGG_AVAIL: + raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + if not PYOGG_OPUS_AVAIL: + raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + if not PYOGG_OPUS_FILE_AVAIL: + raise PyOggError("The OpusFile library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + raise PyOggError("Unknown initialisation error") + + class OpusFileStream: # type: ignore + def __init__(*args, **kw): + if not PYOGG_OGG_AVAIL: + raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + if not PYOGG_OPUS_AVAIL: + raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + if not PYOGG_OPUS_FILE_AVAIL: + raise PyOggError("The OpusFile library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + raise PyOggError("Unknown initialisation error") + + +if PYOGG_OPUS_AVAIL: + # OpusEncoder + from .opus_encoder import OpusEncoder + # OpusBufferedEncoder + from .opus_buffered_encoder import OpusBufferedEncoder + # OpusDecoder + from .opus_decoder import OpusDecoder + +else: + class OpusEncoder: # type: ignore + def __init__(*args, **kw): + raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + + class OpusBufferedEncoder: # type: ignore + def __init__(*args, **kw): + raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + + class OpusDecoder: # type: ignore + def __init__(*args, **kw): + raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + +if (PYOGG_OGG_AVAIL and PYOGG_OPUS_AVAIL): + # OggOpusWriter + from .ogg_opus_writer import OggOpusWriter + +else: + class OggOpusWriter: # type: ignore + def __init__(*args, **kw): + if not PYOGG_OGG_AVAIL: + raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + raise PyOggError("The Opus library was't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + + +if PYOGG_FLAC_AVAIL: + # FlacFile + from .flac_file import FlacFile + # FlacFileStream + from .flac_file_stream import FlacFileStream +else: + class FlacFile: # type: ignore + def __init__(*args, **kw): + raise PyOggError("The FLAC libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") + + class FlacFileStream: # type: ignore + def __init__(*args, **kw): + raise PyOggError("The FLAC libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)") diff --git a/sbapp/pyogg/audio_file.py b/sbapp/pyogg/audio_file.py new file mode 100644 index 0000000..4fb77a2 --- /dev/null +++ b/sbapp/pyogg/audio_file.py @@ -0,0 +1,59 @@ +from .pyogg_error import PyOggError + +class AudioFile: + """Abstract base class for audio files. + + This class is a base class for audio files (such as Vorbis, Opus, + and FLAC). It should not be instatiated directly. + """ + + def __init__(self): + raise PyOggError("AudioFile is an Abstract Base Class "+ + "and should not be instantiated") + + def as_array(self): + """Returns the buffer as a NumPy array. + + The shape of the returned array is in units of (number of + samples per channel, number of channels). + + The data type is either 8-bit or 16-bit signed integers, + depending on bytes_per_sample. + + The buffer is not copied, but rather the NumPy array + shares the memory with the buffer. + + """ + # Assumes that self.buffer is a one-dimensional array of + # bytes and that channels are interleaved. + + import numpy # type: ignore + + assert self.buffer is not None + assert self.channels is not None + + # The following code assumes that the bytes in the buffer + # represent 8-bit or 16-bit signed ints. Ensure the number of + # bytes per sample matches that assumption. + assert self.bytes_per_sample == 1 or self.bytes_per_sample == 2 + + # Create a dictionary mapping bytes per sample to numpy data + # types + dtype = { + 1: numpy.int8, + 2: numpy.int16 + } + + # Convert the ctypes buffer to a NumPy array + array = numpy.frombuffer( + self.buffer, + dtype=dtype[self.bytes_per_sample] + ) + + # Reshape the array + return array.reshape( + (len(self.buffer) + // self.bytes_per_sample + // self.channels, + self.channels) + ) diff --git a/sbapp/pyogg/flac.py b/sbapp/pyogg/flac.py new file mode 100644 index 0000000..d44509e --- /dev/null +++ b/sbapp/pyogg/flac.py @@ -0,0 +1,2061 @@ +############################################################ +# Flac license: # +############################################################ +""" +Copyright (C) 2000-2009 Josh Coalson +Copyright (C) 2011-2016 Xiph.Org Foundation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +import ctypes +from ctypes import c_int, c_int8, c_int16, c_int32, c_int64, c_uint, c_uint8, c_uint16, c_uint32, c_uint64, c_float, c_long, c_ulong, c_char, c_bool, c_char_p, c_ubyte, c_longlong, c_ulonglong, c_size_t, c_void_p, c_double, POINTER, pointer, cast, CFUNCTYPE, Structure, Union +import ctypes.util +import sys +from traceback import print_exc as _print_exc +import os + +from .ogg import * + +from .library_loader import ExternalLibrary, ExternalLibraryError + +__here = os.getcwd() + +libflac = None + +try: + names = { + "Windows": "libFLAC.dll", + "Darwin": "libFLAC.8.dylib", + "external": "FLAC" + } + libflac = Library.load(names, tests = [lambda lib: hasattr(lib, "FLAC__EntropyCodingMethodTypeString")]) +except ExternalLibraryError: + pass +except: + _print_exc() + +if libflac: + PYOGG_FLAC_AVAIL = True +else: + PYOGG_FLAC_AVAIL = False + +# ctypes +c_ubyte_p = POINTER(c_ubyte) +c_uchar_p = c_ubyte_p +c_uint_p = POINTER(c_uint) +c_size_t_p = POINTER(c_size_t) +c_off_t = c_int32 +# /ctypes + +if PYOGG_FLAC_AVAIL: + # Sanity check also satisfies mypy type checking + assert libflac is not None + + # ordinals + + FLAC__int8 = c_int8 + FLAC__uint8 = c_uint8 + + FLAC__int16 = c_int16 + + FLAC__int32 = c_int32 + FLAC__int32_p = POINTER(FLAC__int32) + + FLAC__int64 = c_int64 + FLAC__uint16 = c_uint16 + FLAC__uint32 = c_uint32 + FLAC__uint64 = c_uint64 + + FLAC__uint64_p = POINTER(FLAC__uint64) + + FLAC__bool = c_bool + + FLAC__byte = c_uint8 + + FLAC__byte_p = POINTER(FLAC__byte) + + c_char_p_p = POINTER(c_char_p) + + # /ordinals + + # callback + + FLAC__IOHandle = CFUNCTYPE(c_void_p) + + FLAC__IOCallback_Read = CFUNCTYPE(c_size_t, + c_void_p, + c_size_t, + c_size_t, + FLAC__IOHandle) + + FLAC__IOCallback_Write = CFUNCTYPE(c_size_t, c_void_p, c_size_t, c_size_t, FLAC__IOHandle) + + FLAC__IOCallback_Seek = CFUNCTYPE(c_int, FLAC__IOHandle, FLAC__int64, c_int) + + FLAC__IOCallback_Tell = CFUNCTYPE(FLAC__int64, FLAC__IOHandle) + + FLAC__IOCallback_Eof = CFUNCTYPE(c_int, FLAC__IOHandle) + + FLAC__IOCallback_Close = CFUNCTYPE(c_int, FLAC__IOHandle) + + class FLAC__IOCallbacks(Structure): + _fields_ = [("read", FLAC__IOCallback_Read), + ("write", FLAC__IOCallback_Write), + ("seek", FLAC__IOCallback_Seek), + ("tell", FLAC__IOCallback_Tell), + ("eof", FLAC__IOCallback_Eof), + ("close", FLAC__IOCallback_Close)] + + # /callback + + # format + + FLAC__MAX_METADATA_TYPE_CODE =(126) + FLAC__MIN_BLOCK_SIZE =(16) + FLAC__MAX_BLOCK_SIZE =(65535) + FLAC__SUBSET_MAX_BLOCK_SIZE_48000HZ =(4608) + FLAC__MAX_CHANNELS =(8) + FLAC__MIN_BITS_PER_SAMPLE =(4) + FLAC__MAX_BITS_PER_SAMPLE =(32) + FLAC__REFERENCE_CODEC_MAX_BITS_PER_SAMPLE =(24) + FLAC__MAX_SAMPLE_RATE =(655350) + FLAC__MAX_LPC_ORDER =(32) + FLAC__SUBSET_MAX_LPC_ORDER_48000HZ =(12) + FLAC__MIN_QLP_COEFF_PRECISION =(5) + FLAC__MAX_QLP_COEFF_PRECISION =(15) + FLAC__MAX_FIXED_ORDER =(4) + FLAC__MAX_RICE_PARTITION_ORDER =(15) + FLAC__SUBSET_MAX_RICE_PARTITION_ORDER =(8) + + FLAC__VERSION_STRING = c_char_p.in_dll(libflac, "FLAC__VERSION_STRING") + + FLAC__VENDOR_STRING = c_char_p.in_dll(libflac, "FLAC__VENDOR_STRING") + + FLAC__STREAM_SYNC_STRING = (FLAC__byte * 4).in_dll(libflac, "FLAC__STREAM_SYNC_STRING") + + FLAC__STREAM_SYNC = c_uint.in_dll(libflac, "FLAC__STREAM_SYNC") + + FLAC__STREAM_SYNC_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_SYNC_LEN") + + FLAC__STREAM_SYNC_LENGTH =(4) + + + + FLAC__EntropyCodingMethodType = c_int + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE = 0 + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE2 = 1 + + + + libflac.FLAC__EntropyCodingMethodTypeString.restype = c_char_p + libflac.FLAC__EntropyCodingMethodTypeString.argtypes = [] + + def FLAC__EntropyCodingMethodTypeString(): + return libflac.FLAC__EntropyCodingMethodTypeString() + + + + class FLAC__EntropyCodingMethod_PartitionedRiceContents(Structure): + _fields_ = [("parameters", c_uint_p), + ("raw_bits", c_uint_p), + ("capacity_by_order", c_uint)] + + class FLAC__EntropyCodingMethod_PartitionedRice(Structure): + _fields_ = [("order", c_uint), + ("contents", POINTER(FLAC__EntropyCodingMethod_PartitionedRiceContents))] + + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_ORDER_LEN = c_uint.in_dll(libflac, "FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_ORDER_LEN") + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_PARAMETER_LEN = c_uint.in_dll(libflac, "FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_PARAMETER_LEN") + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE2_PARAMETER_LEN = c_uint.in_dll(libflac, "FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE2_PARAMETER_LEN") + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_RAW_LEN = c_uint.in_dll(libflac, "FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_RAW_LEN") + + FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_ESCAPE_PARAMETER = c_uint.in_dll(libflac, "FLAC__ENTROPY_CODING_METHOD_PARTITIONED_RICE_ESCAPE_PARAMETER") + + + class FLAC__EntropyCodingMethod_data(Union): + _fields_ = [("partitioned_rice", FLAC__EntropyCodingMethod_PartitionedRice)] + + class FLAC__EntropyCodingMethod(Structure): + _fields_ = [("type", POINTER(FLAC__EntropyCodingMethodType)), + ("data", FLAC__EntropyCodingMethod_data)] + + FLAC__ENTROPY_CODING_METHOD_TYPE_LEN = c_uint.in_dll(libflac, "FLAC__ENTROPY_CODING_METHOD_TYPE_LEN") + + + + FLAC__SubframeType = c_int + FLAC__SUBFRAME_TYPE_CONSTANT = 0 + FLAC__SUBFRAME_TYPE_VERBATIM = 1 + FLAC__SUBFRAME_TYPE_FIXED = 2 + FLAC__SUBFRAME_TYPE_LPC = 3 + + + + libflac.FLAC__SubframeTypeString.restype = c_char_p + libflac.FLAC__SubframeTypeString.argtypes = [] + + def FLAC__SubframeTypeString(): + return libflac.FLAC__SubframeTypeString() + + + + class FLAC__Subframe_Constant(Structure): + _fields_ = [("value", FLAC__int32)] + + + class FLAC__Subframe_Verbatim(Structure): + _fields_ = [("data", FLAC__int32_p)] + + + class FLAC__Subframe_Fixed(Structure): + _fields_ = [("entropy_coding_method", FLAC__EntropyCodingMethod), + ("order", c_uint), + ("warmup", FLAC__int32 * FLAC__MAX_FIXED_ORDER), + ("residual", FLAC__int32_p)] + + + class FLAC__Subframe_LPC(Structure): + _fields_ = [("entropy_coding_method", FLAC__EntropyCodingMethod), + ("order", c_uint), + ("qlp_coeff_precision", c_uint), + ("quantization_level", c_int), + ("qlp_coeff", FLAC__int32 * FLAC__MAX_LPC_ORDER), + ("warmup", FLAC__int32 * FLAC__MAX_LPC_ORDER), + ("residual", FLAC__int32_p)] + + + FLAC__SUBFRAME_LPC_QLP_COEFF_PRECISION_LEN = c_uint.in_dll(libflac, "FLAC__SUBFRAME_LPC_QLP_COEFF_PRECISION_LEN") + + FLAC__SUBFRAME_LPC_QLP_SHIFT_LEN = c_uint.in_dll(libflac, "FLAC__SUBFRAME_LPC_QLP_SHIFT_LEN") + + + + class FLAC__Subframe_data(Union): + _fields_ = [("constant", FLAC__Subframe_Constant), + ("fixed", FLAC__Subframe_Fixed), + ("lpc", FLAC__Subframe_LPC), + ("verbatim", FLAC__Subframe_Verbatim)] + + class FLAC__Subframe(Structure): + _fields_ = [("type", FLAC__SubframeType), + ("data", FLAC__Subframe_data), + ("wasted_bits", c_uint)] + + + FLAC__SUBFRAME_ZERO_PAD_LEN = c_uint.in_dll(libflac, "FLAC__SUBFRAME_ZERO_PAD_LEN") + + FLAC__SUBFRAME_TYPE_LEN = c_uint.in_dll(libflac, "FLAC__SUBFRAME_TYPE_LEN") + + FLAC__SUBFRAME_WASTED_BITS_FLAG_LEN = c_uint.in_dll(libflac, "FLAC__SUBFRAME_WASTED_BITS_FLAG_LEN") + + FLAC__SUBFRAME_TYPE_CONSTANT_BYTE_ALIGNED_MASK = c_uint.in_dll(libflac, "FLAC__SUBFRAME_TYPE_CONSTANT_BYTE_ALIGNED_MASK") + + FLAC__SUBFRAME_TYPE_VERBATIM_BYTE_ALIGNED_MASK = c_uint.in_dll(libflac, "FLAC__SUBFRAME_TYPE_VERBATIM_BYTE_ALIGNED_MASK") + + FLAC__SUBFRAME_TYPE_FIXED_BYTE_ALIGNED_MASK = c_uint.in_dll(libflac, "FLAC__SUBFRAME_TYPE_FIXED_BYTE_ALIGNED_MASK") + + FLAC__SUBFRAME_TYPE_LPC_BYTE_ALIGNED_MASK = c_uint.in_dll(libflac, "FLAC__SUBFRAME_TYPE_LPC_BYTE_ALIGNED_MASK") + + + FLAC__ChannelAssignment = c_int + + FLAC__CHANNEL_ASSIGNMENT_INDEPENDENT = 0 + FLAC__CHANNEL_ASSIGNMENT_LEFT_SIDE = 1 + FLAC__CHANNEL_ASSIGNMENT_RIGHT_SIDE = 2 + FLAC__CHANNEL_ASSIGNMENT_MID_SIDE = 3 + + + + libflac.FLAC__ChannelAssignmentString.restype = c_char_p + libflac.FLAC__ChannelAssignmentString.argtypes = [] + + def FLAC__ChannelAssignmentString(): + return libflac.FLAC__ChannelAssignmentString() + + FLAC__FrameNumberType = c_int + + + libflac.FLAC__FrameNumberTypeString.restype = c_char_p + libflac.FLAC__FrameNumberTypeString.argtypes = [] + + def FLAC__FrameNumberTypeString(): + return libflac.FLAC__FrameNumberTypeString() + + + class FLAC__FrameHeader_number(Union): + _fields_ =[("frame_number", FLAC__uint32), + ("sample_number", FLAC__uint64)] + + class FLAC__FrameHeader(Structure): + _fields_ = [("blocksize", c_uint), + ("sample_rate", c_uint), + ("channels", c_uint), + ("channel_assignment", FLAC__ChannelAssignment), + ("bits_per_sample", c_uint), + ("number_type", FLAC__FrameNumberType), + ("number", FLAC__FrameHeader_number), + ("crc", FLAC__uint8)] + + + FLAC__FRAME_HEADER_SYNC = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_SYNC") + + FLAC__FRAME_HEADER_RESERVED_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_RESERVED_LEN") + + FLAC__FRAME_HEADER_BLOCKING_STRATEGY_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_BLOCKING_STRATEGY_LEN") + + FLAC__FRAME_HEADER_BLOCK_SIZE_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_BLOCK_SIZE_LEN") + + FLAC__FRAME_HEADER_SAMPLE_RATE_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_SAMPLE_RATE_LEN") + + FLAC__FRAME_HEADER_CHANNEL_ASSIGNMENT_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_CHANNEL_ASSIGNMENT_LEN") + + FLAC__FRAME_HEADER_BITS_PER_SAMPLE_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_BITS_PER_SAMPLE_LEN") + + FLAC__FRAME_HEADER_ZERO_PAD_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_ZERO_PAD_LEN") + + FLAC__FRAME_HEADER_CRC_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_HEADER_CRC_LEN") + + + + class FLAC__FrameFooter(Structure): + _fields_ = [("crc", FLAC__uint16)] + + FLAC__FRAME_FOOTER_CRC_LEN = c_uint.in_dll(libflac, "FLAC__FRAME_FOOTER_CRC_LEN") + + + + class FLAC__Frame(Structure): + _fields_ = [("header", FLAC__FrameHeader), + ("subframes", FLAC__Subframe * FLAC__MAX_CHANNELS), + ("footer", FLAC__FrameFooter)] + + + FLAC__MetadataType = c_int + + FLAC__METADATA_TYPE_STREAMINFO = 0 + + FLAC__METADATA_TYPE_PADDING = 1 + + FLAC__METADATA_TYPE_APPLICATION = 2 + + FLAC__METADATA_TYPE_SEEKTABLE = 3 + + FLAC__METADATA_TYPE_VORBIS_COMMENT = 4 + + FLAC__METADATA_TYPE_CUESHEET = 5 + + FLAC__METADATA_TYPE_PICTURE = 6 + + FLAC__METADATA_TYPE_UNDEFINED = 7 + + FLAC__MAX_METADATA_TYPE = FLAC__MAX_METADATA_TYPE_CODE + + + + libflac.FLAC__MetadataTypeString.restype = c_char_p + libflac.FLAC__MetadataTypeString.argtypes = [] + + def FLAC__MetadataTypeString(): + return libflac.FLAC__MetadataTypeString() + + + + class FLAC__StreamMetadata_StreamInfo(Structure): + _fields_ = [("min_blocksize", c_uint), + ("max_framesize", c_uint), + ("min_framesize", c_uint), + ("max_framesize", c_uint), + ("sample_rate", c_uint), + ("channels", c_uint), + ("bits_per_sample", c_uint), + ("total_samples", FLAC__uint64), + ("md5sum", FLAC__byte*16)] + + FLAC__STREAM_METADATA_STREAMINFO_MIN_BLOCK_SIZE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_MIN_BLOCK_SIZE_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_MAX_BLOCK_SIZE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_MAX_BLOCK_SIZE_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_MIN_FRAME_SIZE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_MIN_FRAME_SIZE_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_MAX_FRAME_SIZE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_MAX_FRAME_SIZE_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_SAMPLE_RATE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_SAMPLE_RATE_LEN") + + + FLAC__STREAM_METADATA_STREAMINFO_CHANNELS_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_CHANNELS_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_BITS_PER_SAMPLE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_BITS_PER_SAMPLE_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_TOTAL_SAMPLES_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_TOTAL_SAMPLES_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_MD5SUM_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_STREAMINFO_MD5SUM_LEN") + + FLAC__STREAM_METADATA_STREAMINFO_LENGTH =(34) + + + class FLAC__StreamMetadata_Padding(Structure): + _fields_ = [("dummy", c_int)] + + + + class FLAC__StreamMetadata_Application(Structure): + _fields_ = [("id", FLAC__byte*4), + ("data", FLAC__byte_p)] + + FLAC__STREAM_METADATA_APPLICATION_ID_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_APPLICATION_ID_LEN") + + + class FLAC__StreamMetadata_SeekPoint(Structure): + _fields_ = [("sample_number", FLAC__uint64), + ("stream_offset", FLAC__uint64), + ("frame_samples", c_uint)] + + FLAC__STREAM_METADATA_SEEKPOINT_SAMPLE_NUMBER_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_SEEKPOINT_SAMPLE_NUMBER_LEN") + + FLAC__STREAM_METADATA_SEEKPOINT_STREAM_OFFSET_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_SEEKPOINT_STREAM_OFFSET_LEN") + + FLAC__STREAM_METADATA_SEEKPOINT_FRAME_SAMPLES_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_SEEKPOINT_FRAME_SAMPLES_LEN") + + FLAC__STREAM_METADATA_SEEKPOINT_LENGTH =(18) + + + FLAC__STREAM_METADATA_SEEKPOINT_PLACEHOLDER = FLAC__uint64.in_dll(libflac, "FLAC__STREAM_METADATA_SEEKPOINT_PLACEHOLDER") + + class FLAC__StreamMetadata_SeekTable(Structure): + _fields_ = [("num_points", c_uint), + ("points", POINTER(FLAC__StreamMetadata_SeekPoint))] + + class FLAC__StreamMetadata_VorbisComment_Entry(Structure): + _fields_ = [("length", FLAC__uint32), + ("entry", FLAC__byte_p)] + + FLAC__STREAM_METADATA_VORBIS_COMMENT_ENTRY_LENGTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_VORBIS_COMMENT_ENTRY_LENGTH_LEN") + + + class FLAC__StreamMetadata_VorbisComment(Structure): + _fields_ = [("vendor_string", FLAC__StreamMetadata_VorbisComment_Entry), + ("num_comments", FLAC__uint32), + ("comments", POINTER(FLAC__StreamMetadata_VorbisComment_Entry))] + + FLAC__STREAM_METADATA_VORBIS_COMMENT_NUM_COMMENTS_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_VORBIS_COMMENT_NUM_COMMENTS_LEN") + + + class FLAC__StreamMetadata_CueSheet_Index(Structure): + _fields_ = [("offset", FLAC__uint64), + ("number", FLAC__byte)] + + + FLAC__STREAM_METADATA_CUESHEET_INDEX_OFFSET_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_INDEX_OFFSET_LEN") + + FLAC__STREAM_METADATA_CUESHEET_INDEX_NUMBER_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_INDEX_NUMBER_LEN") + + FLAC__STREAM_METADATA_CUESHEET_INDEX_RESERVED_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_INDEX_RESERVED_LEN") + + + class FLAC__StreamMetadata_CueSheet_Track(Structure): + _fields_ = [("offset", FLAC__uint64), + ("number", FLAC__byte), + ("isrc", c_char*13), + ("type", c_uint), + ("pre_emphasis", c_uint), + ("num_indices", FLAC__byte), + ("indices", POINTER(FLAC__StreamMetadata_CueSheet_Index))] + + FLAC__STREAM_METADATA_CUESHEET_TRACK_OFFSET_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_OFFSET_LEN") + + FLAC__STREAM_METADATA_CUESHEET_TRACK_NUMBER_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_NUMBER_LEN") + + FLAC__STREAM_METADATA_CUESHEET_TRACK_ISRC_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_ISRC_LEN") + + FLAC__STREAM_METADATA_CUESHEET_TRACK_TYPE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_TYPE_LEN") + + FLAC__STREAM_METADATA_CUESHEET_TRACK_PRE_EMPHASIS_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_PRE_EMPHASIS_LEN") + + FLAC__STREAM_METADATA_CUESHEET_TRACK_RESERVED_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_RESERVED_LEN") + + FLAC__STREAM_METADATA_CUESHEET_TRACK_NUM_INDICES_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_TRACK_NUM_INDICES_LEN") + + + class FLAC__StreamMetadata_CueSheet(Structure): + _fields_ = [("media_catalog_number", c_char*129), + ("lead_in", FLAC__uint64), + ("is_cd", FLAC__bool), + ("num_tracks", c_uint), + ("tracks", POINTER(FLAC__StreamMetadata_CueSheet_Track))] + + FLAC__STREAM_METADATA_CUESHEET_MEDIA_CATALOG_NUMBER_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_MEDIA_CATALOG_NUMBER_LEN") + + + FLAC__STREAM_METADATA_CUESHEET_LEAD_IN_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_LEAD_IN_LEN") + + FLAC__STREAM_METADATA_CUESHEET_IS_CD_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_IS_CD_LEN") + + FLAC__STREAM_METADATA_CUESHEET_RESERVED_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_RESERVED_LEN") + + FLAC__STREAM_METADATA_CUESHEET_NUM_TRACKS_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_CUESHEET_NUM_TRACKS_LEN") + + + FLAC__StreamMetadata_Picture_Type = c_int + FLAC__STREAM_METADATA_PICTURE_TYPE_OTHER = 0 + FLAC__STREAM_METADATA_PICTURE_TYPE_FILE_ICON_STANDARD = 1 + FLAC__STREAM_METADATA_PICTURE_TYPE_FILE_ICON = 2 + FLAC__STREAM_METADATA_PICTURE_TYPE_FRONT_COVER = 3 + FLAC__STREAM_METADATA_PICTURE_TYPE_BACK_COVER = 4 + FLAC__STREAM_METADATA_PICTURE_TYPE_LEAFLET_PAGE = 5 + FLAC__STREAM_METADATA_PICTURE_TYPE_MEDIA = 6 + FLAC__STREAM_METADATA_PICTURE_TYPE_LEAD_ARTIST = 7 + FLAC__STREAM_METADATA_PICTURE_TYPE_ARTIST = 8 + FLAC__STREAM_METADATA_PICTURE_TYPE_CONDUCTOR = 9 + FLAC__STREAM_METADATA_PICTURE_TYPE_BAND = 10 + FLAC__STREAM_METADATA_PICTURE_TYPE_COMPOSER = 11 + FLAC__STREAM_METADATA_PICTURE_TYPE_LYRICIST = 12 + FLAC__STREAM_METADATA_PICTURE_TYPE_RECORDING_LOCATION = 13 + FLAC__STREAM_METADATA_PICTURE_TYPE_DURING_RECORDING = 14 + FLAC__STREAM_METADATA_PICTURE_TYPE_DURING_PERFORMANCE = 15 + FLAC__STREAM_METADATA_PICTURE_TYPE_VIDEO_SCREEN_CAPTURE = 16 + FLAC__STREAM_METADATA_PICTURE_TYPE_FISH = 17 + FLAC__STREAM_METADATA_PICTURE_TYPE_ILLUSTRATION = 18 + FLAC__STREAM_METADATA_PICTURE_TYPE_BAND_LOGOTYPE = 19 + FLAC__STREAM_METADATA_PICTURE_TYPE_PUBLISHER_LOGOTYPE = 20 + + + libflac.FLAC__StreamMetadata_Picture_TypeString.restype = c_char_p + libflac.FLAC__StreamMetadata_Picture_TypeString.argtypes = [] + + def FLAC__StreamMetadata_Picture_TypeString(): + return libflac.FLAC__StreamMetadata_Picture_TypeString() + + + class FLAC__StreamMetadata_Picture(Structure): + _fields_ = [("type", FLAC__StreamMetadata_Picture_Type), + ("mime_type", c_char_p), + ("description", FLAC__byte_p), + ("width", FLAC__uint32), + ("height", FLAC__uint32), + ("depth", FLAC__uint32), + ("colors", FLAC__uint32), + ("data_length", FLAC__uint32), + ("data", FLAC__byte)] + + FLAC__STREAM_METADATA_PICTURE_TYPE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_TYPE_LEN") + + FLAC__STREAM_METADATA_PICTURE_MIME_TYPE_LENGTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_MIME_TYPE_LENGTH_LEN") + + FLAC__STREAM_METADATA_PICTURE_DESCRIPTION_LENGTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_DESCRIPTION_LENGTH_LEN") + + FLAC__STREAM_METADATA_PICTURE_WIDTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_WIDTH_LEN") + + FLAC__STREAM_METADATA_PICTURE_HEIGHT_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_HEIGHT_LEN") + + + FLAC__STREAM_METADATA_PICTURE_DEPTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_DEPTH_LEN") + + FLAC__STREAM_METADATA_PICTURE_COLORS_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_COLORS_LEN") + + FLAC__STREAM_METADATA_PICTURE_DATA_LENGTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_PICTURE_DATA_LENGTH_LEN") + + + class FLAC__StreamMetadata_Unknown(Structure): + _fields_ = [("data", FLAC__byte_p)] + + + class FLAC__StreamMetadata_data(Union): + _fields_ = [("stream_info", FLAC__StreamMetadata_StreamInfo), + ("padding", FLAC__StreamMetadata_Padding), + ("application", FLAC__StreamMetadata_Application), + ("seek_table", FLAC__StreamMetadata_SeekTable), + ("vorbis_comment", FLAC__StreamMetadata_VorbisComment), + ("cue_sheet", FLAC__StreamMetadata_CueSheet), + ("picture", FLAC__StreamMetadata_Picture), + ("unknown", FLAC__StreamMetadata_Unknown)] + + class FLAC__StreamMetadata(Structure): + _fields_ = [("type", FLAC__MetadataType), + ("is_last", FLAC__bool), + ("length", c_uint), + ("data", FLAC__StreamMetadata_data)] + + FLAC__STREAM_METADATA_IS_LAST_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_IS_LAST_LEN") + + FLAC__STREAM_METADATA_TYPE_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_TYPE_LEN") + + FLAC__STREAM_METADATA_LENGTH_LEN = c_uint.in_dll(libflac, "FLAC__STREAM_METADATA_LENGTH_LEN") + + FLAC__STREAM_METADATA_HEADER_LENGTH =(4) + + + + libflac.FLAC__format_sample_rate_is_valid.restype = FLAC__bool + libflac.FLAC__format_sample_rate_is_valid.argtypes = [c_uint] + + def FLAC__format_sample_rate_is_valid(sample_rate): + return libflac.FLAC__format_sample_rate_is_valid(sample_rate) + + + libflac.FLAC__format_blocksize_is_subset.restype = FLAC__bool + libflac.FLAC__format_blocksize_is_subset.argtypes = [c_uint, c_uint] + + def FLAC__format_blocksize_is_subset(blocksize, sample_rate): + return libflac.FLAC__format_blocksize_is_subset(blocksize, sample_rate) + + + libflac.FLAC__format_sample_rate_is_subset.restype = FLAC__bool + libflac.FLAC__format_sample_rate_is_subset.argtypes = [c_uint] + + def FLAC__format_sample_rate_is_subset(sample_rate): + return libflac.FLAC__format_sample_rate_is_subset(sample_rate) + + + libflac.FLAC__format_vorbiscomment_entry_name_is_legal.restype = FLAC__bool + libflac.FLAC__format_vorbiscomment_entry_name_is_legal.argtypes = [c_char_p] + + def FLAC__format_vorbiscomment_entry_name_is_legal(name): + return libflac.FLAC__format_vorbiscomment_entry_name_is_legal(name) + + libflac.FLAC__format_vorbiscomment_entry_value_is_legal.restype = FLAC__bool + libflac.FLAC__format_vorbiscomment_entry_value_is_legal.argtypes = [FLAC__byte_p, c_uint] + + def FLAC__format_vorbiscomment_entry_value_is_legal(value, length): + return libflac.FLAC__format_vorbiscomment_entry_value_is_legal(value, length) + + libflac.FLAC__format_vorbiscomment_entry_is_legal.restype = FLAC__bool + libflac.FLAC__format_vorbiscomment_entry_is_legal.argtypes = [FLAC__byte_p, c_uint] + + def FLAC__format_vorbiscomment_entry_is_legal(entry, length): + return libflac.FLAC__format_vorbiscomment_entry_is_legal(entry, length) + + libflac.FLAC__format_seektable_is_legal.restype = FLAC__bool + libflac.FLAC__format_seektable_is_legal.argtypes = [POINTER(FLAC__StreamMetadata_SeekTable)] + + def FLAC__format_seektable_is_legal(seek_table): + return libflac.FLAC__format_seektable_is_legal(seek_table) + + + libflac.FLAC__format_seektable_sort.restype = FLAC__bool + libflac.FLAC__format_seektable_sort.argtypes = [POINTER(FLAC__StreamMetadata_SeekTable)] + + def FLAC__format_seektable_sort(seek_table): + return libflac.FLAC__format_seektable_sort(seek_table) + + libflac.FLAC__format_cuesheet_is_legal.restype = FLAC__bool + libflac.FLAC__format_cuesheet_is_legal.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet), FLAC__bool, c_char_p_p] + + def FLAC__format_cuesheet_is_legal(cue_sheet, check_cd_da_subset, violation): + return libflac.FLAC__format_cuesheet_is_legal(cue_sheet, check_cd_da_subset, violation) + + # /format + + # metadata + + libflac.FLAC__metadata_get_streaminfo.restype = FLAC__bool + libflac.FLAC__metadata_get_streaminfo.argtypes = [c_char_p, POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_get_streaminfo(filename, streaminfo): + return libflac.FLAC__metadata_get_streaminfo(filename, streaminfo) + + libflac.FLAC__metadata_get_tags.restype = FLAC__bool + libflac.FLAC__metadata_get_tags.argtypes = [c_char_p, POINTER(POINTER(FLAC__StreamMetadata))] + + def FLAC__metadata_get_tags(filename, tags): + return libflac.FLAC__metadata_get_tags(filename, tags) + + libflac.FLAC__metadata_get_cuesheet.restype = FLAC__bool + libflac.FLAC__metadata_get_cuesheet.argtypes = [c_char_p, POINTER(POINTER(FLAC__StreamMetadata))] + + def FLAC__metadata_get_cuesheet(filename, cuesheet): + return libflac.FLAC__metadata_get_cuesheet(filename, cuesheet) + + libflac.FLAC__metadata_get_picture.restype = FLAC__bool + libflac.FLAC__metadata_get_picture.argtypes = [c_char_p, POINTER(POINTER(FLAC__StreamMetadata)), FLAC__StreamMetadata_Picture_Type, c_char_p, FLAC__byte_p, c_uint, c_uint, c_uint, c_uint] + + def FLAC__metadata_get_picture(filename, picture, type, mime_type, description, max_width, max_height, max_depth, max_colors): + return libflac.FLAC__metadata_get_picture(filename, picture, type, mime_type, description, max_width, max_height, max_depth, max_colors) + + + class FLAC__Metadata_SimpleIterator(Structure): + _fields_ = [("dummy", c_int)] + + FLAC__Metadata_SimpleIteratorStatus = c_int + + FLAC__METADATA_SIMPLE_ITERATOR_STATUS_OK = 0 + + + libflac.FLAC__Metadata_SimpleIteratorStatusString.restype = c_char_p + libflac.FLAC__Metadata_SimpleIteratorStatusString.argtypes = [] + + def FLAC__Metadata_SimpleIteratorStatusString(): + return libflac.FLAC__Metadata_SimpleIteratorStatusString() + + + libflac.FLAC__metadata_simple_iterator_new.restype = POINTER(FLAC__Metadata_SimpleIterator) + libflac.FLAC__metadata_simple_iterator_new.argtypes = [] + + def FLAC__metadata_simple_iterator_new(): + return libflac.FLAC__metadata_simple_iterator_new() + + + libflac.FLAC__metadata_simple_iterator_delete.restype = None + libflac.FLAC__metadata_simple_iterator_delete.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_delete(iterator): + return libflac.FLAC__metadata_simple_iterator_delete(iterator) + + + libflac.FLAC__metadata_simple_iterator_status.restype = FLAC__Metadata_SimpleIteratorStatus + libflac.FLAC__metadata_simple_iterator_status.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_status(iterator): + return libflac.FLAC__metadata_simple_iterator_status(iterator) + + libflac.FLAC__metadata_simple_iterator_init.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_init.argtypes = [POINTER(FLAC__Metadata_SimpleIterator), c_char_p, FLAC__bool, FLAC__bool] + + def FLAC__metadata_simple_iterator_init(iterator, filename, read_only, preserve_file_stats): + return libflac.FLAC__metadata_simple_iterator_init(iterator, filename, read_only, preserve_file_stats) + + libflac.FLAC__metadata_simple_iterator_is_writable.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_is_writable.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_is_writable(iterator): + return libflac.FLAC__metadata_simple_iterator_is_writable(iterator) + + libflac.FLAC__metadata_simple_iterator_next.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_next.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_next(iterator): + return libflac.FLAC__metadata_simple_iterator_next(iterator) + + libflac.FLAC__metadata_simple_iterator_prev.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_prev.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_prev(iterator): + return libflac.FLAC__metadata_simple_iterator_prev(iterator) + + libflac.FLAC__metadata_simple_iterator_is_last.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_is_last.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_is_last(iterator): + return libflac.FLAC__metadata_simple_iterator_is_last(iterator) + + libflac.FLAC__metadata_simple_iterator_get_block_offset.restype = c_off_t + libflac.FLAC__metadata_simple_iterator_get_block_offset.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_get_block_offset(iterator): + return libflac.FLAC__metadata_simple_iterator_get_block_offset(iterator) + + libflac.FLAC__metadata_simple_iterator_get_block_type.restype = FLAC__MetadataType + libflac.FLAC__metadata_simple_iterator_get_block_type.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_get_block_type(iterator): + return libflac.FLAC__metadata_simple_iterator_get_block_type(iterator) + + libflac.FLAC__metadata_simple_iterator_get_block_length.restype = c_uint + libflac.FLAC__metadata_simple_iterator_get_block_length.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_get_block_length(iterator): + return libflac.FLAC__metadata_simple_iterator_get_block_length(iterator) + + libflac.FLAC__metadata_simple_iterator_get_application_id.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_get_application_id.argtypes = [POINTER(FLAC__Metadata_SimpleIterator), FLAC__byte_p] + + def FLAC__metadata_simple_iterator_get_application_id(iterator, id): + return libflac.FLAC__metadata_simple_iterator_get_application_id(iterator, id) + + libflac.FLAC__metadata_simple_iterator_get_block.restype = POINTER(FLAC__StreamMetadata) + libflac.FLAC__metadata_simple_iterator_get_block.argtypes = [POINTER(FLAC__Metadata_SimpleIterator)] + + def FLAC__metadata_simple_iterator_get_block(iterator): + return libflac.FLAC__metadata_simple_iterator_get_block(iterator) + + libflac.FLAC__metadata_simple_iterator_set_block.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_set_block.argtypes = [POINTER(FLAC__Metadata_SimpleIterator), POINTER(FLAC__StreamMetadata), FLAC__bool] + + def FLAC__metadata_simple_iterator_set_block(iterator, block, use_padding): + return libflac.FLAC__metadata_simple_iterator_set_block(iterator, block, use_padding) + + libflac.FLAC__metadata_simple_iterator_insert_block_after.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_insert_block_after.argtypes = [POINTER(FLAC__Metadata_SimpleIterator), POINTER(FLAC__StreamMetadata), FLAC__bool] + + def FLAC__metadata_simple_iterator_insert_block_after(iterator, block, use_padding): + return libflac.FLAC__metadata_simple_iterator_insert_block_after(iterator, block, use_padding) + + libflac.FLAC__metadata_simple_iterator_delete_block.restype = FLAC__bool + libflac.FLAC__metadata_simple_iterator_delete_block.argtypes = [POINTER(FLAC__Metadata_SimpleIterator), FLAC__bool] + + def FLAC__metadata_simple_iterator_delete_block(iterator, use_padding): + return libflac.FLAC__metadata_simple_iterator_delete_block(iterator, use_padding) + + class FLAC__Metadata_Chain(Structure): + _fields_ = [("dummy", c_int)] + + class FLAC__Metadata_Iterator(Structure): + _fields_ = [("dummy", c_int)] + + FLAC__Metadata_ChainStatus = c_int + + FLAC__METADATA_CHAIN_STATUS_OK = 0 + + libflac.FLAC__Metadata_ChainStatusString.restype = c_char_p + libflac.FLAC__Metadata_ChainStatusString.argtypes = [] + + def FLAC__Metadata_ChainStatusString(): + return libflac.FLAC__Metadata_ChainStatusString() + + libflac.FLAC__metadata_chain_new.restype = POINTER(FLAC__Metadata_Chain) + libflac.FLAC__metadata_chain_new.argtypes = [] + + def FLAC__metadata_chain_new(): + return libflac.FLAC__metadata_chain_new() + + libflac.FLAC__metadata_chain_delete.restype = None + libflac.FLAC__metadata_chain_delete.argtypes = [POINTER(FLAC__Metadata_Chain)] + + def FLAC__metadata_chain_delete(chain): + return libflac.FLAC__metadata_chain_delete(chain) + + libflac.FLAC__metadata_chain_status.restype = FLAC__Metadata_ChainStatus + libflac.FLAC__metadata_chain_status.argtypes = [POINTER(FLAC__Metadata_Chain)] + + def FLAC__metadata_chain_status(chain): + return libflac.FLAC__metadata_chain_status(chain) + + libflac.FLAC__metadata_chain_read.restype = FLAC__bool + libflac.FLAC__metadata_chain_read.argtypes = [POINTER(FLAC__Metadata_Chain), c_char_p] + + def FLAC__metadata_chain_read(chain, filename): + return libflac.FLAC__metadata_chain_read(chain, filename) + + libflac.FLAC__metadata_chain_read_ogg.restype = FLAC__bool + libflac.FLAC__metadata_chain_read_ogg.argtypes = [POINTER(FLAC__Metadata_Chain), c_char_p] + + def FLAC__metadata_chain_read_ogg(chain, filename): + return libflac.FLAC__metadata_chain_read_ogg(chain, filename) + + libflac.FLAC__metadata_chain_read_with_callbacks.restype = FLAC__bool + libflac.FLAC__metadata_chain_read_with_callbacks.argtypes = [POINTER(FLAC__Metadata_Chain), FLAC__IOHandle, FLAC__IOCallbacks] + + def FLAC__metadata_chain_read_with_callbacks(chain, handle, callbacks): + return libflac.FLAC__metadata_chain_read_with_callbacks(chain, handle, callbacks) + + libflac.FLAC__metadata_chain_read_ogg_with_callbacks.restype = FLAC__bool + libflac.FLAC__metadata_chain_read_ogg_with_callbacks.argtypes = [POINTER(FLAC__Metadata_Chain), FLAC__IOHandle, FLAC__IOCallbacks] + + def FLAC__metadata_chain_read_ogg_with_callbacks(chain, handle, callbacks): + return libflac.FLAC__metadata_chain_read_ogg_with_callbacks(chain, handle, callbacks) + + libflac.FLAC__metadata_chain_check_if_tempfile_needed.restype = FLAC__bool + libflac.FLAC__metadata_chain_check_if_tempfile_needed.argtypes = [POINTER(FLAC__Metadata_Chain), FLAC__bool] + + def FLAC__metadata_chain_check_if_tempfile_needed(chain, use_padding): + return libflac.FLAC__metadata_chain_check_if_tempfile_needed(chain, use_padding) + + libflac.FLAC__metadata_chain_write.restype = FLAC__bool + libflac.FLAC__metadata_chain_write.argtypes = [POINTER(FLAC__Metadata_Chain), FLAC__bool, FLAC__bool] + + def FLAC__metadata_chain_write(chain, use_padding, preserve_file_stats): + return libflac.FLAC__metadata_chain_write(chain, use_padding, preserve_file_stats) + + libflac.FLAC__metadata_chain_write_with_callbacks.restype = FLAC__bool + libflac.FLAC__metadata_chain_write_with_callbacks.argtypes = [POINTER(FLAC__Metadata_Chain), FLAC__bool, FLAC__IOHandle, FLAC__IOCallbacks] + + def FLAC__metadata_chain_write_with_callbacks(chain, use_padding, handle, callbacks): + return libflac.FLAC__metadata_chain_write_with_callbacks(chain, use_padding, handle, callbacks) + + libflac.FLAC__metadata_chain_write_with_callbacks_and_tempfile.restype = FLAC__bool + libflac.FLAC__metadata_chain_write_with_callbacks_and_tempfile.argtypes = [POINTER(FLAC__Metadata_Chain), FLAC__bool, FLAC__IOHandle, FLAC__IOCallbacks, FLAC__IOHandle, FLAC__IOCallbacks] + + def FLAC__metadata_chain_write_with_callbacks_and_tempfile(chain, use_padding, handle, callbacks, temp_handle, temp_callbacks): + return libflac.FLAC__metadata_chain_write_with_callbacks_and_tempfile(chain, use_padding, handle, callbacks, temp_handle, temp_callbacks) + + libflac.FLAC__metadata_chain_merge_padding.restype = None + libflac.FLAC__metadata_chain_merge_padding.argtypes = [POINTER(FLAC__Metadata_Chain)] + + def FLAC__metadata_chain_merge_padding(chain): + return libflac.FLAC__metadata_chain_merge_padding(chain) + + libflac.FLAC__metadata_chain_sort_padding.restype = None + libflac.FLAC__metadata_chain_sort_padding.argtypes = [POINTER(FLAC__Metadata_Chain)] + + def FLAC__metadata_chain_sort_padding(chain): + return libflac.FLAC__metadata_chain_sort_padding(chain) + + libflac.FLAC__metadata_iterator_new.restype = POINTER(FLAC__Metadata_Iterator) + libflac.FLAC__metadata_iterator_new.argtypes = [] + + def FLAC__metadata_iterator_new(): + return libflac.FLAC__metadata_iterator_new() + + libflac.FLAC__metadata_iterator_delete.restype = None + libflac.FLAC__metadata_iterator_delete.argtypes = [POINTER(FLAC__Metadata_Iterator)] + + def FLAC__metadata_iterator_delete(iterator): + return libflac.FLAC__metadata_iterator_delete(iterator) + + libflac.FLAC__metadata_iterator_init.restype = None + libflac.FLAC__metadata_iterator_init.argtypes = [POINTER(FLAC__Metadata_Iterator), POINTER(FLAC__Metadata_Chain)] + + def FLAC__metadata_iterator_init(iterator, chain): + return libflac.FLAC__metadata_iterator_init(iterator, chain) + + libflac.FLAC__metadata_iterator_next.restype = FLAC__bool + libflac.FLAC__metadata_iterator_next.argtypes = [POINTER(FLAC__Metadata_Iterator)] + + def FLAC__metadata_iterator_next(iterator): + return libflac.FLAC__metadata_iterator_next(iterator) + + libflac.FLAC__metadata_iterator_prev.restype = FLAC__bool + libflac.FLAC__metadata_iterator_prev.argtypes = [POINTER(FLAC__Metadata_Iterator)] + + def FLAC__metadata_iterator_prev(iterator): + return libflac.FLAC__metadata_iterator_prev(iterator) + + libflac.FLAC__metadata_iterator_get_block_type.restype = FLAC__MetadataType + libflac.FLAC__metadata_iterator_get_block_type.argtypes = [POINTER(FLAC__Metadata_Iterator)] + + def FLAC__metadata_iterator_get_block_type(iterator): + return libflac.FLAC__metadata_iterator_get_block_type(iterator) + + libflac.FLAC__metadata_iterator_get_block_type.restype = POINTER(FLAC__StreamMetadata) + libflac.FLAC__metadata_iterator_get_block_type.argtypes = [POINTER(FLAC__Metadata_Iterator)] + + def FLAC__metadata_iterator_get_block_type(iterator): + return libflac.FLAC__metadata_iterator_get_block_type(iterator) + + libflac.FLAC__metadata_iterator_set_block.restype = FLAC__bool + libflac.FLAC__metadata_iterator_set_block.argtypes = [POINTER(FLAC__Metadata_Iterator), POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_iterator_set_block(iterator, block): + return libflac.FLAC__metadata_iterator_set_block(iterator, block) + + libflac.FLAC__metadata_iterator_delete_block.restype = FLAC__bool + libflac.FLAC__metadata_iterator_delete_block.argtypes = [POINTER(FLAC__Metadata_Iterator), FLAC__bool] + + def FLAC__metadata_iterator_delete_block(iterator, replace_with_padding): + return libflac.FLAC__metadata_iterator_delete_block(iterator, replace_with_padding) + + libflac.FLAC__metadata_iterator_insert_block_before.restype = FLAC__bool + libflac.FLAC__metadata_iterator_insert_block_before.argtypes = [POINTER(FLAC__Metadata_Iterator), POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_iterator_insert_block_before(iterator, block): + return libflac.FLAC__metadata_iterator_insert_block_before(iterator, block) + + libflac.FLAC__metadata_iterator_insert_block_after.restype = FLAC__bool + libflac.FLAC__metadata_iterator_insert_block_after.argtypes = [POINTER(FLAC__Metadata_Iterator), POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_iterator_insert_block_after(iterator, block): + return libflac.FLAC__metadata_iterator_insert_block_after(iterator, block) + + libflac.FLAC__metadata_object_new.restype = POINTER(FLAC__StreamMetadata) + libflac.FLAC__metadata_object_new.argtypes = [POINTER(FLAC__MetadataType)] + + def FLAC__metadata_object_new(type): + return libflac.FLAC__metadata_object_new(type) + + libflac.FLAC__metadata_object_clone.restype = POINTER(FLAC__StreamMetadata) + libflac.FLAC__metadata_object_clone.argtypes = [POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_object_clone(object): + return libflac.FLAC__metadata_object_clone(object) + + libflac.FLAC__metadata_object_delete.restype = None + libflac.FLAC__metadata_object_delete.argtypes = [POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_object_delete(object): + return libflac.FLAC__metadata_object_delete(object) + + libflac.FLAC__metadata_object_is_equal.restype = FLAC__bool + libflac.FLAC__metadata_object_is_equal.argtypes = [POINTER(FLAC__StreamMetadata), POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_object_is_equal(block1, block2): + return libflac.FLAC__metadata_object_is_equal(block1, block2) + + libflac.FLAC__metadata_object_application_set_data.restype = FLAC__bool + libflac.FLAC__metadata_object_application_set_data.argtypes = [POINTER(FLAC__StreamMetadata), FLAC__byte_p, c_uint, FLAC__bool] + + def FLAC__metadata_object_application_set_data(object, data, length, copy): + return libflac.FLAC__metadata_object_application_set_data(object, data, length, copy) + + libflac.FLAC__metadata_object_seektable_resize_points.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_resize_points.argtypes = [POINTER(FLAC__StreamMetadata),c_uint] + + def FLAC__metadata_object_seektable_resize_points(object, new_num_points): + return libflac.FLAC__metadata_object_seektable_resize_points(object, new_num_points) + + libflac.FLAC__metadata_object_seektable_set_point.restype = None + libflac.FLAC__metadata_object_seektable_set_point.argtypes = [POINTER(FLAC__StreamMetadata),c_uint, FLAC__StreamMetadata_SeekPoint] + + def FLAC__metadata_object_seektable_set_point(object, point_num, point): + return libflac.FLAC__metadata_object_seektable_set_point(object, point_num, point) + + libflac.FLAC__metadata_object_seektable_insert_point.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_insert_point.argtypes = [POINTER(FLAC__StreamMetadata),c_uint, FLAC__StreamMetadata_SeekPoint] + + def FLAC__metadata_object_seektable_insert_point(object, point_num, point): + return libflac.FLAC__metadata_object_seektable_insert_point(object, point_num, point) + + libflac.FLAC__metadata_object_seektable_delete_point.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_delete_point.argtypes = [POINTER(FLAC__StreamMetadata),c_uint] + + def FLAC__metadata_object_seektable_delete_point(object, point_num): + return libflac.FLAC__metadata_object_seektable_delete_point(object, point_num) + + libflac.FLAC__metadata_object_seektable_is_legal.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_is_legal.argtypes = [POINTER(FLAC__StreamMetadata)] + + def FLAC__metadata_object_seektable_is_legal(object): + return libflac.FLAC__metadata_object_seektable_is_legal(object) + + libflac.FLAC__metadata_object_seektable_template_append_placeholders.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_template_append_placeholders.argtypes = [POINTER(FLAC__StreamMetadata), c_uint] + + def FLAC__metadata_object_seektable_template_append_placeholders(object, num): + return libflac.FLAC__metadata_object_seektable_template_append_placeholders(object, num) + + libflac.FLAC__metadata_object_seektable_template_append_point.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_template_append_point.argtypes = [POINTER(FLAC__StreamMetadata), FLAC__uint64] + + def FLAC__metadata_object_seektable_template_append_point(object, sample_number): + return libflac.FLAC__metadata_object_seektable_template_append_point(object, sample_number) + + libflac.FLAC__metadata_object_seektable_template_append_points.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_template_append_points.argtypes = [POINTER(FLAC__StreamMetadata), POINTER(FLAC__uint64*0), c_uint] + + def FLAC__metadata_object_seektable_template_append_points(object, sample_numbers, num): + return libflac.FLAC__metadata_object_seektable_template_append_points(object, sample_numbers, num) + + libflac.FLAC__metadata_object_seektable_template_append_spaced_points.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_template_append_spaced_points.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, FLAC__uint64] + + def FLAC__metadata_object_seektable_template_append_spaced_points(object, num, total_samples): + return libflac.FLAC__metadata_object_seektable_template_append_spaced_points(object, num, total_samples) + + libflac.FLAC__metadata_object_seektable_template_append_spaced_points_by_samples.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_template_append_spaced_points_by_samples.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, FLAC__uint64] + + def FLAC__metadata_object_seektable_template_append_spaced_points_by_samples(object, samples, total_samples): + return libflac.FLAC__metadata_object_seektable_template_append_spaced_points_by_samples(object, samples, total_samples) + + libflac.FLAC__metadata_object_seektable_template_sort.restype = FLAC__bool + libflac.FLAC__metadata_object_seektable_template_sort.argtypes = [POINTER(FLAC__StreamMetadata), FLAC__bool] + + def FLAC__metadata_object_seektable_template_sort(object, compact): + return libflac.FLAC__metadata_object_seektable_template_sort(object, compact) + + libflac.FLAC__metadata_object_vorbiscomment_set_vendor_string.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_set_vendor_string.argtypes = [POINTER(FLAC__StreamMetadata), FLAC__StreamMetadata_VorbisComment_Entry, FLAC__bool] + + def FLAC__metadata_object_vorbiscomment_set_vendor_string(object, entry, copy): + return libflac.FLAC__metadata_object_vorbiscomment_set_vendor_string(object, entry, copy) + + libflac.FLAC__metadata_object_vorbiscomment_resize_comments.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_resize_comments.argtypes = [POINTER(FLAC__StreamMetadata), c_uint] + + def FLAC__metadata_object_vorbiscomment_resize_comments(object, new_num_comments): + return libflac.FLAC__metadata_object_vorbiscomment_resize_comments(object, new_num_comments) + + libflac.FLAC__metadata_object_vorbiscomment_set_comment.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_set_comment.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, FLAC__StreamMetadata_VorbisComment_Entry, FLAC__bool] + + def FLAC__metadata_object_vorbiscomment_set_comment(object, comment_num, entry, copy): + return libflac.FLAC__metadata_object_vorbiscomment_set_comment(object, comment_num, entry, copy) + + libflac.FLAC__metadata_object_vorbiscomment_insert_comment.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_insert_comment.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, FLAC__StreamMetadata_VorbisComment_Entry, FLAC__bool] + + def FLAC__metadata_object_vorbiscomment_insert_comment(object, comment_num, entry, copy): + return libflac.FLAC__metadata_object_vorbiscomment_insert_comment(object, comment_num, entry, copy) + + libflac.FLAC__metadata_object_vorbiscomment_append_comment.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_append_comment.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, FLAC__StreamMetadata_VorbisComment_Entry, FLAC__bool] + + def FLAC__metadata_object_vorbiscomment_append_comment(object, entry, copy): + return libflac.FLAC__metadata_object_vorbiscomment_append_comment(object,entry, copy) + + libflac.FLAC__metadata_object_vorbiscomment_replace_comment.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_replace_comment.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, FLAC__StreamMetadata_VorbisComment_Entry, FLAC__bool, FLAC__bool] + + def FLAC__metadata_object_vorbiscomment_replace_comment(object, entry, all, copy): + return libflac.FLAC__metadata_object_vorbiscomment_replace_comment(object,entry, all, copy) + + libflac.FLAC__metadata_object_vorbiscomment_delete_comment.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_delete_comment.argtypes = [POINTER(FLAC__StreamMetadata), c_uint] + + def FLAC__metadata_object_vorbiscomment_delete_comment(object, comment_num): + return libflac.FLAC__metadata_object_vorbiscomment_delete_comment(object,comment_num) + + libflac.FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair.argtypes = [POINTER(FLAC__StreamMetadata_VorbisComment_Entry), c_char_p, c_char_p] + + def FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair(entry, field_name, field_value): + return libflac.FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair(entry, field_name, field_value) + + libflac.FLAC__metadata_object_vorbiscomment_entry_to_name_value_pair.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_entry_to_name_value_pair.argtypes = [POINTER(FLAC__StreamMetadata_VorbisComment_Entry), c_char_p_p, c_char_p_p] + + def FLAC__metadata_object_vorbiscomment_entry_to_name_value_pair(entry, field_name, field_value): + return libflac.FLAC__metadata_object_vorbiscomment_entry_to_name_value_pair(entry, field_name, field_value) + + libflac.FLAC__metadata_object_vorbiscomment_entry_matches.restype = FLAC__bool + libflac.FLAC__metadata_object_vorbiscomment_entry_matches.argtypes = [POINTER(FLAC__StreamMetadata_VorbisComment_Entry), c_char_p, c_uint] + + def FLAC__metadata_object_vorbiscomment_entry_matches(entry, field_name, field_value): + return libflac.FLAC__metadata_object_vorbiscomment_entry_matches(entry, field_name, field_value) + + libflac.FLAC__metadata_object_vorbiscomment_find_entry_from.restype = c_int + libflac.FLAC__metadata_object_vorbiscomment_find_entry_from.argtypes = [POINTER(FLAC__StreamMetadata), c_uint, c_char_p] + + def FLAC__metadata_object_vorbiscomment_find_entry_from(object, offset, field_name): + return libflac.FLAC__metadata_object_vorbiscomment_find_entry_from(object, offset, field_name) + + libflac.FLAC__metadata_object_vorbiscomment_remove_entry_matching.restype = c_int + libflac.FLAC__metadata_object_vorbiscomment_remove_entry_matching.argtypes = [POINTER(FLAC__StreamMetadata), c_char_p] + + def FLAC__metadata_object_vorbiscomment_remove_entry_matching(object, field_name): + return libflac.FLAC__metadata_object_vorbiscomment_remove_entry_matching(object, field_name) + + libflac.FLAC__metadata_object_vorbiscomment_remove_entries_matching.restype = c_int + libflac.FLAC__metadata_object_vorbiscomment_remove_entries_matching.argtypes = [POINTER(FLAC__StreamMetadata), c_char_p] + + def FLAC__metadata_object_vorbiscomment_remove_entries_matching(object, field_name): + return libflac.FLAC__metadata_object_vorbiscomment_remove_entries_matching(object, field_name) + + libflac.FLAC__metadata_object_cuesheet_track_new.restype = POINTER(FLAC__StreamMetadata_CueSheet_Track) + libflac.FLAC__metadata_object_cuesheet_track_new.argtypes = [] + + def FLAC__metadata_object_cuesheet_track_new(): + return libflac.FLAC__metadata_object_cuesheet_track_new() + + libflac.FLAC__metadata_object_cuesheet_track_delete.restype = None + libflac.FLAC__metadata_object_cuesheet_track_delete.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track)] + + def FLAC__metadata_object_cuesheet_track_delete(object): + return libflac.FLAC__metadata_object_cuesheet_track_delete(object) + + libflac.FLAC__metadata_object_cuesheet_track_resize_indices.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_track_resize_indices.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint, c_uint] + + def FLAC__metadata_object_cuesheet_track_resize_indices(object, track_num, new_num_indices): + return libflac.FLAC__metadata_object_cuesheet_track_resize_indices(object, track_num, new_num_indices) + + libflac.FLAC__metadata_object_cuesheet_track_insert_index.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_track_insert_index.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint, c_uint, FLAC__StreamMetadata_CueSheet_Index] + + def FLAC__metadata_object_cuesheet_track_insert_index(object, track_num, index_num, index): + return libflac.FLAC__metadata_object_cuesheet_track_insert_index(object, track_num, index_num, index) + + libflac.FLAC__metadata_object_cuesheet_track_insert_blank_index.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_track_insert_blank_index.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint, c_uint] + + def FLAC__metadata_object_cuesheet_track_insert_blank_index(object, track_num, index_num): + return libflac.FLAC__metadata_object_cuesheet_track_insert_blank_index(object, track_num, index_num) + + libflac.FLAC__metadata_object_cuesheet_track_delete_index.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_track_delete_index.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint, c_uint] + + def FLAC__metadata_object_cuesheet_track_delete_index(object, track_num, index_num): + return libflac.FLAC__metadata_object_cuesheet_track_delete_index(object, track_num, index_num) + + libflac.FLAC__metadata_object_cuesheet_resize_tracks.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_resize_tracks.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint] + + def FLAC__metadata_object_cuesheet_resize_tracks(object, new_num_tracks): + return libflac.FLAC__metadata_object_cuesheet_resize_tracks(object, new_num_tracks) + + libflac.FLAC__metadata_object_cuesheet_set_track.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_set_track.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint, POINTER(FLAC__StreamMetadata_CueSheet_Track), FLAC__bool] + + def FLAC__metadata_object_cuesheet_set_track(object, new_num_tracks, track, copy): + return libflac.FLAC__metadata_object_cuesheet_set_track(object, new_num_tracks, track, copy) + + libflac.FLAC__metadata_object_cuesheet_insert_track.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_insert_track.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint, POINTER(FLAC__StreamMetadata_CueSheet_Track), FLAC__bool] + + def FLAC__metadata_object_cuesheet_insert_track(object, track_num, track, copy): + return libflac.FLAC__metadata_object_cuesheet_insert_track(object, track_num, track, copy) + + libflac.FLAC__metadata_object_cuesheet_insert_blank_track.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_insert_blank_track.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint] + + def FLAC__metadata_object_cuesheet_insert_blank_track(object, track_num): + return libflac.FLAC__metadata_object_cuesheet_insert_blank_track(object, track_num) + + libflac.FLAC__metadata_object_cuesheet_delete_track.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_delete_track.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_uint] + + def FLAC__metadata_object_cuesheet_delete_track(object, track_num): + return libflac.FLAC__metadata_object_cuesheet_delete_track(object, track_num) + + libflac.FLAC__metadata_object_cuesheet_is_legal.restype = FLAC__bool + libflac.FLAC__metadata_object_cuesheet_is_legal.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), FLAC__bool, c_char_p_p] + + def FLAC__metadata_object_cuesheet_is_legal(object, check_cd_da_subset, violation): + return libflac.FLAC__metadata_object_cuesheet_is_legal(object, check_cd_da_subset, violation) + + libflac.FLAC__metadata_object_cuesheet_calculate_cddb_id.restype = FLAC__uint32 + libflac.FLAC__metadata_object_cuesheet_calculate_cddb_id.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track)] + + def FLAC__metadata_object_cuesheet_calculate_cddb_id(object): + return libflac.FLAC__metadata_object_cuesheet_calculate_cddb_id(object) + + libflac.FLAC__metadata_object_picture_set_mime_type.restype = FLAC__bool + libflac.FLAC__metadata_object_picture_set_mime_type.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_char_p, FLAC__bool] + + def FLAC__metadata_object_picture_set_mime_type(object, mime_type, copy): + return libflac.FLAC__metadata_object_picture_set_mime_type(object, mime_type, copy) + + libflac.FLAC__metadata_object_picture_set_description.restype = FLAC__bool + libflac.FLAC__metadata_object_picture_set_description.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), FLAC__byte_p, FLAC__bool] + + def FLAC__metadata_object_picture_set_description(object, description, copy): + return libflac.FLAC__metadata_object_picture_set_description(object, mime_type, copy) + + libflac.FLAC__metadata_object_picture_set_data.restype = FLAC__bool + libflac.FLAC__metadata_object_picture_set_data.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), FLAC__byte_p,FLAC__uint32, FLAC__bool] + + def FLAC__metadata_object_picture_set_data(object, data, length, copy): + return libflac.FLAC__metadata_object_picture_set_data(object, mime_type, copy) + + libflac.FLAC__metadata_object_picture_is_legal.restype = FLAC__bool + libflac.FLAC__metadata_object_picture_is_legal.argtypes = [POINTER(FLAC__StreamMetadata_CueSheet_Track), c_char_p] + + def FLAC__metadata_object_picture_is_legal(object, violation): + return libflac.FLAC__metadata_object_picture_is_legal(object, violation) + + # /metadata + + # stream_decoder + + FLAC__StreamDecoderState = c_int + FLAC__StreamDecoderStateEnum = ["FLAC__STREAM_DECODER_SEARCH_FOR_METADATA", + "FLAC__STREAM_DECODER_READ_METADATA", + "FLAC__STREAM_DECODER_SEARCH_FOR_FRAME_SYNC", + "FLAC__STREAM_DECODER_READ_FRAME", + "FLAC__STREAM_DECODER_END_OF_STREAM", + "FLAC__STREAM_DECODER_OGG_ERROR", + "FLAC__STREAM_DECODER_SEEK_ERROR", + "FLAC__STREAM_DECODER_ABORTED", + "FLAC__STREAM_DECODER_MEMORY_ALLOCATION_ERROR", + "FLAC__STREAM_DECODER_UNINITIALIZED"] + + libflac.FLAC__StreamDecoderStateString.restype = c_char_p + libflac.FLAC__StreamDecoderStateString.argtypes = [] + + def FLAC__StreamDecoderStateString(): + return libflac.FLAC__StreamDecoderStateString() + + + FLAC__StreamDecoderInitStatus = c_int + FLAC__StreamDecoderInitStatusEnum = ["FLAC__STREAM_DECODER_INIT_STATUS_OK", + "FLAC__STREAM_DECODER_INIT_STATUS_UNSUPPORTED_CONTAINER", + "FLAC__STREAM_DECODER_INIT_STATUS_INVALID_CALLBACKS", + "FLAC__STREAM_DECODER_INIT_STATUS_MEMORY_ALLOCATION_ERROR", + "FLAC__STREAM_DECODER_INIT_STATUS_ERROR_OPENING_FILE", + "FLAC__STREAM_DECODER_INIT_STATUS_ALREADY_INITIALIZED"] + + libflac.FLAC__StreamDecoderInitStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderInitStatusString.argtypes = [] + + def FLAC__StreamDecoderInitStatusString(): + return libflac.FLAC__StreamDecoderInitStatusString() + + + FLAC__StreamDecoderReadStatus = c_int + FLAC__StreamDecoderReadStatusEnum = ["FLAC__STREAM_DECODER_READ_STATUS_CONTINUE", + "FLAC__STREAM_DECODER_READ_STATUS_END_OF_STREAM", + "FLAC__STREAM_DECODER_READ_STATUS_ABORT"] + + libflac.FLAC__StreamDecoderReadStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderReadStatusString.argtypes = [] + + def FLAC__StreamDecoderReadStatusString(): + return libflac.FLAC__StreamDecoderReadStatusString() + + + FLAC__StreamDecoderSeekStatus = c_int + FLAC__StreamDecoderSeekStatusEnum = ["FLAC__STREAM_DECODER_SEEK_STATUS_OK", + "FLAC__STREAM_DECODER_SEEK_STATUS_ERROR", + "FLAC__STREAM_DECODER_SEEK_STATUS_UNSUPPORTED"] + + libflac.FLAC__StreamDecoderSeekStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderSeekStatusString.argtypes = [] + + def FLAC__StreamDecoderSeekStatusString(): + return libflac.FLAC__StreamDecoderSeekStatusString() + + + FLAC__StreamDecoderTellStatus = c_int + FLAC__StreamDecoderTellStatusEnum = ["FLAC__STREAM_DECODER_TELL_STATUS_OK", + "FLAC__STREAM_DECODER_TELL_STATUS_ERROR", + "FLAC__STREAM_DECODER_TELL_STATUS_UNSUPPORTED"] + + libflac.FLAC__StreamDecoderTellStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderTellStatusString.argtypes = [] + + def FLAC__StreamDecoderTellStatusString(): + return libflac.FLAC__StreamDecoderTellStatusString() + + + FLAC__StreamDecoderLengthStatus = c_int + FLAC__StreamDecoderLengthStatusEnum = ["FLAC__STREAM_DECODER_LENGTH_STATUS_OK", + "FLAC__STREAM_DECODER_LENGTH_STATUS_ERROR", + "FLAC__STREAM_DECODER_LENGTH_STATUS_UNSUPPORTED"] + + libflac.FLAC__StreamDecoderLengthStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderLengthStatusString.argtypes = [] + + def FLAC__StreamDecoderLengthStatusString(): + return libflac.FLAC__StreamDecoderLengthStatusString() + + + FLAC__StreamDecoderWriteStatus = c_int + FLAC__StreamDecoderWriteStatusEnum = ["FLAC__STREAM_DECODER_WRITE_STATUS_CONTINUE", "FLAC__STREAM_DECODER_WRITE_STATUS_ABORT"] + + libflac.FLAC__StreamDecoderWriteStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderWriteStatusString.argtypes = [] + + def FLAC__StreamDecoderWriteStatusString(): + return libflac.FLAC__StreamDecoderWriteStatusString() + + FLAC__StreamDecoderErrorStatus = c_int + FLAC__StreamDecoderErrorStatusEnum = ["FLAC__STREAM_DECODER_ERROR_STATUS_LOST_SYNC", + "FLAC__STREAM_DECODER_ERROR_STATUS_BAD_HEADER", + "FLAC__STREAM_DECODER_ERROR_STATUS_FRAME_CRC_MISMATCH", + "FLAC__STREAM_DECODER_ERROR_STATUS_UNPARSEABLE_STREAM"] + + libflac.FLAC__StreamDecoderErrorStatusString.restype = c_char_p + libflac.FLAC__StreamDecoderErrorStatusString.argtypes = [] + + def FLAC__StreamDecoderErrorStatusString(): + return libflac.FLAC__StreamDecoderErrorStatusString() + + + + class FLAC__StreamDecoderProtected(Structure): + _fields_ = [("dummy", c_int)] + + class FLAC__StreamDecoderPrivate(Structure): + _fields_ = [("dummy", c_int)] + + class FLAC__StreamDecoder(Structure): + _fields_ = [("protected_", POINTER(FLAC__StreamDecoderProtected)), + ("private_", POINTER(FLAC__StreamDecoderPrivate))] + + FLAC__StreamDecoderReadCallback = CFUNCTYPE( + FLAC__StreamDecoderReadStatus, + POINTER(FLAC__StreamDecoder), + POINTER(FLAC__byte*0), + c_size_t_p, + c_void_p + ) + + FLAC__StreamDecoderSeekCallback = CFUNCTYPE( + FLAC__StreamDecoderSeekStatus, + POINTER(FLAC__StreamDecoder), + FLAC__uint64, + c_void_p + ) + + FLAC__StreamDecoderTellCallback = CFUNCTYPE( + FLAC__StreamDecoderTellStatus, + POINTER(FLAC__StreamDecoder), + FLAC__uint64_p, + c_void_p + ) + + FLAC__StreamDecoderLengthCallback = CFUNCTYPE( + FLAC__StreamDecoderLengthStatus, + POINTER(FLAC__StreamDecoder), + FLAC__uint64_p, + c_void_p + ) + + FLAC__StreamDecoderEofCallback = CFUNCTYPE( + FLAC__bool, + POINTER(FLAC__StreamDecoder), + c_void_p + ) + + FLAC__StreamDecoderWriteCallback = CFUNCTYPE( + FLAC__StreamDecoderWriteStatus, + POINTER(FLAC__StreamDecoder), + POINTER(FLAC__Frame), + POINTER(FLAC__int32_p*0), + c_void_p + ) + + FLAC__StreamDecoderMetadataCallback = CFUNCTYPE( + None, + POINTER(FLAC__StreamDecoder), + POINTER(FLAC__StreamMetadata), + c_void_p + ) + + FLAC__StreamDecoderErrorCallback = CFUNCTYPE( + None, + POINTER(FLAC__StreamDecoder), + FLAC__StreamDecoderErrorStatus, + c_void_p + ) + + + libflac.FLAC__stream_decoder_new.restype = POINTER(FLAC__StreamDecoder) + libflac.FLAC__stream_decoder_new.argtypes = [] + + def FLAC__stream_decoder_new(): + return libflac.FLAC__stream_decoder_new() + + libflac.FLAC__stream_decoder_delete.restype = None + libflac.FLAC__stream_decoder_delete.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_delete(decoder): + return libflac.FLAC__stream_decoder_delete(decoder) + + + libflac.FLAC__stream_decoder_set_ogg_serial_number.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_ogg_serial_number.argtypes = [POINTER(FLAC__StreamDecoder), c_long] + + def FLAC__stream_decoder_set_ogg_serial_number(decoder, serial_number): + return libflac.FLAC__stream_decoder_set_ogg_serial_number(decoder, serial_number) + + libflac.FLAC__stream_decoder_set_md5_checking.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_md5_checking.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__bool] + + def FLAC__stream_decoder_set_md5_checking(decoder, value): + return libflac.FLAC__stream_decoder_set_md5_checking(decoder, value) + + libflac.FLAC__stream_decoder_set_metadata_respond.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_metadata_respond.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__MetadataType] + + def FLAC__stream_decoder_set_metadata_respond(decoder, type): + return libflac.FLAC__stream_decoder_set_metadata_respond(decoder, type) + + libflac.FLAC__stream_decoder_set_metadata_respond_application.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_metadata_respond_application.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__byte*4] + + def FLAC__stream_decoder_set_metadata_respond_application(decoder, id): + return libflac.FLAC__stream_decoder_set_metadata_respond_application(decoder, id) + + libflac.FLAC__stream_decoder_set_metadata_respond_all.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_metadata_respond_all.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_set_metadata_respond_all(decoder): + return libflac.FLAC__stream_decoder_set_metadata_respond_all(decoder) + + libflac.FLAC__stream_decoder_set_metadata_ignore.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_metadata_ignore.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__MetadataType] + + def FLAC__stream_decoder_set_metadata_ignore(decoder, type): + return libflac.FLAC__stream_decoder_set_metadata_ignore(decoder, type) + + libflac.FLAC__stream_decoder_set_metadata_ignore_application.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_metadata_ignore_application.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__byte*4] + + def FLAC__stream_decoder_set_metadata_ignore_application(decoder, id): + return libflac.FLAC__stream_decoder_set_metadata_ignore_application(decoder, id) + + libflac.FLAC__stream_decoder_set_metadata_ignore_all.restype = FLAC__bool + libflac.FLAC__stream_decoder_set_metadata_ignore_all.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_set_metadata_ignore_all(decoder): + return libflac.FLAC__stream_decoder_set_metadata_ignore_all(decoder) + + libflac.FLAC__stream_decoder_get_state.restype = FLAC__StreamDecoderState + libflac.FLAC__stream_decoder_get_state.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_state(decoder): + return libflac.FLAC__stream_decoder_get_state(decoder) + + libflac.FLAC__stream_decoder_get_resolved_state_string.restype = c_char_p + libflac.FLAC__stream_decoder_get_resolved_state_string.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_resolved_state_string(decoder): + return libflac.FLAC__stream_decoder_get_resolved_state_string(decoder) + + libflac.FLAC__stream_decoder_get_md5_checking.restype = FLAC__bool + libflac.FLAC__stream_decoder_get_md5_checking.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_md5_checking(decoder): + return libflac.FLAC__stream_decoder_get_md5_checking(decoder) + + libflac.FLAC__stream_decoder_get_total_samples.restype = FLAC__uint64 + libflac.FLAC__stream_decoder_get_total_samples.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_total_samples(decoder): + return libflac.FLAC__stream_decoder_get_total_samples(decoder) + + libflac.FLAC__stream_decoder_get_channels.restype = c_uint + libflac.FLAC__stream_decoder_get_channels.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_channels(decoder): + return libflac.FLAC__stream_decoder_get_channels(decoder) + + libflac.FLAC__stream_decoder_get_channel_assignment.restype = FLAC__ChannelAssignment + libflac.FLAC__stream_decoder_get_channel_assignment.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_channel_assignment(decoder): + return libflac.FLAC__stream_decoder_get_channel_assignment(decoder) + + libflac.FLAC__stream_decoder_get_bits_per_sample.restype = c_uint + libflac.FLAC__stream_decoder_get_bits_per_sample.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_bits_per_sample(decoder): + return libflac.FLAC__stream_decoder_get_bits_per_sample(decoder) + + libflac.FLAC__stream_decoder_get_sample_rate.restype = c_uint + libflac.FLAC__stream_decoder_get_sample_rate.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_sample_rate(decoder): + return libflac.FLAC__stream_decoder_get_sample_rate(decoder) + + libflac.FLAC__stream_decoder_get_blocksize.restype = c_uint + libflac.FLAC__stream_decoder_get_blocksize.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_get_blocksize(decoder): + return libflac.FLAC__stream_decoder_get_blocksize(decoder) + + libflac.FLAC__stream_decoder_get_decode_position.restype = FLAC__bool + libflac.FLAC__stream_decoder_get_decode_position.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__uint64_p] + + def FLAC__stream_decoder_get_decode_position(decoder, position): + return libflac.FLAC__stream_decoder_get_decode_position(decoder, position) + + libflac.FLAC__stream_decoder_init_stream.restype = FLAC__StreamDecoderInitStatus + libflac.FLAC__stream_decoder_init_stream.argtypes = [POINTER(FLAC__StreamDecoder), + FLAC__StreamDecoderReadCallback, + FLAC__StreamDecoderSeekCallback, + FLAC__StreamDecoderTellCallback, + FLAC__StreamDecoderLengthCallback, + FLAC__StreamDecoderEofCallback, + FLAC__StreamDecoderWriteCallback, + FLAC__StreamDecoderMetadataCallback, + FLAC__StreamDecoderErrorCallback, + c_void_p] + + def FLAC__stream_decoder_init_stream(decoder, read_callback, seek_callback, tell_callback, length_callback, eof_callback, write_callback, metadata_callback, error_callback, client_data): + return libflac.FLAC__stream_decoder_init_stream(decoder, read_callback, seek_callback, tell_callback, length_callback, eof_callback, write_callback, metadata_callback, error_callback, client_data) + + + libflac.FLAC__stream_decoder_init_ogg_stream.restype = FLAC__StreamDecoderInitStatus + libflac.FLAC__stream_decoder_init_ogg_stream.argtypes = [POINTER(FLAC__StreamDecoder), + FLAC__StreamDecoderReadCallback, + FLAC__StreamDecoderSeekCallback, + FLAC__StreamDecoderTellCallback, + FLAC__StreamDecoderLengthCallback, + FLAC__StreamDecoderEofCallback, + FLAC__StreamDecoderWriteCallback, + FLAC__StreamDecoderMetadataCallback, + FLAC__StreamDecoderErrorCallback, + c_void_p] + + def FLAC__stream_decoder_init_ogg_stream(decoder, read_callback, seek_callback, tell_callback, length_callback, eof_callback, write_callback, metadata_callback, error_callback, client_data): + return libflac.FLAC__stream_decoder_init_ogg_stream(decoder, read_callback, seek_callback, tell_callback, length_callback, eof_callback, write_callback, metadata_callback, error_callback, client_data) + + libflac.FLAC__stream_decoder_init_file.restype = FLAC__StreamDecoderInitStatus + libflac.FLAC__stream_decoder_init_file.argtypes = [POINTER(FLAC__StreamDecoder), + c_char_p, + FLAC__StreamDecoderWriteCallback, + FLAC__StreamDecoderMetadataCallback, + FLAC__StreamDecoderErrorCallback, + c_void_p] + + def FLAC__stream_decoder_init_file(decoder, filename, write_callback, metadata_callback, error_callback, client_data): + return libflac.FLAC__stream_decoder_init_file(decoder, filename, write_callback, metadata_callback, error_callback, client_data) + + libflac.FLAC__stream_decoder_init_ogg_file.restype = FLAC__StreamDecoderInitStatus + libflac.FLAC__stream_decoder_init_ogg_file.argtypes = [POINTER(FLAC__StreamDecoder), + c_char_p, + FLAC__StreamDecoderWriteCallback, + FLAC__StreamDecoderMetadataCallback, + FLAC__StreamDecoderErrorCallback, + c_void_p] + + def FLAC__stream_decoder_init_ogg_file(decoder, filename, write_callback, metadata_callback, error_callback, client_data): + return libflac.FLAC__stream_decoder_init_ogg_file(decoder, filename, write_callback, metadata_callback, error_callback, client_data) + + libflac.FLAC__stream_decoder_finish.restype = FLAC__bool + libflac.FLAC__stream_decoder_finish.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_finish(decoder): + return libflac.FLAC__stream_decoder_finish(decoder) + + libflac.FLAC__stream_decoder_flush.restype = FLAC__bool + libflac.FLAC__stream_decoder_flush.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_flush(decoder): + return libflac.FLAC__stream_decoder_flush(decoder) + + libflac.FLAC__stream_decoder_reset.restype = FLAC__bool + libflac.FLAC__stream_decoder_reset.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_reset(decoder): + return libflac.FLAC__stream_decoder_reset(decoder) + + libflac.FLAC__stream_decoder_process_single.restype = FLAC__bool + libflac.FLAC__stream_decoder_process_single.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_process_single(decoder): + return libflac.FLAC__stream_decoder_process_single(decoder) + + libflac.FLAC__stream_decoder_process_until_end_of_metadata.restype = FLAC__bool + libflac.FLAC__stream_decoder_process_until_end_of_metadata.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_process_until_end_of_metadata(decoder): + return libflac.FLAC__stream_decoder_process_until_end_of_metadata(decoder) + + libflac.FLAC__stream_decoder_process_until_end_of_stream.restype = FLAC__bool + libflac.FLAC__stream_decoder_process_until_end_of_stream.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_process_until_end_of_stream(decoder): + return libflac.FLAC__stream_decoder_process_until_end_of_stream(decoder) + + libflac.FLAC__stream_decoder_skip_single_frame.restype = FLAC__bool + libflac.FLAC__stream_decoder_skip_single_frame.argtypes = [POINTER(FLAC__StreamDecoder)] + + def FLAC__stream_decoder_skip_single_frame(decoder): + return libflac.FLAC__stream_decoder_skip_single_frame(decoder) + + libflac.FLAC__stream_decoder_seek_absolute.restype = FLAC__bool + libflac.FLAC__stream_decoder_seek_absolute.argtypes = [POINTER(FLAC__StreamDecoder), FLAC__uint64] + + def FLAC__stream_decoder_seek_absolute(decoder, sample): + return libflac.FLAC__stream_decoder_seek_absolute(decoder, sample) + + # /stream_decoder + + # stream_encoder + + FLAC__StreamEncoderState = c_int + + libflac.FLAC__StreamEncoderStateString.restype = c_char_p + libflac.FLAC__StreamEncoderStateString.argtypes = [] + + def FLAC__StreamEncoderStateString(): + return libflac.FLAC__StreamEncoderStateString() + + + FLAC__StreamEncoderInitStatus = c_int + + libflac.FLAC__StreamEncoderInitStatusString.restype = c_char_p + libflac.FLAC__StreamEncoderInitStatusString.argtypes = [] + + def FLAC__StreamEncoderInitStatusString(): + return libflac.FLAC__StreamEncoderInitStatusString() + + + FLAC__StreamEncoderReadStatus = c_int + + libflac.FLAC__StreamEncoderReadStatusString.restype = c_char_p + libflac.FLAC__StreamEncoderReadStatusString.argtypes = [] + + def FLAC__StreamEncoderReadStatusString(): + return libflac.FLAC__StreamEncoderReadStatusString() + + + FLAC__StreamEncoderWriteStatus = c_int + + libflac.FLAC__StreamEncoderWriteStatusString.restype = c_char_p + libflac.FLAC__StreamEncoderWriteStatusString.argtypes = [] + + def FLAC__StreamEncoderWriteStatusString(): + return libflac.FLAC__StreamEncoderWriteStatusString() + + + FLAC__StreamEncoderSeekStatus = c_int + + libflac.FLAC__StreamEncoderSeekStatusString.restype = c_char_p + libflac.FLAC__StreamEncoderSeekStatusString.argtypes = [] + + def FLAC__StreamEncoderSeekStatusString(): + return libflac.FLAC__StreamEncoderSeekStatusString() + + + FLAC__StreamEncoderTellStatus = c_int + + libflac.FLAC__StreamEncoderTellStatusString.restype = c_char_p + libflac.FLAC__StreamEncoderTellStatusString.argtypes = [] + + def FLAC__StreamEncoderTellStatusString(): + return libflac.FLAC__StreamEncoderTellStatusString() + + + class FLAC__StreamEncoderProtected(Structure): + _fields_ = [("dummy", c_int)] + + class FLAC__StreamEncoderPrivate(Structure): + _fields_ = [("dummy", c_int)] + + class FLAC__StreamEncoder(Structure): + _fields_ = [("protected_", POINTER(FLAC__StreamEncoderProtected)), + ("private_", POINTER(FLAC__StreamEncoderPrivate))] + + FLAC__StreamEncoderReadCallback = CFUNCTYPE(FLAC__StreamEncoderReadStatus, POINTER(FLAC__StreamEncoder), POINTER(FLAC__byte*0), c_size_t_p, c_void_p) + + FLAC__StreamEncoderWriteCallback = CFUNCTYPE(FLAC__StreamEncoderWriteStatus, POINTER(FLAC__StreamEncoder), POINTER(FLAC__byte*0), c_size_t, c_uint, c_uint, c_void_p) + + FLAC__StreamEncoderSeekCallback = CFUNCTYPE(FLAC__StreamEncoderSeekStatus, POINTER(FLAC__StreamEncoder), FLAC__uint64, c_void_p) + + FLAC__StreamEncoderTellCallback = CFUNCTYPE(FLAC__StreamEncoderTellStatus, POINTER(FLAC__StreamEncoder), FLAC__uint64_p, c_void_p) + + FLAC__StreamEncoderMetadataCallback = CFUNCTYPE(None, POINTER(FLAC__StreamEncoder), POINTER(FLAC__StreamMetadata), c_void_p) + + FLAC__StreamEncoderProgressCallback = CFUNCTYPE(None, POINTER(FLAC__StreamEncoder), FLAC__uint64,FLAC__uint64, c_uint, c_uint, c_void_p) + + + libflac.FLAC__stream_encoder_new.restype = POINTER(FLAC__StreamEncoder) + libflac.FLAC__stream_encoder_new.argtypes = [] + + def FLAC__stream_encoder_new(): + return libflac.FLAC__stream_encoder_new() + + libflac.FLAC__stream_encoder_delete.restype = None + libflac.FLAC__stream_encoder_delete.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_delete(encoder): + return libflac.FLAC__stream_encoder_delete(encoder) + + + libflac.FLAC__stream_encoder_set_ogg_serial_number.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_ogg_serial_number.argtypes = [POINTER(FLAC__StreamEncoder), c_long] + + def FLAC__stream_encoder_set_ogg_serial_number(encoder, serial_number): + return libflac.FLAC__stream_encoder_set_ogg_serial_number(encoder, serial_number) + + libflac.FLAC__stream_encoder_set_verify.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_verify.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_verify(encoder, value): + return libflac.FLAC__stream_encoder_set_verify(encoder, value) + + libflac.FLAC__stream_encoder_set_streamable_subset.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_streamable_subset.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_streamable_subset(encoder, value): + return libflac.FLAC__stream_encoder_set_streamable_subset(encoder, value) + + libflac.FLAC__stream_encoder_set_channels.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_channels.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_channels(encoder, value): + return libflac.FLAC__stream_encoder_set_channels(encoder, value) + + libflac.FLAC__stream_encoder_set_bits_per_sample.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_bits_per_sample.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_bits_per_sample(encoder, value): + return libflac.FLAC__stream_encoder_set_bits_per_sample(encoder, value) + + libflac.FLAC__stream_encoder_set_sample_rate.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_sample_rate.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_sample_rate(encoder, value): + return libflac.FLAC__stream_encoder_set_sample_rate(encoder, value) + + libflac.FLAC__stream_encoder_set_compression_level.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_compression_level.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_compression_level(encoder, value): + return libflac.FLAC__stream_encoder_set_compression_level(encoder, value) + + libflac.FLAC__stream_encoder_set_blocksize.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_blocksize.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_blocksize(encoder, value): + return libflac.FLAC__stream_encoder_set_blocksize(encoder, value) + + libflac.FLAC__stream_encoder_set_do_mid_side_stereo.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_do_mid_side_stereo.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_do_mid_side_stereo(encoder, value): + return libflac.FLAC__stream_encoder_set_do_mid_side_stereo(encoder, value) + + libflac.FLAC__stream_encoder_set_loose_mid_side_stereo.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_loose_mid_side_stereo.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_loose_mid_side_stereo(encoder, value): + return libflac.FLAC__stream_encoder_set_loose_mid_side_stereo(encoder, value) + + libflac.FLAC__stream_encoder_set_apodization.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_apodization.argtypes = [POINTER(FLAC__StreamEncoder), c_char_p] + + def FLAC__stream_encoder_set_apodization(encoder, specification): + return libflac.FLAC__stream_encoder_set_apodization(encoder, specification) + + libflac.FLAC__stream_encoder_set_max_lpc_order.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_max_lpc_order.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_max_lpc_order(encoder, value): + return libflac.FLAC__stream_encoder_set_max_lpc_order(encoder, value) + + libflac.FLAC__stream_encoder_set_qlp_coeff_precision.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_qlp_coeff_precision.argtypes = [POINTER(FLAC__StreamEncoder), c_uint] + + def FLAC__stream_encoder_set_qlp_coeff_precision(encoder, value): + return libflac.FLAC__stream_encoder_set_qlp_coeff_precision(encoder, value) + + libflac.FLAC__stream_encoder_set_do_qlp_coeff_prec_search.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_do_qlp_coeff_prec_search.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_do_qlp_coeff_prec_search(encoder, value): + return libflac.FLAC__stream_encoder_set_do_qlp_coeff_prec_search(encoder, value) + + libflac.FLAC__stream_encoder_set_do_escape_coding.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_do_escape_coding.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_do_escape_coding(encoder, value): + return libflac.FLAC__stream_encoder_set_do_escape_coding(encoder, value) + + libflac.FLAC__stream_encoder_set_do_exhaustive_model_search.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_do_exhaustive_model_search.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_do_exhaustive_model_search(encoder, value): + return libflac.FLAC__stream_encoder_set_do_exhaustive_model_search(encoder, value) + + libflac.FLAC__stream_encoder_set_min_residual_partition_order.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_min_residual_partition_order.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_min_residual_partition_order(encoder, value): + return libflac.FLAC__stream_encoder_set_min_residual_partition_order(encoder, value) + + libflac.FLAC__stream_encoder_set_max_residual_partition_order.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_max_residual_partition_order.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_max_residual_partition_order(encoder, value): + return libflac.FLAC__stream_encoder_set_max_residual_partition_order(encoder, value) + + libflac.FLAC__stream_encoder_set_rice_parameter_search_dist.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_rice_parameter_search_dist.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__bool] + + def FLAC__stream_encoder_set_rice_parameter_search_dist(encoder, value): + return libflac.FLAC__stream_encoder_set_rice_parameter_search_dist(encoder, value) + + libflac.FLAC__stream_encoder_set_total_samples_estimate.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_total_samples_estimate.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__uint64] + + def FLAC__stream_encoder_set_total_samples_estimate(encoder, value): + return libflac.FLAC__stream_encoder_set_total_samples_estimate(encoder, value) + + libflac.FLAC__stream_encoder_set_metadata.restype = FLAC__bool + libflac.FLAC__stream_encoder_set_metadata.argtypes = [POINTER(FLAC__StreamEncoder), POINTER(POINTER(FLAC__StreamMetadata)), c_uint] + + def FLAC__stream_encoder_set_metadata(encoder, metadata, num_blocks): + return libflac.FLAC__stream_encoder_set_metadata(encoder, metadata, num_blocks) + + libflac.FLAC__stream_encoder_get_state.restype = FLAC__StreamEncoderState + libflac.FLAC__stream_encoder_get_state.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_state(encoder): + return libflac.FLAC__stream_encoder_get_state(encoder) + + libflac.FLAC__stream_encoder_get_verify_decoder_state.restype = FLAC__StreamEncoderState + libflac.FLAC__stream_encoder_get_verify_decoder_state.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_verify_decoder_state(encoder): + return libflac.FLAC__stream_encoder_get_verify_decoder_state(encoder) + + libflac.FLAC__stream_encoder_get_resolved_state_string.restype = c_char_p + libflac.FLAC__stream_encoder_get_resolved_state_string.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_resolved_state_string(encoder): + return libflac.FLAC__stream_encoder_get_resolved_state_string(encoder) + + libflac.FLAC__stream_encoder_get_verify_decoder_error_stats.restype = None + libflac.FLAC__stream_encoder_get_verify_decoder_error_stats.argtypes = [POINTER(FLAC__StreamEncoder), FLAC__uint64_p, c_uint_p, c_uint_p, c_uint_p, FLAC__int32_p, FLAC__int32_p] + + def FLAC__stream_encoder_get_verify_decoder_error_stats(encoder, absolute_sample, frame_number, channel, sample, expected, got): + return libflac.FLAC__stream_encoder_get_verify_decoder_error_stats(encoder, absolute_sample, frame_number, channel, sample, expected, got) + + libflac.FLAC__stream_encoder_get_verify.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_verify.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_verify(encoder): + return libflac.FLAC__stream_encoder_get_verify(encoder) + + libflac.FLAC__stream_encoder_get_streamable_subset.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_streamable_subset.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_streamable_subset(encoder): + return libflac.FLAC__stream_encoder_get_streamable_subset(encoder) + + libflac.FLAC__stream_encoder_get_channels.restype = c_uint + libflac.FLAC__stream_encoder_get_channels.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_channels(encoder): + return libflac.FLAC__stream_encoder_get_channels(encoder) + + libflac.FLAC__stream_encoder_get_bits_per_sample.restype = c_uint + libflac.FLAC__stream_encoder_get_bits_per_sample.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_bits_per_sample(encoder): + return libflac.FLAC__stream_encoder_get_bits_per_sample(encoder) + + libflac.FLAC__stream_encoder_get_sample_rate.restype = c_uint + libflac.FLAC__stream_encoder_get_sample_rate.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_sample_rate(encoder): + return libflac.FLAC__stream_encoder_get_sample_rate(encoder) + + libflac.FLAC__stream_encoder_get_blocksize.restype = c_uint + libflac.FLAC__stream_encoder_get_blocksize.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_blocksize(encoder): + return libflac.FLAC__stream_encoder_get_blocksize(encoder) + + libflac.FLAC__stream_encoder_get_do_mid_side_stereo.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_do_mid_side_stereo.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_do_mid_side_stereo(encoder): + return libflac.FLAC__stream_encoder_get_do_mid_side_stereo(encoder) + + libflac.FLAC__stream_encoder_get_loose_mid_side_stereo.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_loose_mid_side_stereo.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_loose_mid_side_stereo(encoder): + return libflac.FLAC__stream_encoder_get_loose_mid_side_stereo(encoder) + + libflac.FLAC__stream_encoder_get_max_lpc_order.restype = c_uint + libflac.FLAC__stream_encoder_get_max_lpc_order.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_max_lpc_order(encoder): + return libflac.FLAC__stream_encoder_get_max_lpc_order(encoder) + + libflac.FLAC__stream_encoder_get_qlp_coeff_precision.restype = c_uint + libflac.FLAC__stream_encoder_get_qlp_coeff_precision.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_qlp_coeff_precision(encoder): + return libflac.FLAC__stream_encoder_get_qlp_coeff_precision(encoder) + + libflac.FLAC__stream_encoder_get_do_qlp_coeff_prec_search.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_do_qlp_coeff_prec_search.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_do_qlp_coeff_prec_search(encoder): + return libflac.FLAC__stream_encoder_get_do_qlp_coeff_prec_search(encoder) + + libflac.FLAC__stream_encoder_get_do_escape_coding.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_do_escape_coding.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_do_escape_coding(encoder): + return libflac.FLAC__stream_encoder_get_do_escape_coding(encoder) + + libflac.FLAC__stream_encoder_get_do_exhaustive_model_search.restype = FLAC__bool + libflac.FLAC__stream_encoder_get_do_exhaustive_model_search.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_do_exhaustive_model_search(encoder): + return libflac.FLAC__stream_encoder_get_do_exhaustive_model_search(encoder) + + libflac.FLAC__stream_encoder_get_min_residual_partition_order.restype = c_uint + libflac.FLAC__stream_encoder_get_min_residual_partition_order.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_min_residual_partition_order(encoder): + return libflac.FLAC__stream_encoder_get_min_residual_partition_order(encoder) + + libflac.FLAC__stream_encoder_get_max_residual_partition_order.restype = c_uint + libflac.FLAC__stream_encoder_get_max_residual_partition_order.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_max_residual_partition_order(encoder): + return libflac.FLAC__stream_encoder_get_max_residual_partition_order(encoder) + + libflac.FLAC__stream_encoder_get_rice_parameter_search_dist.restype = c_uint + libflac.FLAC__stream_encoder_get_rice_parameter_search_dist.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_rice_parameter_search_dist(encoder): + return libflac.FLAC__stream_encoder_get_rice_parameter_search_dist(encoder) + + libflac.FLAC__stream_encoder_get_total_samples_estimate.restype = FLAC__uint64 + libflac.FLAC__stream_encoder_get_total_samples_estimate.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_get_total_samples_estimate(encoder): + return libflac.FLAC__stream_encoder_get_total_samples_estimate(encoder) + + libflac.FLAC__stream_encoder_init_stream.restype = FLAC__StreamEncoderInitStatus + libflac.FLAC__stream_encoder_init_stream.argtypes = [POINTER(FLAC__StreamEncoder), + FLAC__StreamEncoderWriteCallback, + FLAC__StreamEncoderSeekCallback, + FLAC__StreamEncoderTellCallback, + FLAC__StreamEncoderMetadataCallback, + c_void_p] + + def FLAC__stream_encoder_init_stream(encoder, write_callback, seek_callback, tell_callback, metadata_callback,client_data): + return libflac.FLAC__stream_encoder_init_stream(encoder, write_callback, seek_callback, tell_callback, metadata_callback,client_data) + + libflac.FLAC__stream_encoder_init_ogg_stream.restype = FLAC__StreamEncoderInitStatus + libflac.FLAC__stream_encoder_init_ogg_stream.argtypes = [POINTER(FLAC__StreamEncoder), + FLAC__StreamEncoderReadCallback, + FLAC__StreamEncoderWriteCallback, + FLAC__StreamEncoderSeekCallback, + FLAC__StreamEncoderTellCallback, + FLAC__StreamEncoderMetadataCallback, + c_void_p] + + def FLAC__stream_encoder_init_ogg_stream(encoder, read_callback, write_callback, seek_callback, tell_callback, metadata_callback,client_data): + return libflac.FLAC__stream_encoder_init_ogg_stream(encoder, read_callback, write_callback, seek_callback, tell_callback, metadata_callback,client_data) + + libflac.FLAC__stream_encoder_init_file.restype = FLAC__StreamEncoderInitStatus + libflac.FLAC__stream_encoder_init_file.argtypes = [POINTER(FLAC__StreamEncoder), + c_char_p, + FLAC__StreamEncoderProgressCallback, + c_void_p] + + def FLAC__stream_encoder_init_file(encoder, filename, progress_callback,client_data): + return libflac.FLAC__stream_encoder_init_file(encoder, filename, progress_callback,client_data) + + + libflac.FLAC__stream_encoder_init_ogg_file.restype = FLAC__StreamEncoderInitStatus + libflac.FLAC__stream_encoder_init_ogg_file.argtypes = [POINTER(FLAC__StreamEncoder), + c_char_p, + FLAC__StreamEncoderProgressCallback, + c_void_p] + + def FLAC__stream_encoder_init_ogg_file(encoder, filename, progress_callback,client_data): + return libflac.FLAC__stream_encoder_init_ogg_file(encoder, filename, progress_callback,client_data) + + libflac.FLAC__stream_encoder_finish.restype = FLAC__bool + libflac.FLAC__stream_encoder_finish.argtypes = [POINTER(FLAC__StreamEncoder)] + + def FLAC__stream_encoder_finish(encoder): + return libflac.FLAC__stream_encoder_finish(encoder) + + libflac.FLAC__stream_encoder_process.restype = FLAC__bool + libflac.FLAC__stream_encoder_process.argtypes = [POINTER(FLAC__StreamEncoder), POINTER(FLAC__int32_p*0), c_uint] + + def FLAC__stream_encoder_process(encoder, buffer, samples): + return libflac.FLAC__stream_encoder_process(encoder, buffer, samples) + + libflac.FLAC__stream_encoder_process_interleaved.restype = FLAC__bool + libflac.FLAC__stream_encoder_process_interleaved.argtypes = [POINTER(FLAC__StreamEncoder), POINTER(FLAC__int32*0), c_uint] + + def FLAC__stream_encoder_process_interleaved(encoder, buffer, samples): + return libflac.FLAC__stream_encoder_process_interleaved(encoder, buffer, samples) + + # /stream_encoder diff --git a/sbapp/pyogg/flac_file.py b/sbapp/pyogg/flac_file.py new file mode 100644 index 0000000..7e97ca7 --- /dev/null +++ b/sbapp/pyogg/flac_file.py @@ -0,0 +1,114 @@ +import ctypes +from itertools import chain + +from . import flac +from .audio_file import AudioFile +from .pyogg_error import PyOggError + +def _to_char_p(string): + try: + return ctypes.c_char_p(string.encode("utf-8")) + except: + return ctypes.c_char_p(string) + +def _resize_array(array, new_size): + return (array._type_*new_size).from_address(ctypes.addressof(array)) + + +class FlacFile(AudioFile): + def write_callback(self, decoder, frame, buffer, client_data): + multi_channel_buf = _resize_array(buffer.contents, self.channels) + arr_size = frame.contents.header.blocksize + if frame.contents.header.channels >= 2: + arrays = [] + for i in range(frame.contents.header.channels): + arr = ctypes.cast(multi_channel_buf[i], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents + arrays.append(arr[:]) + + arr = list(chain.from_iterable(zip(*arrays))) + + self.buffer[self.buffer_pos : self.buffer_pos + len(arr)] = arr[:] + self.buffer_pos += len(arr) + + else: + arr = ctypes.cast(multi_channel_buf[0], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents + self.buffer[self.buffer_pos : self.buffer_pos + arr_size] = arr[:] + self.buffer_pos += arr_size + return 0 + + def metadata_callback(self,decoder, metadata, client_data): + if not self.buffer: + self.total_samples = metadata.contents.data.stream_info.total_samples + self.channels = metadata.contents.data.stream_info.channels + Buffer = flac.FLAC__int16*(self.total_samples * self.channels) + self.buffer = Buffer() + self.frequency = metadata.contents.data.stream_info.sample_rate + + def error_callback(self,decoder, status, client_data): + raise PyOggError("An error occured during the process of decoding. Status enum: {}".format(flac.FLAC__StreamDecoderErrorStatusEnum[status])) + + def __init__(self, path): + self.decoder = flac.FLAC__stream_decoder_new() + + self.client_data = ctypes.c_void_p() + + #: Number of channels in audio file. + self.channels = None + + #: Number of samples per second (per channel). For + # example, 44100. + self.frequency = None + + self.total_samples = None + + #: Raw PCM data from audio file. + self.buffer = None + + self.buffer_pos = 0 + + write_callback_ = flac.FLAC__StreamDecoderWriteCallback(self.write_callback) + + metadata_callback_ = flac.FLAC__StreamDecoderMetadataCallback(self.metadata_callback) + + error_callback_ = flac.FLAC__StreamDecoderErrorCallback(self.error_callback) + + init_status = flac.FLAC__stream_decoder_init_file( + self.decoder, + _to_char_p(path), # This will have an issue with Unicode filenames + write_callback_, + metadata_callback_, + error_callback_, + self.client_data + ) + + if init_status: # error + error = flac.FLAC__StreamDecoderInitStatusEnum[init_status] + raise PyOggError( + "An error occured when trying to open '{}': {}".format(path, error) + ) + + metadata_status = (flac.FLAC__stream_decoder_process_until_end_of_metadata(self.decoder)) + if not metadata_status: # error + raise PyOggError("An error occured when trying to decode the metadata of {}".format(path)) + + stream_status = (flac.FLAC__stream_decoder_process_until_end_of_stream(self.decoder)) + if not stream_status: # error + raise PyOggError("An error occured when trying to decode the audio stream of {}".format(path)) + + flac.FLAC__stream_decoder_finish(self.decoder) + + #: Length of buffer + self.buffer_length = len(self.buffer) + + self.bytes_per_sample = ctypes.sizeof(flac.FLAC__int16) # See definition of Buffer in metadata_callback() + + # Cast buffer to one-dimensional array of chars + CharBuffer = ( + ctypes.c_byte * + (self.bytes_per_sample * len(self.buffer)) + ) + self.buffer = CharBuffer.from_buffer(self.buffer) + + # FLAC audio is always signed. See + # https://xiph.org/flac/api/group__flac__stream__decoder.html#gaf98a4f9e2cac5747da6018c3dfc8dde1 + self.signed = True diff --git a/sbapp/pyogg/flac_file_stream.py b/sbapp/pyogg/flac_file_stream.py new file mode 100644 index 0000000..f832c31 --- /dev/null +++ b/sbapp/pyogg/flac_file_stream.py @@ -0,0 +1,141 @@ +import ctypes +from itertools import chain + +from . import flac +from .pyogg_error import PyOggError + +def _to_char_p(string): + try: + return ctypes.c_char_p(string.encode("utf-8")) + except: + return ctypes.c_char_p(string) + +def _resize_array(array, new_size): + return (array._type_*new_size).from_address(ctypes.addressof(array)) + + +class FlacFileStream: + def write_callback(self,decoder, frame, buffer, client_data): + multi_channel_buf = _resize_array(buffer.contents, self.channels) + arr_size = frame.contents.header.blocksize + if frame.contents.header.channels >= 2: + arrays = [] + for i in range(frame.contents.header.channels): + arr = ctypes.cast(multi_channel_buf[i], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents + arrays.append(arr[:]) + + arr = list(chain.from_iterable(zip(*arrays))) + + self.buffer = (flac.FLAC__int16*len(arr))(*arr) + self.bytes_written = len(arr) * 2 + + else: + arr = ctypes.cast(multi_channel_buf[0], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents + self.buffer = (flac.FLAC__int16*len(arr))(*arr[:]) + self.bytes_written = arr_size * 2 + return 0 + + def metadata_callback(self,decoder, metadata, client_data): + self.total_samples = metadata.contents.data.stream_info.total_samples + self.channels = metadata.contents.data.stream_info.channels + self.frequency = metadata.contents.data.stream_info.sample_rate + + def error_callback(self,decoder, status, client_data): + raise PyOggError("An error occured during the process of decoding. Status enum: {}".format(flac.FLAC__StreamDecoderErrorStatusEnum[status])) + + def __init__(self, path): + self.decoder = flac.FLAC__stream_decoder_new() + + self.client_data = ctypes.c_void_p() + + #: Number of channels in audio file. + self.channels = None + + #: Number of samples per second (per channel). For + # example, 44100. + self.frequency = None + + self.total_samples = None + + self.buffer = None + + self.bytes_written = None + + self.write_callback_ = flac.FLAC__StreamDecoderWriteCallback(self.write_callback) + + self.metadata_callback_ = flac.FLAC__StreamDecoderMetadataCallback(self.metadata_callback) + + self.error_callback_ = flac.FLAC__StreamDecoderErrorCallback(self.error_callback) + + init_status = flac.FLAC__stream_decoder_init_file(self.decoder, + _to_char_p(path), + self.write_callback_, + self.metadata_callback_, + self.error_callback_, + self.client_data) + + if init_status: # error + raise PyOggError("An error occured when trying to open '{}': {}".format(path, flac.FLAC__StreamDecoderInitStatusEnum[init_status])) + + metadata_status = (flac.FLAC__stream_decoder_process_until_end_of_metadata(self.decoder)) + if not metadata_status: # error + raise PyOggError("An error occured when trying to decode the metadata of {}".format(path)) + + #: Bytes per sample + self.bytes_per_sample = 2 + + def get_buffer(self): + """Returns the buffer. + + Returns buffer (a bytes object) or None if all data has + been read from the file. + + """ + # Attempt to read a single frame of audio + stream_status = (flac.FLAC__stream_decoder_process_single(self.decoder)) + if not stream_status: # error + raise PyOggError("An error occured when trying to decode the audio stream of {}".format(path)) + + # Check if we encountered the end of the stream + if (flac.FLAC__stream_decoder_get_state(self.decoder) == 4): # end of stream + return None + + buffer_as_bytes = bytes(self.buffer) + return buffer_as_bytes + + def clean_up(self): + flac.FLAC__stream_decoder_finish(self.decoder) + + def get_buffer_as_array(self): + """Provides the buffer as a NumPy array. + + Note that the underlying data type is 16-bit signed + integers. + + Does not copy the underlying data, so the returned array + should either be processed or copied before the next call + to get_buffer() or get_buffer_as_array(). + + """ + import numpy # type: ignore + + # Read the next samples from the stream + buf = self.get_buffer() + + # Check if we've come to the end of the stream + if buf is None: + return None + + # Convert the bytes buffer to a NumPy array + array = numpy.frombuffer( + buf, + dtype=numpy.int16 + ) + + # Reshape the array + return array.reshape( + (len(buf) + // self.bytes_per_sample + // self.channels, + self.channels) + ) diff --git a/sbapp/pyogg/library_loader.py b/sbapp/pyogg/library_loader.py new file mode 100644 index 0000000..711b1ba --- /dev/null +++ b/sbapp/pyogg/library_loader.py @@ -0,0 +1,147 @@ +import ctypes +import ctypes.util +import os +import sys +import platform +from typing import ( + Optional, + Dict, + List +) + +_here = os.path.dirname(__file__) + +class ExternalLibraryError(Exception): + pass + +architecture = platform.architecture()[0] + +_windows_styles = ["{}", "lib{}", "lib{}_dynamic", "{}_dynamic"] + +_other_styles = ["{}", "lib{}"] + +if architecture == "32bit": + for arch_style in ["32bit", "32" "86", "win32", "x86", "_x86", "_32", "_win32", "_32bit"]: + for style in ["{}", "lib{}"]: + _windows_styles.append(style.format("{}"+arch_style)) + +elif architecture == "64bit": + for arch_style in ["64bit", "64" "86_64", "amd64", "win_amd64", "x86_64", "_x86_64", "_64", "_amd64", "_64bit"]: + for style in ["{}", "lib{}"]: + _windows_styles.append(style.format("{}"+arch_style)) + + +run_tests = lambda lib, tests: [f(lib) for f in tests] + +# Get the appropriate directory for the shared libraries depending +# on the current platform and architecture +platform_ = platform.system() +lib_dir = None +if platform_ == "Darwin": + lib_dir = "libs/macos" +elif platform_ == "Windows": + if architecture == "32bit": + lib_dir = "libs/win32" + elif architecture == "64bit": + lib_dir = "libs/win_amd64" + + +class Library: + @staticmethod + def load(names: Dict[str, str], paths: Optional[List[str]] = None, tests = []) -> Optional[ctypes.CDLL]: + lib = InternalLibrary.load(names, tests) + if lib is None: + lib = ExternalLibrary.load(names["external"], paths, tests) + return lib + + +class InternalLibrary: + @staticmethod + def load(names: Dict[str, str], tests) -> Optional[ctypes.CDLL]: + # If we do not have a library directory, give up immediately + if lib_dir is None: + return None + + # Get the appropriate library filename given the platform + try: + name = names[platform_] + except KeyError: + return None + + # Attempt to load the library from here + path = _here + "/" + lib_dir + "/" + name + try: + lib = ctypes.CDLL(path) + except OSError as e: + return None + + # Check that the library passes the tests + if tests and all(run_tests(lib, tests)): + return lib + + # Library failed tests + return None + +# Cache of libraries that have already been loaded +_loaded_libraries: Dict[str, ctypes.CDLL] = {} + +class ExternalLibrary: + @staticmethod + def load(name, paths = None, tests = []): + if name in _loaded_libraries: + return _loaded_libraries[name] + if sys.platform == "win32": + lib = ExternalLibrary.load_windows(name, paths, tests) + _loaded_libraries[name] = lib + return lib + else: + lib = ExternalLibrary.load_other(name, paths, tests) + _loaded_libraries[name] = lib + return lib + + @staticmethod + def load_other(name, paths = None, tests = []): + os.environ["PATH"] += ";" + ";".join((os.getcwd(), _here)) + if paths: os.environ["PATH"] += ";" + ";".join(paths) + + for style in _other_styles: + candidate = style.format(name) + library = ctypes.util.find_library(candidate) + if library: + try: + lib = ctypes.CDLL(library) + if tests and all(run_tests(lib, tests)): + return lib + except: + pass + + @staticmethod + def load_windows(name, paths = None, tests = []): + os.environ["PATH"] += ";" + ";".join((os.getcwd(), _here)) + if paths: os.environ["PATH"] += ";" + ";".join(paths) + + not_supported = [] # libraries that were found, but are not supported + for style in _windows_styles: + candidate = style.format(name) + library = ctypes.util.find_library(candidate) + if library: + try: + lib = ctypes.CDLL(library) + if tests and all(run_tests(lib, tests)): + return lib + not_supported.append(library) + except WindowsError: + pass + except OSError: + not_supported.append(library) + + + if not_supported: + raise ExternalLibraryError("library '{}' couldn't be loaded, because the following candidates were not supported:".format(name) + + ("\n{}" * len(not_supported)).format(*not_supported)) + + raise ExternalLibraryError("library '{}' couldn't be loaded".format(name)) + + + + diff --git a/sbapp/pyogg/ogg.py b/sbapp/pyogg/ogg.py new file mode 100644 index 0000000..08a944b --- /dev/null +++ b/sbapp/pyogg/ogg.py @@ -0,0 +1,672 @@ +############################################################ +# Ogg license: # +############################################################ +""" +Copyright (c) 2002, Xiph.org Foundation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +import ctypes +from ctypes import c_int, c_int8, c_int16, c_int32, c_int64, c_uint, c_uint8, c_uint16, c_uint32, c_uint64, c_float, c_long, c_ulong, c_char, c_char_p, c_ubyte, c_longlong, c_ulonglong, c_size_t, c_void_p, c_double, POINTER, pointer, cast +import ctypes.util +import sys +from traceback import print_exc as _print_exc +import os + +from .library_loader import Library, ExternalLibrary, ExternalLibraryError + + +def get_raw_libname(name): + name = os.path.splitext(name)[0].lower() + for x in "0123456789._- ":name=name.replace(x,"") + return name + +# Define a function to convert strings to char-pointers. In Python 3 +# all strings are Unicode, while in Python 2 they were ASCII-encoded. +# FIXME: Does PyOgg even support Python 2? +if sys.version_info.major > 2: + to_char_p = lambda s: s.encode('utf-8') +else: + to_char_p = lambda s: s + +__here = os.getcwd() + +libogg = None + +try: + names = { + "Windows": "ogg.dll", + "Darwin": "libogg.0.dylib", + "external": "ogg" + } + libogg = Library.load(names, tests = [lambda lib: hasattr(lib, "oggpack_writeinit")]) +except ExternalLibraryError: + pass +except: + _print_exc() + +if libogg is not None: + PYOGG_OGG_AVAIL = True +else: + PYOGG_OGG_AVAIL = False + +if PYOGG_OGG_AVAIL: + # Sanity check also satisfies mypy type checking + assert libogg is not None + + # ctypes + c_ubyte_p = POINTER(c_ubyte) + c_uchar = c_ubyte + c_uchar_p = c_ubyte_p + c_float_p = POINTER(c_float) + c_float_p_p = POINTER(c_float_p) + c_float_p_p_p = POINTER(c_float_p_p) + c_char_p_p = POINTER(c_char_p) + c_int_p = POINTER(c_int) + c_long_p = POINTER(c_long) + + # os_types + ogg_int16_t = c_int16 + ogg_uint16_t = c_uint16 + ogg_int32_t = c_int32 + ogg_uint32_t = c_uint32 + ogg_int64_t = c_int64 + ogg_uint64_t = c_uint64 + ogg_int64_t_p = POINTER(ogg_int64_t) + + # ogg + class ogg_iovec_t(ctypes.Structure): + """ + Wrapper for: + typedef struct ogg_iovec_t; + """ + _fields_ = [("iov_base", c_void_p), + ("iov_len", c_size_t)] + + class oggpack_buffer(ctypes.Structure): + """ + Wrapper for: + typedef struct oggpack_buffer; + """ + _fields_ = [("endbyte", c_long), + ("endbit", c_int), + ("buffer", c_uchar_p), + ("ptr", c_uchar_p), + ("storage", c_long)] + + class ogg_page(ctypes.Structure): + """ + Wrapper for: + typedef struct ogg_page; + """ + _fields_ = [("header", c_uchar_p), + ("header_len", c_long), + ("body", c_uchar_p), + ("body_len", c_long)] + + class ogg_stream_state(ctypes.Structure): + """ + Wrapper for: + typedef struct ogg_stream_state; + """ + _fields_ = [("body_data", c_uchar_p), + ("body_storage", c_long), + ("body_fill", c_long), + ("body_returned", c_long), + + ("lacing_vals", c_int), + ("granule_vals", ogg_int64_t), + + ("lacing_storage", c_long), + ("lacing_fill", c_long), + ("lacing_packet", c_long), + ("lacing_returned", c_long), + + ("header", c_uchar*282), + ("header_fill", c_int), + + ("e_o_s", c_int), + ("b_o_s", c_int), + + ("serialno", c_long), + ("pageno", c_long), + ("packetno", ogg_int64_t), + ("granulepos", ogg_int64_t)] + + class ogg_packet(ctypes.Structure): + """ + Wrapper for: + typedef struct ogg_packet; + """ + _fields_ = [("packet", c_uchar_p), + ("bytes", c_long), + ("b_o_s", c_long), + ("e_o_s", c_long), + + ("granulepos", ogg_int64_t), + + ("packetno", ogg_int64_t)] + + def __str__(self): + bos = "" + if self.b_o_s: + bos = "beginning of stream, " + eos = "" + if self.e_o_s: + eos = "end of stream, " + + # Converting the data will cause a seg-fault if the memory isn't valid + data = bytes(self.packet[0:self.bytes]) + value = ( + f"Ogg Packet <{hex(id(self))}>: " + + f"number {self.packetno}, " + + f"granule position {self.granulepos}, " + + bos + eos + + f"{self.bytes} bytes" + ) + return value + + class ogg_sync_state(ctypes.Structure): + """ + Wrapper for: + typedef struct ogg_sync_state; + """ + _fields_ = [("data", c_uchar_p), + ("storage", c_int), + ("fill", c_int), + ("returned", c_int), + + ("unsynched", c_int), + ("headerbytes", c_int), + ("bodybytes", c_int)] + + b_p = POINTER(oggpack_buffer) + oy_p = POINTER(ogg_sync_state) + op_p = POINTER(ogg_packet) + og_p = POINTER(ogg_page) + os_p = POINTER(ogg_stream_state) + iov_p = POINTER(ogg_iovec_t) + + libogg.oggpack_writeinit.restype = None + libogg.oggpack_writeinit.argtypes = [b_p] + + def oggpack_writeinit(b): + libogg.oggpack_writeinit(b) + + try: + libogg.oggpack_writecheck.restype = c_int + libogg.oggpack_writecheck.argtypes = [b_p] + def oggpack_writecheck(b): + libogg.oggpack_writecheck(b) + except: + pass + + libogg.oggpack_writetrunc.restype = None + libogg.oggpack_writetrunc.argtypes = [b_p, c_long] + + def oggpack_writetrunc(b, bits): + libogg.oggpack_writetrunc(b, bits) + + libogg.oggpack_writealign.restype = None + libogg.oggpack_writealign.argtypes = [b_p] + + def oggpack_writealign(b): + libogg.oggpack_writealign(b) + + libogg.oggpack_writecopy.restype = None + libogg.oggpack_writecopy.argtypes = [b_p, c_void_p, c_long] + + def oggpack_writecopy(b, source, bits): + libogg.oggpack_writecopy(b, source, bits) + + libogg.oggpack_reset.restype = None + libogg.oggpack_reset.argtypes = [b_p] + + def oggpack_reset(b): + libogg.oggpack_reset(b) + + libogg.oggpack_writeclear.restype = None + libogg.oggpack_writeclear.argtypes = [b_p] + + def oggpack_writeclear(b): + libogg.oggpack_writeclear(b) + + libogg.oggpack_readinit.restype = None + libogg.oggpack_readinit.argtypes = [b_p, c_uchar_p, c_int] + + def oggpack_readinit(b, buf, bytes): + libogg.oggpack_readinit(b, buf, bytes) + + libogg.oggpack_write.restype = None + libogg.oggpack_write.argtypes = [b_p, c_ulong, c_int] + + def oggpack_write(b, value, bits): + libogg.oggpack_write(b, value, bits) + + libogg.oggpack_look.restype = c_long + libogg.oggpack_look.argtypes = [b_p, c_int] + + def oggpack_look(b, bits): + return libogg.oggpack_look(b, bits) + + libogg.oggpack_look1.restype = c_long + libogg.oggpack_look1.argtypes = [b_p] + + def oggpack_look1(b): + return libogg.oggpack_look1(b) + + libogg.oggpack_adv.restype = None + libogg.oggpack_adv.argtypes = [b_p, c_int] + + def oggpack_adv(b, bits): + libogg.oggpack_adv(b, bits) + + libogg.oggpack_adv1.restype = None + libogg.oggpack_adv1.argtypes = [b_p] + + def oggpack_adv1(b): + libogg.oggpack_adv1(b) + + libogg.oggpack_read.restype = c_long + libogg.oggpack_read.argtypes = [b_p, c_int] + + def oggpack_read(b, bits): + return libogg.oggpack_read(b, bits) + + libogg.oggpack_read1.restype = c_long + libogg.oggpack_read1.argtypes = [b_p] + + def oggpack_read1(b): + return libogg.oggpack_read1(b) + + libogg.oggpack_bytes.restype = c_long + libogg.oggpack_bytes.argtypes = [b_p] + + def oggpack_bytes(b): + return libogg.oggpack_bytes(b) + + libogg.oggpack_bits.restype = c_long + libogg.oggpack_bits.argtypes = [b_p] + + def oggpack_bits(b): + return libogg.oggpack_bits(b) + + libogg.oggpack_get_buffer.restype = c_uchar_p + libogg.oggpack_get_buffer.argtypes = [b_p] + + def oggpack_get_buffer(b): + return libogg.oggpack_get_buffer(b) + + + + libogg.oggpackB_writeinit.restype = None + libogg.oggpackB_writeinit.argtypes = [b_p] + + def oggpackB_writeinit(b): + libogg.oggpackB_writeinit(b) + + try: + libogg.oggpackB_writecheck.restype = c_int + libogg.oggpackB_writecheck.argtypes = [b_p] + + def oggpackB_writecheck(b): + return libogg.oggpackB_writecheck(b) + except: + pass + + libogg.oggpackB_writetrunc.restype = None + libogg.oggpackB_writetrunc.argtypes = [b_p, c_long] + + def oggpackB_writetrunc(b, bits): + libogg.oggpackB_writetrunc(b, bits) + + libogg.oggpackB_writealign.restype = None + libogg.oggpackB_writealign.argtypes = [b_p] + + def oggpackB_writealign(b): + libogg.oggpackB_writealign(b) + + libogg.oggpackB_writecopy.restype = None + libogg.oggpackB_writecopy.argtypes = [b_p, c_void_p, c_long] + + def oggpackB_writecopy(b, source, bits): + libogg.oggpackB_writecopy(b, source, bits) + + libogg.oggpackB_reset.restype = None + libogg.oggpackB_reset.argtypes = [b_p] + + def oggpackB_reset(b): + libogg.oggpackB_reset(b) + + libogg.oggpackB_reset.restype = None + libogg.oggpackB_writeclear.argtypes = [b_p] + + def oggpackB_reset(b): + libogg.oggpackB_reset(b) + + libogg.oggpackB_readinit.restype = None + libogg.oggpackB_readinit.argtypes = [b_p, c_uchar_p, c_int] + + def oggpackB_readinit(b, buf, bytes): + libogg.oggpackB_readinit(b, buf, bytes) + + libogg.oggpackB_write.restype = None + libogg.oggpackB_write.argtypes = [b_p, c_ulong, c_int] + + def oggpackB_write(b, value, bits): + libogg.oggpackB_write(b, value, bits) + + libogg.oggpackB_look.restype = c_long + libogg.oggpackB_look.argtypes = [b_p, c_int] + + def oggpackB_look(b, bits): + return libogg.oggpackB_look(b, bits) + + libogg.oggpackB_look1.restype = c_long + libogg.oggpackB_look1.argtypes = [b_p] + + def oggpackB_look1(b): + return libogg.oggpackB_look1(b) + + libogg.oggpackB_adv.restype = None + libogg.oggpackB_adv.argtypes = [b_p, c_int] + + def oggpackB_adv(b, bits): + libogg.oggpackB_adv(b, bits) + + libogg.oggpackB_adv1.restype = None + libogg.oggpackB_adv1.argtypes = [b_p] + + def oggpackB_adv1(b): + libogg.oggpackB_adv1(b) + + libogg.oggpackB_read.restype = c_long + libogg.oggpackB_read.argtypes = [b_p, c_int] + + def oggpackB_read(b, bits): + return libogg.oggpackB_read(b, bits) + + libogg.oggpackB_read1.restype = c_long + libogg.oggpackB_read1.argtypes = [b_p] + + def oggpackB_read1(b): + return libogg.oggpackB_read1(b) + + libogg.oggpackB_bytes.restype = c_long + libogg.oggpackB_bytes.argtypes = [b_p] + + def oggpackB_bytes(b): + return libogg.oggpackB_bytes(b) + + libogg.oggpackB_bits.restype = c_long + libogg.oggpackB_bits.argtypes = [b_p] + + def oggpackB_bits(b): + return libogg.oggpackB_bits(b) + + libogg.oggpackB_get_buffer.restype = c_uchar_p + libogg.oggpackB_get_buffer.argtypes = [b_p] + + def oggpackB_get_buffer(b): + return libogg.oggpackB_get_buffer(b) + + + + libogg.ogg_stream_packetin.restype = c_int + libogg.ogg_stream_packetin.argtypes = [os_p, op_p] + + def ogg_stream_packetin(os, op): + return libogg.ogg_stream_packetin(os, op) + + try: + libogg.ogg_stream_iovecin.restype = c_int + libogg.ogg_stream_iovecin.argtypes = [os_p, iov_p, c_int, c_long, ogg_int64_t] + + def ogg_stream_iovecin(os, iov, count, e_o_s, granulepos): + return libogg.ogg_stream_iovecin(os, iov, count, e_o_s, granulepos) + except: + pass + + libogg.ogg_stream_pageout.restype = c_int + libogg.ogg_stream_pageout.argtypes = [os_p, og_p] + + def ogg_stream_pageout(os, og): + return libogg.ogg_stream_pageout(os, og) + + try: + libogg.ogg_stream_pageout_fill.restype = c_int + libogg.ogg_stream_pageout_fill.argtypes = [os_p, og_p, c_int] + def ogg_stream_pageout_fill(os, og, nfill): + return libogg.ogg_stream_pageout_fill(os, og, nfill) + except: + pass + + libogg.ogg_stream_flush.restype = c_int + libogg.ogg_stream_flush.argtypes = [os_p, og_p] + + def ogg_stream_flush(os, og): + return libogg.ogg_stream_flush(os, og) + + try: + libogg.ogg_stream_flush_fill.restype = c_int + libogg.ogg_stream_flush_fill.argtypes = [os_p, og_p, c_int] + def ogg_stream_flush_fill(os, og, nfill): + return libogg.ogg_stream_flush_fill(os, og, nfill) + except: + pass + + + + libogg.ogg_sync_init.restype = c_int + libogg.ogg_sync_init.argtypes = [oy_p] + + def ogg_sync_init(oy): + return libogg.ogg_sync_init(oy) + + libogg.ogg_sync_clear.restype = c_int + libogg.ogg_sync_clear.argtypes = [oy_p] + + def ogg_sync_clear(oy): + return libogg.ogg_sync_clear(oy) + + libogg.ogg_sync_reset.restype = c_int + libogg.ogg_sync_reset.argtypes = [oy_p] + + def ogg_sync_reset(oy): + return libogg.ogg_sync_reset(oy) + + libogg.ogg_sync_destroy.restype = c_int + libogg.ogg_sync_destroy.argtypes = [oy_p] + + def ogg_sync_destroy(oy): + return libogg.ogg_sync_destroy(oy) + + try: + libogg.ogg_sync_check.restype = c_int + libogg.ogg_sync_check.argtypes = [oy_p] + def ogg_sync_check(oy): + return libogg.ogg_sync_check(oy) + except: + pass + + + + libogg.ogg_sync_buffer.restype = c_char_p + libogg.ogg_sync_buffer.argtypes = [oy_p, c_long] + + def ogg_sync_buffer(oy, size): + return libogg.ogg_sync_buffer(oy, size) + + libogg.ogg_sync_wrote.restype = c_int + libogg.ogg_sync_wrote.argtypes = [oy_p, c_long] + + def ogg_sync_wrote(oy, bytes): + return libogg.ogg_sync_wrote(oy, bytes) + + libogg.ogg_sync_pageseek.restype = c_int + libogg.ogg_sync_pageseek.argtypes = [oy_p, og_p] + + def ogg_sync_pageseek(oy, og): + return libogg.ogg_sync_pageseek(oy, og) + + libogg.ogg_sync_pageout.restype = c_long + libogg.ogg_sync_pageout.argtypes = [oy_p, og_p] + + def ogg_sync_pageout(oy, og): + return libogg.ogg_sync_pageout(oy, og) + + libogg.ogg_stream_pagein.restype = c_int + libogg.ogg_stream_pagein.argtypes = [os_p, og_p] + + def ogg_stream_pagein(os, og): + return libogg.ogg_stream_pagein(oy, og) + + libogg.ogg_stream_packetout.restype = c_int + libogg.ogg_stream_packetout.argtypes = [os_p, op_p] + + def ogg_stream_packetout(os, op): + return libogg.ogg_stream_packetout(oy, op) + + libogg.ogg_stream_packetpeek.restype = c_int + libogg.ogg_stream_packetpeek.argtypes = [os_p, op_p] + + def ogg_stream_packetpeek(os, op): + return libogg.ogg_stream_packetpeek(os, op) + + + + libogg.ogg_stream_init.restype = c_int + libogg.ogg_stream_init.argtypes = [os_p, c_int] + + def ogg_stream_init(os, serialno): + return libogg.ogg_stream_init(os, serialno) + + libogg.ogg_stream_clear.restype = c_int + libogg.ogg_stream_clear.argtypes = [os_p] + + def ogg_stream_clear(os): + return libogg.ogg_stream_clear(os) + + libogg.ogg_stream_reset.restype = c_int + libogg.ogg_stream_reset.argtypes = [os_p] + + def ogg_stream_reset(os): + return libogg.ogg_stream_reset(os) + + libogg.ogg_stream_reset_serialno.restype = c_int + libogg.ogg_stream_reset_serialno.argtypes = [os_p, c_int] + + def ogg_stream_reset_serialno(os, serialno): + return libogg.ogg_stream_reset_serialno(os, serialno) + + libogg.ogg_stream_destroy.restype = c_int + libogg.ogg_stream_destroy.argtypes = [os_p] + + def ogg_stream_destroy(os): + return libogg.ogg_stream_destroy(os) + + try: + libogg.ogg_stream_check.restype = c_int + libogg.ogg_stream_check.argtypes = [os_p] + def ogg_stream_check(os): + return libogg.ogg_stream_check(os) + except: + pass + + libogg.ogg_stream_eos.restype = c_int + libogg.ogg_stream_eos.argtypes = [os_p] + + def ogg_stream_eos(os): + return libogg.ogg_stream_eos(os) + + + + libogg.ogg_page_checksum_set.restype = None + libogg.ogg_page_checksum_set.argtypes = [og_p] + + def ogg_page_checksum_set(og): + libogg.ogg_page_checksum_set(og) + + + + libogg.ogg_page_version.restype = c_int + libogg.ogg_page_version.argtypes = [og_p] + + def ogg_page_version(og): + return libogg.ogg_page_version(og) + + libogg.ogg_page_continued.restype = c_int + libogg.ogg_page_continued.argtypes = [og_p] + + def ogg_page_continued(og): + return libogg.ogg_page_continued(og) + + libogg.ogg_page_bos.restype = c_int + libogg.ogg_page_bos.argtypes = [og_p] + + def ogg_page_bos(og): + return libogg.ogg_page_bos(og) + + libogg.ogg_page_eos.restype = c_int + libogg.ogg_page_eos.argtypes = [og_p] + + def ogg_page_eos(og): + return libogg.ogg_page_eos(og) + + libogg.ogg_page_granulepos.restype = ogg_int64_t + libogg.ogg_page_granulepos.argtypes = [og_p] + + def ogg_page_granulepos(og): + return libogg.ogg_page_granulepos(og) + + libogg.ogg_page_serialno.restype = c_int + libogg.ogg_page_serialno.argtypes = [og_p] + + def ogg_page_serialno(og): + return libogg.ogg_page_serialno(og) + + libogg.ogg_page_pageno.restype = c_long + libogg.ogg_page_pageno.argtypes = [og_p] + + def ogg_page_pageno(og): + return libogg.ogg_page_pageno(og) + + libogg.ogg_page_packets.restype = c_int + libogg.ogg_page_packets.argtypes = [og_p] + + def ogg_page_packets(og): + return libogg.ogg_page_packets(og) + + + + libogg.ogg_packet_clear.restype = None + libogg.ogg_packet_clear.argtypes = [op_p] + + def ogg_packet_clear(op): + libogg.ogg_packet_clear(op) diff --git a/sbapp/pyogg/ogg_opus_writer.py b/sbapp/pyogg/ogg_opus_writer.py new file mode 100644 index 0000000..547d0f5 --- /dev/null +++ b/sbapp/pyogg/ogg_opus_writer.py @@ -0,0 +1,421 @@ +import builtins +import copy +import ctypes +import random +import struct +from typing import ( + Optional, + Union, + BinaryIO +) + +from . import ogg +from . import opus +from .opus_buffered_encoder import OpusBufferedEncoder +#from .opus_encoder import OpusEncoder +from .pyogg_error import PyOggError + +class OggOpusWriter(): + """Encodes PCM data into an OggOpus file.""" + + def __init__(self, + f: Union[BinaryIO, str], + encoder: OpusBufferedEncoder, + custom_pre_skip: Optional[int] = None) -> None: + """Construct an OggOpusWriter. + + f may be either a string giving the path to the file, or + an already-opened file handle. + + If f is an already-opened file handle, then it is the + user's responsibility to close the file when they are + finished with it. The file should be opened for writing + in binary (not text) mode. + + The encoder should be a + OpusBufferedEncoder and should be fully configured before the + first call to the `write()` method. + + The Opus encoder requires an amount of "warm up" and when + stored in an Ogg container that warm up can be skipped. When + `custom_pre_skip` is None, the required amount of warm up + silence is automatically calculated and inserted. If a custom + (non-silent) pre-skip is desired, then `custom_pre_skip` + should be specified as the number of samples (per channel). + It is then the user's responsibility to pass the non-silent + pre-skip samples to `encode()`. + + """ + # Store the Opus encoder + self._encoder = encoder + + # Store the custom pre skip + self._custom_pre_skip = custom_pre_skip + + # Create a new stream state with a random serial number + self._stream_state = self._create_stream_state() + + # Create a packet (reused for each pass) + self._ogg_packet = ogg.ogg_packet() + self._packet_valid = False + + # Create a page (reused for each pass) + self._ogg_page = ogg.ogg_page() + + # Counter for the number of packets written into Ogg stream + self._count_packets = 0 + + # Counter for the number of samples encoded into Opus + # packets + self._count_samples = 0 + + # Flag to indicate if the headers have been written + self._headers_written = False + + # Flag to indicate that the stream has been finished (the + # EOS bit was set in a final packet) + self._finished = False + + # Reference to the current encoded packet (written only + # when we know if it the last) + self._current_encoded_packet: Optional[bytes] = None + + # Open file if required. Given this may raise an exception, + # it should be the last step of initialisation. + self._i_opened_the_file = False + if isinstance(f, str): + self._file = builtins.open(f, 'wb') + self._i_opened_the_file = True + else: + # Assume it's already opened file + self._file = f + + def __del__(self) -> None: + if not self._finished: + self.close() + + # + # User visible methods + # + + def write(self, pcm: memoryview) -> None: + """Encode the PCM and write out the Ogg Opus stream. + + Encoders the PCM using the provided encoder. + + """ + # Check that the stream hasn't already been finished + if self._finished: + raise PyOggError( + "Stream has already ended. Perhaps close() was "+ + "called too early?") + + # If we haven't already written out the headers, do so + # now. Then, write a frame of silence to warm up the + # encoder. + if not self._headers_written: + pre_skip = self._write_headers(self._custom_pre_skip) + if self._custom_pre_skip is None: + self._write_silence(pre_skip) + + # Call the internal method to encode the bytes + self._write_to_oggopus(pcm) + + + def _write_to_oggopus(self, pcm: memoryview, flush: bool = False) -> None: + assert self._encoder is not None + + def handle_encoded_packet(encoded_packet: memoryview, + samples: int, + end_of_stream: bool) -> None: + # Cast memoryview to ctypes Array + Buffer = ctypes.c_ubyte * len(encoded_packet) + encoded_packet_ctypes = Buffer.from_buffer(encoded_packet) + + # Obtain a pointer to the encoded packet + encoded_packet_ptr = ctypes.cast( + encoded_packet_ctypes, + ctypes.POINTER(ctypes.c_ubyte) + ) + + # Increase the count of the number of samples written + self._count_samples += samples + + # Place data into the packet + self._ogg_packet.packet = encoded_packet_ptr + self._ogg_packet.bytes = len(encoded_packet) + self._ogg_packet.b_o_s = 0 + self._ogg_packet.e_o_s = end_of_stream + self._ogg_packet.granulepos = self._count_samples + self._ogg_packet.packetno = self._count_packets + + # Increase the counter of the number of packets + # in the stream + self._count_packets += 1 + + # Write the packet into the stream + self._write_packet() + + + # Encode the PCM data into an Opus packet + self._encoder.buffered_encode( + pcm, + flush=flush, + callback=handle_encoded_packet + ) + + def close(self) -> None: + # Check we haven't already closed this stream + if self._finished: + # We're attempting to close an already closed stream, + # do nothing more. + return + + # Flush the underlying buffered encoder + self._write_to_oggopus(memoryview(bytearray(b"")), flush=True) + + # The current packet must be the end of the stream, update + # the packet's details + self._ogg_packet.e_o_s = 1 + + # Write the packet to the stream + if self._packet_valid: + self._write_packet() + + # Flush the stream of any unwritten pages + self._flush() + + # Mark the stream as finished + self._finished = True + + # Close the file if we opened it + if self._i_opened_the_file: + self._file.close() + self._i_opened_the_file = False + + # Clean up the Ogg-related memory + ogg.ogg_stream_clear(self._stream_state) + + # Clean up the reference to the encoded packet (as it must + # now have been written) + del self._current_encoded_packet + + # + # Internal methods + # + + def _create_random_serial_no(self) -> ctypes.c_int: + sizeof_c_int = ctypes.sizeof(ctypes.c_int) + min_int = -2**(sizeof_c_int*8-1) + max_int = 2**(sizeof_c_int*8-1)-1 + serial_no = ctypes.c_int(random.randint(min_int, max_int)) + + return serial_no + + def _create_stream_state(self) -> ogg.ogg_stream_state: + # Create a random serial number + serial_no = self._create_random_serial_no() + + # Create an ogg_stream_state + ogg_stream_state = ogg.ogg_stream_state() + + # Initialise the stream state + ogg.ogg_stream_init( + ctypes.pointer(ogg_stream_state), + serial_no + ) + + return ogg_stream_state + + def _make_identification_header(self, pre_skip: int, input_sampling_rate: int = 0) -> bytes: + """Make the OggOpus identification header. + + An input_sampling rate may be set to zero to mean 'unspecified'. + + Only channel mapping family 0 is currently supported. + This allows mono and stereo signals. + + See https://tools.ietf.org/html/rfc7845#page-12 for more + details. + + """ + signature = b"OpusHead" + version = 1 + output_channels = self._encoder._channels + output_gain = 0 + channel_mapping_family = 0 + data = struct.pack( + " int: + """ Returns pre-skip. """ + if custom_pre_skip is not None: + # Use the user-specified amount of pre-skip + pre_skip = custom_pre_skip + else: + # Obtain the algorithmic delay of the Opus encoder. See + # https://tools.ietf.org/html/rfc7845#page-27 + delay_samples = self._encoder.get_algorithmic_delay() + + # Extra samples are recommended. See + # https://tools.ietf.org/html/rfc7845#page-27 + extra_samples = 120 + + # We will just fill a whole frame with silence. Calculate + # the minimum frame length, which we'll use as the + # pre-skip. + frame_durations = [2.5, 5, 10, 20, 40, 60] # milliseconds + frame_lengths = [ + x * self._encoder._samples_per_second // 1000 + for x in frame_durations + ] + for frame_length in frame_lengths: + if frame_length > delay_samples + extra_samples: + pre_skip = frame_length + break + + # Create the identification header + id_header = self._make_identification_header( + pre_skip = pre_skip + ) + + # Specify the packet containing the identification header + self._ogg_packet.packet = ctypes.cast(id_header, ogg.c_uchar_p) # type: ignore + self._ogg_packet.bytes = len(id_header) + self._ogg_packet.b_o_s = 1 + self._ogg_packet.e_o_s = 0 + self._ogg_packet.granulepos = 0 + self._ogg_packet.packetno = self._count_packets + self._count_packets += 1 + + # Write the identification header + result = ogg.ogg_stream_packetin( + self._stream_state, + self._ogg_packet + ) + + if result != 0: + raise PyOggError( + "Failed to write Opus identification header" + ) + + return pre_skip + + def _make_comment_header(self): + """Make the OggOpus comment header. + + See https://tools.ietf.org/html/rfc7845#page-22 for more + details. + + """ + signature = b"OpusTags" + vendor_string = b"ENCODER=PyOgg" + vendor_string_length = struct.pack(" None: + super().__init__() + + self._frame_size_ms: Optional[float] = None + self._frame_size_bytes: Optional[int] = None + + # Buffer contains the bytes required for the next + # frame. + self._buffer: Optional[ctypes.Array] = None + + # Location of the next free byte in the buffer + self._buffer_index = 0 + + + def set_frame_size(self, frame_size: float) -> None: + """ Set the desired frame duration (in milliseconds). + + Valid options are 2.5, 5, 10, 20, 40, or 60ms. + + """ + + # Ensure the frame size is valid. Compare frame size in + # units of 0.1ms to avoid floating point comparison + if int(frame_size*10) not in [25, 50, 100, 200, 400, 600]: + raise PyOggError( + "Frame size ({:f}) not one of ".format(frame_size)+ + "the acceptable values" + ) + + self._frame_size_ms = frame_size + + self._calc_frame_size() + + + def set_sampling_frequency(self, samples_per_second: int) -> None: + super().set_sampling_frequency(samples_per_second) + self._calc_frame_size() + + + def buffered_encode(self, + pcm_bytes: memoryview, + flush: bool = False, + callback: Callable[[memoryview,int,bool],None] = None + ) -> List[Tuple[memoryview, int, bool]]: + """Gets encoded packets and their number of samples. + + This method returns a list, where each item in the list is + a tuple. The first item in the tuple is an Opus-encoded + frame stored as a bytes-object. The second item in the + tuple is the number of samples encoded (excluding + silence). + + If `callback` is supplied then this method will instead + return an empty list but call the callback for every + Opus-encoded frame that would have been returned as a + list. This option has the desireable property of + eliminating the copying of the encoded packets, which is + required in order to form a list. The callback should + take two arguments, the encoded frame (a Python bytes + object) and the number of samples encoded per channel (an + int). The user must either process or copy the data as + the data may be overwritten once the callback terminates. + + """ + # If there's no work to do return immediately + if len(pcm_bytes) == 0 and flush == False: + return [] # no work to do + + # Sanity checks + if self._frame_size_ms is None: + raise PyOggError("Frame size must be set before encoding") + assert self._frame_size_bytes is not None + assert self._channels is not None + assert self._buffer is not None + assert self._buffer_index is not None + + # Local variable initialisation + results = [] + pcm_index = 0 + pcm_len = len(pcm_bytes) + + # 'Cast' memoryview of PCM to ctypes Array + Buffer = ctypes.c_ubyte * len(pcm_bytes) + try: + pcm_ctypes = Buffer.from_buffer(pcm_bytes) + except TypeError: + warnings.warn( + "Because PCM was read-only, an extra memory "+ + "copy was required; consider storing PCM in "+ + "writable memory (for example, bytearray "+ + "rather than bytes)." + ) + pcm_ctypes = Buffer.from_buffer(pcm_bytes) + + # Either store the encoded packet to return at the end of the + # method or immediately call the callback with the encoded + # packet. + def store_or_callback(encoded_packet: memoryview, + samples: int, + end_of_stream: bool = False) -> None: + if callback is None: + # Store the result + results.append(( + encoded_packet, + samples, + end_of_stream + )) + else: + # Call the callback + callback( + encoded_packet, + samples, + end_of_stream + ) + + # Fill the remainder of the buffer with silence and encode it. + # The associated number of samples are only that of actual + # data, not the added silence. + def flush_buffer() -> None: + # Sanity checks to satisfy mypy + assert self._buffer_index is not None + assert self._channels is not None + assert self._buffer is not None + + # If the buffer is already empty, we have no work to do + if self._buffer_index == 0: + return + + # Store the number of samples currently in the buffer + samples = ( + self._buffer_index + // self._channels + // ctypes.sizeof(opus.opus_int16) + ) + + # Fill the buffer with silence + ctypes.memset( + # destination + ctypes.byref(self._buffer, self._buffer_index), + # value + 0, + # count + len(self._buffer) - self._buffer_index + ) + + # Encode the PCM + # As at 2020-11-05, mypy is unaware that ctype Arrays + # support the buffer protocol. + encoded_packet = self.encode(memoryview(self._buffer)) # type: ignore + + # Either store the encoded packet or call the + # callback + store_or_callback(encoded_packet, samples, True) + + + # Copy the data remaining from the provided PCM into the + # buffer. Flush if required. + def copy_insufficient_data() -> None: + # Sanity checks to satisfy mypy + assert self._buffer is not None + + # Calculate remaining data + remaining_data = len(pcm_bytes) - pcm_index + + # Copy the data into the buffer. + ctypes.memmove( + # destination + ctypes.byref(self._buffer, self._buffer_index), + # source + ctypes.byref(pcm_ctypes, pcm_index), + # count + remaining_data + ) + + self._buffer_index += remaining_data + + # If we've been asked to flush the buffer then do so + if flush: + flush_buffer() + + # Loop through the provided PCM and the current buffer, + # encoding as we have full packets. + while True: + # There are two possibilities at this point: either we + # have previously unencoded data still in the buffer or we + # do not + if self._buffer_index == 0: + # We do not have unencoded data + + # We are free to progress through the PCM that has + # been provided encoding frames without copying any + # bytes. Once there is insufficient data remaining + # for a complete frame, that data should be copied + # into the buffer and we have finished. + if pcm_len - pcm_index > self._frame_size_bytes: + # We have enough data remaining in the provided + # PCM to encode more than an entire frame without + # copying any data. Unfortunately, splicing a + # ctypes array copies the array. To avoid the + # copy we use memoryview see + # https://mattgwwalker.wordpress.com/2020/12/12/python-ctypes-slicing/ + frame_data = memoryview(pcm_bytes)[ + pcm_index:pcm_index+self._frame_size_bytes + ] + + # Update the PCM index + pcm_index += self._frame_size_bytes + + # Store number of samples (per channel) of actual + # data + samples = ( + len(frame_data) + // self._channels + // ctypes.sizeof(opus.opus_int16) + ) + + # Encode the PCM + encoded_packet = super().encode(frame_data) + + # Either store the encoded packet or call the + # callback + store_or_callback(encoded_packet, samples) + + else: + # We do not have enough data to fill a frame while + # still having data left over. Copy the data into + # the buffer. + copy_insufficient_data() + return results + + else: + # We have unencoded data. + + # Copy the provided PCM into the buffer (up until the + # buffer is full). If we can fill it, then we can + # encode the filled buffer and continue. If we can't + # fill it then we've finished. + data_required = len(self._buffer) - self._buffer_index + if pcm_len > data_required: + # We have sufficient data to fill the buffer and + # have data left over. Copy data into the buffer. + assert pcm_index == 0 + remaining = len(self._buffer) - self._buffer_index + ctypes.memmove( + # destination + ctypes.byref(self._buffer, self._buffer_index), + # source + pcm_ctypes, + # count + remaining + ) + pcm_index += remaining + self._buffer_index += remaining + assert self._buffer_index == len(self._buffer) + + # Encode the PCM + encoded_packet = super().encode( + # Memoryviews of ctypes do work, even though + # mypy complains. + memoryview(self._buffer) # type: ignore + ) + + # Store number of samples (per channel) of actual + # data + samples = ( + self._buffer_index + // self._channels + // ctypes.sizeof(opus.opus_int16) + ) + + # We've now processed the buffer + self._buffer_index = 0 + + # Either store the encoded packet or call the + # callback + store_or_callback(encoded_packet, samples) + else: + # We have insufficient data to fill the buffer + # while still having data left over. Copy the + # data into the buffer. + copy_insufficient_data() + return results + + + def _calc_frame_size(self): + """Calculates the number of bytes in a frame. + + If the frame size (in milliseconds) and the number of + samples per seconds have already been specified, then the + frame size in bytes is set. Otherwise, this method does + nothing. + + The frame size is measured in bytes required to store the + sample. + + """ + if (self._frame_size_ms is None + or self._samples_per_second is None): + return + + self._frame_size_bytes = ( + self._frame_size_ms + * self._samples_per_second + // 1000 + * ctypes.sizeof(opus.opus_int16) + * self._channels + ) + + # Allocate space for the buffer + Buffer = ctypes.c_ubyte * self._frame_size_bytes + self._buffer = Buffer() + + + def _get_next_frame(self, add_silence=False): + """Gets the next Opus-encoded frame. + + Returns a tuple where the first item is the Opus-encoded + frame and the second item is the number of encoded samples + (per channel). + + Returns None if insufficient data is available. + + """ + next_frame = bytes() + samples = 0 + + # Ensure frame size has been specified + if self._frame_size_bytes is None: + raise PyOggError( + "Desired frame size hasn't been set. Perhaps "+ + "encode() was called before set_frame_size() "+ + "and set_sampling_frequency()?" + ) + + # Check if there's insufficient data in the buffer to fill + # a frame. + if self._frame_size_bytes > self._buffer_size: + if len(self._buffer) == 0: + # No data at all in buffer + return None + if add_silence: + # Get all remaining data + while len(self._buffer) != 0: + next_frame += self._buffer.popleft() + self._buffer_size = 0 + # Store number of samples (per channel) of actual + # data + samples = ( + len(next_frame) + // self._channels + // ctypes.sizeof(opus.opus_int16) + ) + # Fill remainder of frame with silence + bytes_remaining = self._frame_size_bytes - len(next_frame) + next_frame += b'\x00' * bytes_remaining + return (next_frame, samples) + else: + # Insufficient data to fill a frame and we're not + # adding silence + return None + + bytes_remaining = self._frame_size_bytes + while bytes_remaining > 0: + if len(self._buffer[0]) <= bytes_remaining: + # Take the whole first item + buffer_ = self._buffer.popleft() + next_frame += buffer_ + bytes_remaining -= len(buffer_) + self._buffer_size -= len(buffer_) + else: + # Take only part of the buffer + + # TODO: This could be more efficiently + # implemented. Rather than appending back the + # remaining data, we could just update an index + # saying where we were up to in regards to the + # first entry of the buffer. + buffer_ = self._buffer.popleft() + next_frame += buffer_[:bytes_remaining] + self._buffer_size -= bytes_remaining + # And put the unused part back into the buffer + self._buffer.appendleft(buffer_[bytes_remaining:]) + bytes_remaining = 0 + + # Calculate number of samples (per channel) + samples = ( + len(next_frame) + // self._channels + // ctypes.sizeof(opus.opus_int16) + ) + + return (next_frame, samples) diff --git a/sbapp/pyogg/opus_decoder.py b/sbapp/pyogg/opus_decoder.py new file mode 100644 index 0000000..8a1f4dd --- /dev/null +++ b/sbapp/pyogg/opus_decoder.py @@ -0,0 +1,273 @@ +import ctypes + +from . import opus +from .pyogg_error import PyOggError + +class OpusDecoder: + def __init__(self): + self._decoder = None + self._channels = None + self._samples_per_second = None + self._pcm_buffer = None + self._pcm_buffer_ptr = None + self._pcm_buffer_size_int = None + + # TODO: Check if there is clean up that we need to do when + # closing a decoder. + + # + # User visible methods + # + + def set_channels(self, n): + + """Set the number of channels. + + n must be either 1 or 2. + + The decoder is capable of filling in either mono or + interleaved stereo pcm buffers. + + """ + if self._decoder is None: + if n < 0 or n > 2: + raise PyOggError( + "Invalid number of channels in call to "+ + "set_channels()" + ) + self._channels = n + else: + raise PyOggError( + "Cannot change the number of channels after "+ + "the decoder was created. Perhaps "+ + "set_channels() was called after decode()?" + ) + self._create_pcm_buffer() + + def set_sampling_frequency(self, samples_per_second): + """Set the number of samples (per channel) per second. + + samples_per_second must be one of 8000, 12000, 16000, + 24000, or 48000. + + Internally Opus stores data at 48000 Hz, so that should be + the default value for Fs. However, the decoder can + efficiently decode to buffers at 8, 12, 16, and 24 kHz so + if for some reason the caller cannot use data at the full + sample rate, or knows the compressed data doesn't use the + full frequency range, it can request decoding at a reduced + rate. + + """ + if self._decoder is None: + if samples_per_second in [8000, 12000, 16000, 24000, 48000]: + self._samples_per_second = samples_per_second + else: + raise PyOggError( + "Specified sampling frequency "+ + "({:d}) ".format(samples_per_second)+ + "was not one of the accepted values" + ) + else: + raise PyOggError( + "Cannot change the sampling frequency after "+ + "the decoder was created. Perhaps "+ + "set_sampling_frequency() was called after decode()?" + ) + self._create_pcm_buffer() + + def decode(self, encoded_bytes: memoryview): + """Decodes an Opus-encoded packet into PCM. + + """ + # If we haven't already created a decoder, do so now + if self._decoder is None: + self._decoder = self._create_decoder() + + # Create a ctypes array from the memoryview (without copying + # data) + Buffer = ctypes.c_char * len(encoded_bytes) + encoded_bytes_ctypes = Buffer.from_buffer(encoded_bytes) + + # Create pointer to encoded bytes + encoded_bytes_ptr = ctypes.cast( + encoded_bytes_ctypes, + ctypes.POINTER(ctypes.c_ubyte) + ) + + # Store length of encoded bytes into int32 + len_int32 = opus.opus_int32( + len(encoded_bytes) + ) + + # Check that we have a PCM buffer + if self._pcm_buffer is None: + raise PyOggError("PCM buffer was not configured.") + + # Decode the encoded frame + result = opus.opus_decode( + self._decoder, + encoded_bytes_ptr, + len_int32, + self._pcm_buffer_ptr, + self._pcm_buffer_size_int, + 0 # TODO: What's Forward Error Correction about? + ) + + # Check for any errors + if result < 0: + raise PyOggError( + "An error occurred while decoding an Opus-encoded "+ + "packet: "+ + opus.opus_strerror(result).decode("utf") + ) + + # Extract just the valid data as bytes + end_valid_data = ( + result + * ctypes.sizeof(opus.opus_int16) + * self._channels + ) + + # Create memoryview of PCM buffer to avoid copying data during slice. + mv = memoryview(self._pcm_buffer) + + # Cast memoryview to chars + mv = mv.cast('c') + + # Slice memoryview to extract only valid data + mv = mv[:end_valid_data] + + return mv + + + def decode_missing_packet(self, frame_duration): + """ Obtain PCM data despite missing a frame. + + frame_duration is in milliseconds. + + """ + + # Consider frame duration in units of 0.1ms in order to + # avoid floating-point comparisons. + if int(frame_duration*10) not in [25, 50, 100, 200, 400, 600]: + raise PyOggError( + "Frame duration ({:f}) is not one of the accepted values".format(frame_duration) + ) + + # Calculate frame size + frame_size = int( + frame_duration + * self._samples_per_second + // 1000 + ) + + # Store frame size as int + frame_size_int = ctypes.c_int(frame_size) + + # Decode missing packet + result = opus.opus_decode( + self._decoder, + None, + 0, + self._pcm_buffer_ptr, + frame_size_int, + 0 # TODO: What is this Forward Error Correction about? + ) + + # Check for any errors + if result < 0: + raise PyOggError( + "An error occurred while decoding an Opus-encoded "+ + "packet: "+ + opus.opus_strerror(result).decode("utf") + ) + + # Extract just the valid data as bytes + end_valid_data = ( + result + * ctypes.sizeof(opus.opus_int16) + * self._channels + ) + return bytes(self._pcm_buffer)[:end_valid_data] + + # + # Internal methods + # + + def _create_pcm_buffer(self): + if (self._samples_per_second is None + or self._channels is None): + # We cannot define the buffer yet + return + + # Create buffer to hold 120ms of samples. See "opus_decode()" at + # https://opus-codec.org/docs/opus_api-1.3.1/group__opus__decoder.html + max_duration = 120 # milliseconds + max_samples = max_duration * self._samples_per_second // 1000 + PCMBuffer = opus.opus_int16 * (max_samples * self._channels) + self._pcm_buffer = PCMBuffer() + self._pcm_buffer_ptr = ( + ctypes.cast(ctypes.pointer(self._pcm_buffer), + ctypes.POINTER(opus.opus_int16)) + ) + + # Store samples per channel in an int + self._pcm_buffer_size_int = ctypes.c_int(max_samples) + + def _create_decoder(self): + # To create a decoder, we must first allocate resources for it. + # We want Python to be responsible for the memory deallocation, + # and thus Python must be responsible for the initial memory + # allocation. + + # Check that the sampling frequency has been defined + if self._samples_per_second is None: + raise PyOggError( + "The sampling frequency was not specified before "+ + "attempting to create an Opus decoder. Perhaps "+ + "decode() was called before set_sampling_frequency()?" + ) + + # The sampling frequency must be passed in as a 32-bit int + samples_per_second = opus.opus_int32(self._samples_per_second) + + # Check that the number of channels has been defined + if self._channels is None: + raise PyOggError( + "The number of channels were not specified before "+ + "attempting to create an Opus decoder. Perhaps "+ + "decode() was called before set_channels()?" + ) + + # The number of channels must also be passed in as a 32-bit int + channels = opus.opus_int32(self._channels) + + # Obtain the number of bytes of memory required for the decoder + size = opus.opus_decoder_get_size(channels); + + # Allocate the required memory for the decoder + memory = ctypes.create_string_buffer(size) + + # Cast the newly-allocated memory as a pointer to a decoder. We + # could also have used opus.od_p as the pointer type, but writing + # it out in full may be clearer. + decoder = ctypes.cast(memory, ctypes.POINTER(opus.OpusDecoder)) + + # Initialise the decoder + error = opus.opus_decoder_init( + decoder, + samples_per_second, + channels + ); + + # Check that there hasn't been an error when initialising the + # decoder + if error != opus.OPUS_OK: + raise PyOggError( + "An error occurred while creating the decoder: "+ + opus.opus_strerror(error).decode("utf") + ) + + # Return our newly-created decoder + return decoder diff --git a/sbapp/pyogg/opus_encoder.py b/sbapp/pyogg/opus_encoder.py new file mode 100644 index 0000000..1da82da --- /dev/null +++ b/sbapp/pyogg/opus_encoder.py @@ -0,0 +1,358 @@ +import ctypes +from typing import Optional, Union, ByteString + +from . import opus +from .pyogg_error import PyOggError + +class OpusEncoder: + """Encodes PCM data into Opus frames.""" + def __init__(self) -> None: + self._encoder: Optional[ctypes.pointer] = None + self._channels: Optional[int] = None + self._samples_per_second: Optional[int] = None + self._application: Optional[int] = None + self._max_bytes_per_frame: Optional[opus.opus_int32] = None + self._output_buffer: Optional[ctypes.Array] = None + self._output_buffer_ptr: Optional[ctypes.pointer] = None + + # An output buffer of 4,000 bytes is recommended in + # https://opus-codec.org/docs/opus_api-1.3.1/group__opus__encoder.html + self.set_max_bytes_per_frame(4000) + + # + # User visible methods + # + + def set_channels(self, n: int) -> None: + """Set the number of channels. + + n must be either 1 or 2. + + """ + if self._encoder is None: + if n < 0 or n > 2: + raise PyOggError( + "Invalid number of channels in call to "+ + "set_channels()" + ) + self._channels = n + else: + raise PyOggError( + "Cannot change the number of channels after "+ + "the encoder was created. Perhaps "+ + "set_channels() was called after encode()?" + ) + + def set_sampling_frequency(self, samples_per_second: int) -> None: + """Set the number of samples (per channel) per second. + + This must be one of 8000, 12000, 16000, 24000, or 48000. + + Regardless of the sampling rate and number of channels + selected, the Opus encoder can switch to a lower audio + bandwidth or number of channels if the bitrate selected is + too low. This also means that it is safe to always use 48 + kHz stereo input and let the encoder optimize the + encoding. + + """ + if self._encoder is None: + if samples_per_second in [8000, 12000, 16000, 24000, 48000]: + self._samples_per_second = samples_per_second + else: + raise PyOggError( + "Specified sampling frequency "+ + "({:d}) ".format(samples_per_second)+ + "was not one of the accepted values" + ) + else: + raise PyOggError( + "Cannot change the sampling frequency after "+ + "the encoder was created. Perhaps "+ + "set_sampling_frequency() was called after encode()?" + ) + + def set_application(self, application: str) -> None: + """Set the encoding mode. + + This must be one of 'voip', 'audio', or 'restricted_lowdelay'. + + 'voip': Gives best quality at a given bitrate for voice + signals. It enhances the input signal by high-pass + filtering and emphasizing formants and + harmonics. Optionally it includes in-band forward error + correction to protect against packet loss. Use this mode + for typical VoIP applications. Because of the enhancement, + even at high bitrates the output may sound different from + the input. + + 'audio': Gives best quality at a given bitrate for most + non-voice signals like music. Use this mode for music and + mixed (music/voice) content, broadcast, and applications + requiring less than 15 ms of coding delay. + + 'restricted_lowdelay': configures low-delay mode that + disables the speech-optimized mode in exchange for + slightly reduced delay. This mode can only be set on an + newly initialized encoder because it changes the codec + delay. + """ + if self._encoder is not None: + raise PyOggError( + "Cannot change the application after "+ + "the encoder was created. Perhaps "+ + "set_application() was called after encode()?" + ) + if application == "voip": + self._application = opus.OPUS_APPLICATION_VOIP + elif application == "audio": + self._application = opus.OPUS_APPLICATION_AUDIO + elif application == "restricted_lowdelay": + self._application = opus.OPUS_APPLICATION_RESTRICTED_LOWDELAY + else: + raise PyOggError( + "The application specification '{:s}' ".format(application)+ + "wasn't one of the accepted values." + ) + + def set_max_bytes_per_frame(self, max_bytes: int) -> None: + """Set the maximum number of bytes in an encoded frame. + + Size of the output payload. This may be used to impose an + upper limit on the instant bitrate, but should not be used + as the only bitrate control. + + TODO: Use OPUS_SET_BITRATE to control the bitrate. + + """ + self._max_bytes_per_frame = opus.opus_int32(max_bytes) + OutputBuffer = ctypes.c_ubyte * max_bytes + self._output_buffer = OutputBuffer() + self._output_buffer_ptr = ( + ctypes.cast(ctypes.pointer(self._output_buffer), + ctypes.POINTER(ctypes.c_ubyte)) + ) + + + def encode(self, pcm: Union[bytes, bytearray, memoryview]) -> memoryview: + """Encodes PCM data into an Opus frame. + + `pcm` must be formatted as bytes-like, with each sample taking + two bytes (signed 16-bit integers; interleaved left, then + right channels if in stereo). + + If `pcm` is not writeable, a copy of the array will be made. + + """ + # If we haven't already created an encoder, do so now + if self._encoder is None: + self._encoder = self._create_encoder() + + # Sanity checks also satisfy mypy type checking + assert self._channels is not None + assert self._samples_per_second is not None + assert self._output_buffer is not None + + # Calculate the effective frame duration of the given PCM + # data. Calculate it in units of 0.1ms in order to avoid + # floating point comparisons. + bytes_per_sample = 2 + frame_size = ( + len(pcm) # bytes + // bytes_per_sample + // self._channels + ) + frame_duration = ( + (10*frame_size) + // (self._samples_per_second//1000) + ) + + # Check that we have a valid frame size + if int(frame_duration) not in [25, 50, 100, 200, 400, 600]: + raise PyOggError( + "The effective frame duration ({:.1f} ms) " + .format(frame_duration/10)+ + "was not one of the acceptable values." + ) + + # Create a ctypes object sharing the memory of the PCM data + PcmCtypes = ctypes.c_ubyte * len(pcm) + try: + # Attempt to share the PCM memory + + # Unfortunately, as at 2020-09-27, the type hinting for + # read-only and writeable buffer protocols was a + # work-in-progress. The following only works for writable + # cases, but the method's parameters include a read-only + # possibility (bytes), thus we ignore mypy's error. + pcm_ctypes = PcmCtypes.from_buffer(pcm) # type: ignore[arg-type] + except TypeError: + # The data must be copied if it's not writeable + pcm_ctypes = PcmCtypes.from_buffer_copy(pcm) + + # Create a pointer to the PCM data + pcm_ptr = ctypes.cast( + pcm_ctypes, + ctypes.POINTER(opus.opus_int16) + ) + + # Create an int giving the frame size per channel + frame_size_int = ctypes.c_int(frame_size) + + # Encode PCM + result = opus.opus_encode( + self._encoder, + pcm_ptr, + frame_size_int, + self._output_buffer_ptr, + self._max_bytes_per_frame + ) + + # Check for any errors + if result < 0: + raise PyOggError( + "An error occurred while encoding to Opus format: "+ + opus.opus_strerror(result).decode("utf") + ) + + # Get memoryview of buffer so that the slice operation doesn't + # copy the data. + # + # Unfortunately, as at 2020-09-27, the type hints for + # memoryview do not include ctype arrays. This is because + # there is no currently accepted manner to label a class as + # supporting the buffer protocol. However, it's clearly a + # work in progress. For more information, see: + # * https://bugs.python.org/issue27501 + # * https://github.com/python/typing/issues/593 + # * https://github.com/python/typeshed/pull/4232 + mv = memoryview(self._output_buffer) # type: ignore + + # Cast the memoryview to char + mv = mv.cast('c') + + # Slice just the valid data from the memoryview + valid_data_as_bytes = mv[:result] + + # DEBUG + # Convert memoryview back to ctypes instance + Buffer = ctypes.c_ubyte * len(valid_data_as_bytes) + buf = Buffer.from_buffer( valid_data_as_bytes ) + + # Convert PCM back to pointer and dump 4,000-byte buffer + ptr = ctypes.cast( + buf, + ctypes.POINTER(ctypes.c_ubyte) + ) + + return valid_data_as_bytes + + + def get_algorithmic_delay(self): + """Gets the total samples of delay added by the entire codec. + + This can be queried by the encoder and then the provided + number of samples can be skipped on from the start of the + decoder's output to provide time aligned input and + output. From the perspective of a decoding application the + real data begins this many samples late. + + The decoder contribution to this delay is identical for all + decoders, but the encoder portion of the delay may vary from + implementation to implementation, version to version, or even + depend on the encoder's initial configuration. Applications + needing delay compensation should call this method rather than + hard-coding a value. + + """ + # If we haven't already created an encoder, do so now + if self._encoder is None: + self._encoder = self._create_encoder() + + # Obtain the algorithmic delay of the Opus encoder. See + # https://tools.ietf.org/html/rfc7845#page-27 + delay = opus.opus_int32() + + result = opus.opus_encoder_ctl( + self._encoder, + opus.OPUS_GET_LOOKAHEAD_REQUEST, + ctypes.pointer(delay) + ) + if result != opus.OPUS_OK: + raise PyOggError( + "Failed to obtain the algorithmic delay of "+ + "the Opus encoder: "+ + opus.opus_strerror(result).decode("utf") + ) + delay_samples = delay.value + return delay_samples + + + # + # Internal methods + # + + def _create_encoder(self) -> ctypes.pointer: + # To create an encoder, we must first allocate resources for it. + # We want Python to be responsible for the memory deallocation, + # and thus Python must be responsible for the initial memory + # allocation. + + # Check that the application has been defined + if self._application is None: + raise PyOggError( + "The application was not specified before "+ + "attempting to create an Opus encoder. Perhaps "+ + "encode() was called before set_application()?" + ) + application = self._application + + # Check that the sampling frequency has been defined + if self._samples_per_second is None: + raise PyOggError( + "The sampling frequency was not specified before "+ + "attempting to create an Opus encoder. Perhaps "+ + "encode() was called before set_sampling_frequency()?" + ) + + # The frequency must be passed in as a 32-bit int + samples_per_second = opus.opus_int32(self._samples_per_second) + + # Check that the number of channels has been defined + if self._channels is None: + raise PyOggError( + "The number of channels were not specified before "+ + "attempting to create an Opus encoder. Perhaps "+ + "encode() was called before set_channels()?" + ) + channels = self._channels + + # Obtain the number of bytes of memory required for the encoder + size = opus.opus_encoder_get_size(channels); + + # Allocate the required memory for the encoder + memory = ctypes.create_string_buffer(size) + + # Cast the newly-allocated memory as a pointer to an encoder. We + # could also have used opus.oe_p as the pointer type, but writing + # it out in full may be clearer. + encoder = ctypes.cast(memory, ctypes.POINTER(opus.OpusEncoder)) + + # Initialise the encoder + error = opus.opus_encoder_init( + encoder, + samples_per_second, + channels, + application + ) + + # Check that there hasn't been an error when initialising the + # encoder + if error != opus.OPUS_OK: + raise PyOggError( + "An error occurred while creating the encoder: "+ + opus.opus_strerror(error).decode("utf") + ) + + # Return our newly-created encoder + return encoder diff --git a/sbapp/pyogg/opus_file.py b/sbapp/pyogg/opus_file.py new file mode 100644 index 0000000..f8519f4 --- /dev/null +++ b/sbapp/pyogg/opus_file.py @@ -0,0 +1,106 @@ +import ctypes + +from . import ogg +from . import opus +from .pyogg_error import PyOggError +from .audio_file import AudioFile + +class OpusFile(AudioFile): + def __init__(self, path: str) -> None: + # Open the file + error = ctypes.c_int() + of = opus.op_open_file( + ogg.to_char_p(path), + ctypes.pointer(error) + ) + + # Check for errors + if error.value != 0: + raise PyOggError( + ("File '{}' couldn't be opened or doesn't exist. "+ + "Error code: {}").format(path, error.value) + ) + + # Extract the number of channels in the newly opened file + #: Number of channels in audio file. + self.channels = opus.op_channel_count(of, -1) + + # Allocate sufficient memory to store the entire PCM + pcm_size = opus.op_pcm_total(of, -1) + Buf = opus.opus_int16*(pcm_size*self.channels) + buf = Buf() + + # Create a pointer to the newly allocated memory. It + # seems we can only do pointer arithmetic on void + # pointers. See + # https://mattgwwalker.wordpress.com/2020/05/30/pointer-manipulation-in-python/ + buf_ptr = ctypes.cast( + ctypes.pointer(buf), + ctypes.c_void_p + ) + assert buf_ptr.value is not None # for mypy + buf_ptr_zero = buf_ptr.value + + #: Bytes per sample + self.bytes_per_sample = ctypes.sizeof(opus.opus_int16) + + # Read through the entire file, copying the PCM into the + # buffer + samples = 0 + while True: + # Calculate remaining buffer size + remaining_buffer = ( + len(buf) # int + - (buf_ptr.value + - buf_ptr_zero) // self.bytes_per_sample + ) + + # Convert buffer pointer to the desired type + ptr = ctypes.cast( + buf_ptr, + ctypes.POINTER(opus.opus_int16) + ) + + # Read the next section of PCM + ns = opus.op_read( + of, + ptr, + remaining_buffer, + ogg.c_int_p() + ) + + # Check for errors + if ns<0: + raise PyOggError( + "Error while reading OggOpus file. "+ + "Error code: {}".format(ns) + ) + + # Increment the pointer + buf_ptr.value += ( + ns + * self.bytes_per_sample + * self.channels + ) + assert buf_ptr.value is not None # for mypy + + samples += ns + + # Check if we've finished + if ns==0: + break + + # Close the open file + opus.op_free(of) + + # Opus files are always stored at 48k samples per second + #: Number of samples per second (per channel). Always 48,000. + self.frequency = 48000 + + # Cast buffer to a one-dimensional array of chars + #: Raw PCM data from audio file. + CharBuffer = ( + ctypes.c_byte + * (self.bytes_per_sample * self.channels * pcm_size) + ) + self.buffer = CharBuffer.from_buffer(buf) diff --git a/sbapp/pyogg/opus_file_stream.py b/sbapp/pyogg/opus_file_stream.py new file mode 100644 index 0000000..b3e1723 --- /dev/null +++ b/sbapp/pyogg/opus_file_stream.py @@ -0,0 +1,127 @@ +import ctypes + +from . import ogg +from . import opus +from .pyogg_error import PyOggError + +class OpusFileStream: + def __init__(self, path): + """Opens an OggOpus file as a stream. + + path should be a string giving the filename of the file to + open. Unicode file names may not work correctly. + + An exception will be raised if the file cannot be opened + correctly. + + """ + error = ctypes.c_int() + + self.of = opus.op_open_file(ogg.to_char_p(path), ctypes.pointer(error)) + + if error.value != 0: + self.of = None + raise PyOggError("file couldn't be opened or doesn't exist. Error code : {}".format(error.value)) + + #: Number of channels in audio file + self.channels = opus.op_channel_count(self.of, -1) + + #: Total PCM Length + self.pcm_size = opus.op_pcm_total(self.of, -1) + + #: Number of samples per second (per channel) + self.frequency = 48000 + + # The buffer size should be (per channel) large enough to + # hold 120ms (the largest possible Opus frame) at 48kHz. + # See https://opus-codec.org/docs/opusfile_api-0.7/group__stream__decoding.html#ga963c917749335e29bb2b698c1cb20a10 + self.buffer_size = self.frequency // 1000 * 120 * self.channels + self.Buf = opus.opus_int16 * self.buffer_size + self._buf = self.Buf() + self.buffer_ptr = ctypes.cast( + ctypes.pointer(self._buf), + opus.opus_int16_p + ) + + #: Bytes per sample + self.bytes_per_sample = ctypes.sizeof(opus.opus_int16) + + def __del__(self): + if self.of is not None: + opus.op_free(self.of) + + def get_buffer(self): + """Obtains the next frame of PCM samples. + + Returns an array of signed 16-bit integers. If the file + is in stereo, the left and right channels are interleaved. + + Returns None when all data has been read. + + The array that is returned should be either processed or + copied before the next call to :meth:`~get_buffer` or + :meth:`~get_buffer_as_array` as the array's memory is reused for + each call. + + """ + # Read the next frame + samples_read = opus.op_read( + self.of, + self.buffer_ptr, + self.buffer_size, + None + ) + + # Check for errors + if samples_read < 0: + raise PyOggError( + "Failed to read OpusFileStream. Error {:d}".format(samples_read) + ) + + # Check if we've reached the end of the stream + if samples_read == 0: + return None + + # Cast the pointer to opus_int16 to an array of the + # correct size + result_ptr = ctypes.cast( + self.buffer_ptr, + ctypes.POINTER(opus.opus_int16 * (samples_read*self.channels)) + ) + + # Convert the array to Python bytes + return bytes(result_ptr.contents) + + def get_buffer_as_array(self): + """Provides the buffer as a NumPy array. + + Note that the underlying data type is 16-bit signed + integers. + + Does not copy the underlying data, so the returned array + should either be processed or copied before the next call + to :meth:`~get_buffer` or :meth:`~get_buffer_as_array`. + + """ + import numpy # type: ignore + + # Read the next samples from the stream + buf = self.get_buffer() + + # Check if we've come to the end of the stream + if buf is None: + return None + + # Convert the bytes buffer to a NumPy array + array = numpy.frombuffer( + buf, + dtype=numpy.int16 + ) + + # Reshape the array + return array.reshape( + (len(buf) + // self.bytes_per_sample + // self.channels, + self.channels) + ) diff --git a/sbapp/pyogg/py.typed b/sbapp/pyogg/py.typed new file mode 100644 index 0000000..d4defd9 --- /dev/null +++ b/sbapp/pyogg/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. This package uses inline types. \ No newline at end of file diff --git a/sbapp/pyogg/pyogg_error.py b/sbapp/pyogg/pyogg_error.py new file mode 100644 index 0000000..35f28bf --- /dev/null +++ b/sbapp/pyogg/pyogg_error.py @@ -0,0 +1,2 @@ +class PyOggError(Exception): + pass diff --git a/sbapp/pyogg/vorbis.py b/sbapp/pyogg/vorbis.py new file mode 100644 index 0000000..a8432ba --- /dev/null +++ b/sbapp/pyogg/vorbis.py @@ -0,0 +1,855 @@ +############################################################ +# Vorbis license: # +############################################################ +""" +Copyright (c) 2002-2015 Xiph.org Foundation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +import ctypes +import ctypes.util +from traceback import print_exc as _print_exc +import os + +OV_EXCLUDE_STATIC_CALLBACKS = False + +__MINGW32__ = False + +_WIN32 = False + +from .ogg import * + +from .library_loader import ExternalLibrary, ExternalLibraryError + +__here = os.getcwd() + +libvorbis = None + +try: + names = { + "Windows": "libvorbis.dll", + "Darwin": "libvorbis.0.dylib", + "external": "vorbis" + } + libvorbis = Library.load(names, tests = [lambda lib: hasattr(lib, "vorbis_info_init")]) +except ExternalLibraryError: + pass +except: + _print_exc() + +libvorbisfile = None + +try: + names = { + "Windows": "libvorbisfile.dll", + "Darwin": "libvorbisfile.3.dylib", + "external": "vorbisfile" + } + libvorbisfile = Library.load(names, tests = [lambda lib: hasattr(lib, "ov_clear")]) +except ExternalLibraryError: + pass +except: + _print_exc() + +libvorbisenc = None + +# In some cases, libvorbis may also have the libvorbisenc functionality. +libvorbis_is_also_libvorbisenc = True + +for f in ("vorbis_encode_ctl", + "vorbis_encode_init", + "vorbis_encode_init_vbr", + "vorbis_encode_setup_init", + "vorbis_encode_setup_managed", + "vorbis_encode_setup_vbr"): + if not hasattr(libvorbis, f): + libvorbis_is_also_libvorbisenc = False + break + +if libvorbis_is_also_libvorbisenc: + libvorbisenc = libvorbis +else: + try: + names = { + "Windows": "libvorbisenc.dll", + "Darwin": "libvorbisenc.2.dylib", + "external": "vorbisenc" + } + libvorbisenc = Library.load(names, tests = [lambda lib: hasattr(lib, "vorbis_encode_init")]) + except ExternalLibraryError: + pass + except: + _print_exc() + +if libvorbis is None: + PYOGG_VORBIS_AVAIL = False +else: + PYOGG_VORBIS_AVAIL = True + +if libvorbisfile is None: + PYOGG_VORBIS_FILE_AVAIL = False +else: + PYOGG_VORBIS_FILE_AVAIL = True + +if libvorbisenc is None: + PYOGG_VORBIS_ENC_AVAIL = False +else: + PYOGG_VORBIS_ENC_AVAIL = True + +# FIXME: What's the story with the lack of checking for PYOGG_VORBIS_ENC_AVAIL? +# We just seem to assume that it's available. + +if PYOGG_OGG_AVAIL and PYOGG_VORBIS_AVAIL and PYOGG_VORBIS_FILE_AVAIL: + # Sanity check also satisfies mypy type checking + assert libogg is not None + assert libvorbis is not None + assert libvorbisfile is not None + + + # codecs + class vorbis_info(ctypes.Structure): + """ + Wrapper for: + typedef struct vorbis_info vorbis_info; + """ + _fields_ = [("version", c_int), + ("channels", c_int), + ("rate", c_long), + + ("bitrate_upper", c_long), + ("bitrate_nominal", c_long), + ("bitrate_lower", c_long), + ("bitrate_window", c_long), + ("codec_setup", c_void_p)] + + + + class vorbis_dsp_state(ctypes.Structure): + """ + Wrapper for: + typedef struct vorbis_dsp_state vorbis_dsp_state; + """ + _fields_ = [("analysisp", c_int), + ("vi", POINTER(vorbis_info)), + ("pcm", c_float_p_p), + ("pcmret", c_float_p_p), + ("pcm_storage", c_int), + ("pcm_current", c_int), + ("pcm_returned", c_int), + + ("preextrapolate", c_int), + ("eofflag", c_int), + + ("lW", c_long), + ("W", c_long), + ("nW", c_long), + ("centerW", c_long), + + ("granulepos", ogg_int64_t), + ("sequence", ogg_int64_t), + + ("glue_bits", ogg_int64_t), + ("time_bits", ogg_int64_t), + ("floor_bits", ogg_int64_t), + ("res_bits", ogg_int64_t), + + ("backend_state", c_void_p)] + + class alloc_chain(ctypes.Structure): + """ + Wrapper for: + typedef struct alloc_chain; + """ + pass + + alloc_chain._fields_ = [("ptr", c_void_p), + ("next", POINTER(alloc_chain))] + + class vorbis_block(ctypes.Structure): + """ + Wrapper for: + typedef struct vorbis_block vorbis_block; + """ + _fields_ = [("pcm", c_float_p_p), + ("opb", oggpack_buffer), + ("lW", c_long), + ("W", c_long), + ("nW", c_long), + ("pcmend", c_int), + ("mode", c_int), + + ("eofflag", c_int), + ("granulepos", ogg_int64_t), + ("sequence", ogg_int64_t), + ("vd", POINTER(vorbis_dsp_state)), + + ("localstore", c_void_p), + ("localtop", c_long), + ("localalloc", c_long), + ("totaluse", c_long), + ("reap", POINTER(alloc_chain)), + + ("glue_bits", c_long), + ("time_bits", c_long), + ("floor_bits", c_long), + ("res_bits", c_long), + + ("internal", c_void_p)] + + class vorbis_comment(ctypes.Structure): + """ + Wrapper for: + typedef struct vorbis_comment vorbis_comment; + """ + _fields_ = [("user_comments", c_char_p_p), + ("comment_lengths", c_int_p), + ("comments", c_int), + ("vendor", c_char_p)] + + + + vi_p = POINTER(vorbis_info) + vc_p = POINTER(vorbis_comment) + vd_p = POINTER(vorbis_dsp_state) + vb_p = POINTER(vorbis_block) + + libvorbis.vorbis_info_init.restype = None + libvorbis.vorbis_info_init.argtypes = [vi_p] + def vorbis_info_init(vi): + libvorbis.vorbis_info_init(vi) + + libvorbis.vorbis_info_clear.restype = None + libvorbis.vorbis_info_clear.argtypes = [vi_p] + def vorbis_info_clear(vi): + libvorbis.vorbis_info_clear(vi) + + libvorbis.vorbis_info_blocksize.restype = c_int + libvorbis.vorbis_info_blocksize.argtypes = [vi_p, c_int] + def vorbis_info_blocksize(vi, zo): + return libvorbis.vorbis_info_blocksize(vi, zo) + + libvorbis.vorbis_comment_init.restype = None + libvorbis.vorbis_comment_init.argtypes = [vc_p] + def vorbis_comment_init(vc): + libvorbis.vorbis_comment_init(vc) + + libvorbis.vorbis_comment_add.restype = None + libvorbis.vorbis_comment_add.argtypes = [vc_p, c_char_p] + def vorbis_comment_add(vc, comment): + libvorbis.vorbis_comment_add(vc, comment) + + libvorbis.vorbis_comment_add_tag.restype = None + libvorbis.vorbis_comment_add_tag.argtypes = [vc_p, c_char_p, c_char_p] + def vorbis_comment_add_tag(vc, tag, comment): + libvorbis.vorbis_comment_add_tag(vc, tag, comment) + + libvorbis.vorbis_comment_query.restype = c_char_p + libvorbis.vorbis_comment_query.argtypes = [vc_p, c_char_p, c_int] + def vorbis_comment_query(vc, tag, count): + libvorbis.vorbis_comment_query(vc, tag, count) + + libvorbis.vorbis_comment_query_count.restype = c_int + libvorbis.vorbis_comment_query_count.argtypes = [vc_p, c_char_p] + def vorbis_comment_query_count(vc, tag): + libvorbis.vorbis_comment_query_count(vc, tag) + + libvorbis.vorbis_comment_clear.restype = None + libvorbis.vorbis_comment_clear.argtypes = [vc_p] + def vorbis_comment_clear(vc): + libvorbis.vorbis_comment_clear(vc) + + + + libvorbis.vorbis_block_init.restype = c_int + libvorbis.vorbis_block_init.argtypes = [vd_p, vb_p] + def vorbis_block_init(v,vb): + return libvorbis.vorbis_block_init(v,vb) + + libvorbis.vorbis_block_clear.restype = c_int + libvorbis.vorbis_block_clear.argtypes = [vb_p] + def vorbis_block_clear(vb): + return libvorbis.vorbis_block_clear(vb) + + libvorbis.vorbis_dsp_clear.restype = None + libvorbis.vorbis_dsp_clear.argtypes = [vd_p] + def vorbis_dsp_clear(v): + return libvorbis.vorbis_dsp_clear(v) + + libvorbis.vorbis_granule_time.restype = c_double + libvorbis.vorbis_granule_time.argtypes = [vd_p, ogg_int64_t] + def vorbis_granule_time(v, granulepos): + return libvorbis.vorbis_granule_time(v, granulepos) + + + + libvorbis.vorbis_version_string.restype = c_char_p + libvorbis.vorbis_version_string.argtypes = [] + def vorbis_version_string(): + return libvorbis.vorbis_version_string() + + + + + + libvorbis.vorbis_analysis_init.restype = c_int + libvorbis.vorbis_analysis_init.argtypes = [vd_p, vi_p] + def vorbis_analysis_init(v, vi): + return libvorbis.vorbis_analysis_init(v, vi) + + libvorbis.vorbis_commentheader_out.restype = c_int + libvorbis.vorbis_commentheader_out.argtypes = [vc_p, op_p] + def vorbis_commentheader_out(vc, op): + return libvorbis.vorbis_commentheader_out(vc, op) + + libvorbis.vorbis_analysis_headerout.restype = c_int + libvorbis.vorbis_analysis_headerout.argtypes = [vd_p, vc_p, op_p, op_p, op_p] + def vorbis_analysis_headerout(v,vc, op, op_comm, op_code): + return libvorbis.vorbis_analysis_headerout(v,vc, op, op_comm, op_code) + + libvorbis.vorbis_analysis_buffer.restype = c_float_p_p + libvorbis.vorbis_analysis_buffer.argtypes = [vd_p, c_int] + def vorbis_analysis_buffer(v, vals): + return libvorbis.vorbis_analysis_buffer(v, vals) + + libvorbis.vorbis_analysis_wrote.restype = c_int + libvorbis.vorbis_analysis_wrote.argtypes = [vd_p, c_int] + def vorbis_analysis_wrote(v, vals): + return libvorbis.vorbis_analysis_wrote(v, vals) + + libvorbis.vorbis_analysis_blockout.restype = c_int + libvorbis.vorbis_analysis_blockout.argtypes = [vd_p, vb_p] + def vorbis_analysis_blockout(v, vb): + return libvorbis.vorbis_analysis_blockout(v, vb) + + libvorbis.vorbis_analysis.restype = c_int + libvorbis.vorbis_analysis.argtypes = [vb_p, op_p] + def vorbis_analysis(vb, op): + return libvorbis.vorbis_analysis(vb, op) + + + + + libvorbis.vorbis_bitrate_addblock.restype = c_int + libvorbis.vorbis_bitrate_addblock.argtypes = [vb_p] + def vorbis_bitrate_addblock(vb): + return libvorbis.vorbis_bitrate_addblock(vb) + + libvorbis.vorbis_bitrate_flushpacket.restype = c_int + libvorbis.vorbis_bitrate_flushpacket.argtypes = [vd_p, op_p] + def vorbis_bitrate_flushpacket(vd, op): + return libvorbis.vorbis_bitrate_flushpacket(vd, op) + + + + + libvorbis.vorbis_synthesis_idheader.restype = c_int + libvorbis.vorbis_synthesis_idheader.argtypes = [op_p] + def vorbis_synthesis_idheader(op): + return libvorbis.vorbis_synthesis_idheader(op) + + libvorbis.vorbis_synthesis_headerin.restype = c_int + libvorbis.vorbis_synthesis_headerin.argtypes = [vi_p, vc_p, op_p] + def vorbis_synthesis_headerin(vi, vc, op): + return libvorbis.vorbis_synthesis_headerin(vi, vc, op) + + + + + libvorbis.vorbis_synthesis_init.restype = c_int + libvorbis.vorbis_synthesis_init.argtypes = [vd_p, vi_p] + def vorbis_synthesis_init(v,vi): + return libvorbis.vorbis_synthesis_init(v,vi) + + libvorbis.vorbis_synthesis_restart.restype = c_int + libvorbis.vorbis_synthesis_restart.argtypes = [vd_p] + def vorbis_synthesis_restart(v): + return libvorbis.vorbis_synthesis_restart(v) + + libvorbis.vorbis_synthesis.restype = c_int + libvorbis.vorbis_synthesis.argtypes = [vb_p, op_p] + def vorbis_synthesis(vb, op): + return libvorbis.vorbis_synthesis(vb, op) + + libvorbis.vorbis_synthesis_trackonly.restype = c_int + libvorbis.vorbis_synthesis_trackonly.argtypes = [vb_p, op_p] + def vorbis_synthesis_trackonly(vb, op): + return libvorbis.vorbis_synthesis_trackonly(vb, op) + + libvorbis.vorbis_synthesis_blockin.restype = c_int + libvorbis.vorbis_synthesis_blockin.argtypes = [vd_p, vb_p] + def vorbis_synthesis_blockin(v, vb): + return libvorbis.vorbis_synthesis_blockin(v, vb) + + libvorbis.vorbis_synthesis_pcmout.restype = c_int + libvorbis.vorbis_synthesis_pcmout.argtypes = [vd_p, c_float_p_p_p] + def vorbis_synthesis_pcmout(v, pcm): + return libvorbis.vorbis_synthesis_pcmout(v, pcm) + + libvorbis.vorbis_synthesis_lapout.restype = c_int + libvorbis.vorbis_synthesis_lapout.argtypes = [vd_p, c_float_p_p_p] + def vorbis_synthesis_lapout(v, pcm): + return libvorbis.vorbis_synthesis_lapout(v, pcm) + + libvorbis.vorbis_synthesis_read.restype = c_int + libvorbis.vorbis_synthesis_read.argtypes = [vd_p, c_int] + def vorbis_synthesis_read(v, samples): + return libvorbis.vorbis_synthesis_read(v, samples) + + libvorbis.vorbis_packet_blocksize.restype = c_long + libvorbis.vorbis_packet_blocksize.argtypes = [vi_p, op_p] + def vorbis_packet_blocksize(vi, op): + return libvorbis.vorbis_packet_blocksize(vi, op) + + + + libvorbis.vorbis_synthesis_halfrate.restype = c_int + libvorbis.vorbis_synthesis_halfrate.argtypes = [vi_p, c_int] + def vorbis_synthesis_halfrate(v, flag): + return libvorbis.vorbis_synthesis_halfrate(v, flag) + + libvorbis.vorbis_synthesis_halfrate_p.restype = c_int + libvorbis.vorbis_synthesis_halfrate_p.argtypes = [vi_p] + def vorbis_synthesis_halfrate_p(vi): + return libvorbis.vorbis_synthesis_halfrate_p(vi) + + OV_FALSE = -1 + OV_EOF = -2 + OV_HOLE = -3 + + OV_EREAD = -128 + OV_EFAULT = -129 + OV_EIMPL =-130 + OV_EINVAL =-131 + OV_ENOTVORBIS =-132 + OV_EBADHEADER =-133 + OV_EVERSION =-134 + OV_ENOTAUDIO =-135 + OV_EBADPACKET =-136 + OV_EBADLINK =-137 + OV_ENOSEEK =-138 + # end of codecs + + # vorbisfile + read_func = ctypes.CFUNCTYPE(c_size_t, + c_void_p, + c_size_t, + c_size_t, + c_void_p) + + seek_func = ctypes.CFUNCTYPE(c_int, + c_void_p, + ogg_int64_t, + c_int) + + close_func = ctypes.CFUNCTYPE(c_int, + c_void_p) + + tell_func = ctypes.CFUNCTYPE(c_long, + c_void_p) + + class ov_callbacks(ctypes.Structure): + """ + Wrapper for: + typedef struct ov_callbacks; + """ + + _fields_ = [("read_func", read_func), + ("seek_func", seek_func), + ("close_func", close_func), + ("tell_func", tell_func)] + + NOTOPEN = 0 + PARTOPEN = 1 + OPENED = 2 + STREAMSET = 3 + INITSET = 4 + + class OggVorbis_File(ctypes.Structure): + """ + Wrapper for: + typedef struct OggVorbis_File OggVorbis_File; + """ + + _fields_ = [("datasource", c_void_p), + ("seekable", c_int), + ("offset", ogg_int64_t), + ("end", ogg_int64_t), + ("oy", ogg_sync_state), + + ("links", c_int), + ("offsets", ogg_int64_t_p), + ("dataoffsets", ogg_int64_t_p), + ("serialnos", c_long_p), + ("pcmlengths", ogg_int64_t_p), + ("vi", vi_p), + ("vc", vc_p), + + ("pcm_offset", ogg_int64_t), + ("ready_state", c_int), + ("current_serialno", c_long), + ("current_link", c_int), + + ("bittrack", c_double), + ("samptrack", c_double), + + ("os", ogg_stream_state), + + ("vd", vorbis_dsp_state), + ("vb", vorbis_block), + + ("callbacks", ov_callbacks)] + vf_p = POINTER(OggVorbis_File) + + libvorbisfile.ov_clear.restype = c_int + libvorbisfile.ov_clear.argtypes = [vf_p] + + def ov_clear(vf): + return libvorbisfile.ov_clear(vf) + + libvorbisfile.ov_fopen.restype = c_int + libvorbisfile.ov_fopen.argtypes = [c_char_p, vf_p] + + def ov_fopen(path, vf): + return libvorbisfile.ov_fopen(to_char_p(path), vf) + + libvorbisfile.ov_open_callbacks.restype = c_int + libvorbisfile.ov_open_callbacks.argtypes = [c_void_p, vf_p, c_char_p, c_long, ov_callbacks] + + def ov_open_callbacks(datasource, vf, initial, ibytes, callbacks): + return libvorbisfile.ov_open_callbacks(datasource, vf, initial, ibytes, callbacks) + + def ov_open(*args, **kw): + raise PyOggError("ov_open is not supported, please use ov_fopen instead") + + def ov_test(*args, **kw): + raise PyOggError("ov_test is not supported") + + libvorbisfile.ov_test_callbacks.restype = c_int + libvorbisfile.ov_test_callbacks.argtypes = [c_void_p, vf_p, c_char_p, c_long, ov_callbacks] + + def ov_test_callbacks(datasource, vf, initial, ibytes, callbacks): + return libvorbisfile.ov_test_callbacks(datasource, vf, initial, ibytes, callbacks) + + libvorbisfile.ov_test_open.restype = c_int + libvorbisfile.ov_test_open.argtypes = [vf_p] + + def ov_test_open(vf): + return libvorbisfile.ov_test_open(vf) + + + + + libvorbisfile.ov_bitrate.restype = c_long + libvorbisfile.ov_bitrate.argtypes = [vf_p, c_int] + + def ov_bitrate(vf, i): + return libvorbisfile.ov_bitrate(vf, i) + + libvorbisfile.ov_bitrate_instant.restype = c_long + libvorbisfile.ov_bitrate_instant.argtypes = [vf_p] + + def ov_bitrate_instant(vf): + return libvorbisfile.ov_bitrate_instant(vf) + + libvorbisfile.ov_streams.restype = c_long + libvorbisfile.ov_streams.argtypes = [vf_p] + + def ov_streams(vf): + return libvorbisfile.ov_streams(vf) + + libvorbisfile.ov_seekable.restype = c_long + libvorbisfile.ov_seekable.argtypes = [vf_p] + + def ov_seekable(vf): + return libvorbisfile.ov_seekable(vf) + + libvorbisfile.ov_serialnumber.restype = c_long + libvorbisfile.ov_serialnumber.argtypes = [vf_p, c_int] + + def ov_serialnumber(vf, i): + return libvorbisfile.ov_serialnumber(vf, i) + + + + libvorbisfile.ov_raw_total.restype = ogg_int64_t + libvorbisfile.ov_raw_total.argtypes = [vf_p, c_int] + + def ov_raw_total(vf, i): + return libvorbisfile.ov_raw_total(vf, i) + + libvorbisfile.ov_pcm_total.restype = ogg_int64_t + libvorbisfile.ov_pcm_total.argtypes = [vf_p, c_int] + + def ov_pcm_total(vf, i): + return libvorbisfile.ov_pcm_total(vf, i) + + libvorbisfile.ov_time_total.restype = c_double + libvorbisfile.ov_time_total.argtypes = [vf_p, c_int] + + def ov_time_total(vf, i): + return libvorbisfile.ov_time_total(vf, i) + + + + + libvorbisfile.ov_raw_seek.restype = c_int + libvorbisfile.ov_raw_seek.argtypes = [vf_p, ogg_int64_t] + + def ov_raw_seek(vf, pos): + return libvorbisfile.ov_raw_seek(vf, pos) + + libvorbisfile.ov_pcm_seek.restype = c_int + libvorbisfile.ov_pcm_seek.argtypes = [vf_p, ogg_int64_t] + + def ov_pcm_seek(vf, pos): + return libvorbisfile.ov_pcm_seek(vf, pos) + + libvorbisfile.ov_pcm_seek_page.restype = c_int + libvorbisfile.ov_pcm_seek_page.argtypes = [vf_p, ogg_int64_t] + + def ov_pcm_seek_page(vf, pos): + return libvorbisfile.ov_pcm_seek_page(vf, pos) + + libvorbisfile.ov_time_seek.restype = c_int + libvorbisfile.ov_time_seek.argtypes = [vf_p, c_double] + + def ov_time_seek(vf, pos): + return libvorbisfile.ov_time_seek(vf, pos) + + libvorbisfile.ov_time_seek_page.restype = c_int + libvorbisfile.ov_time_seek_page.argtypes = [vf_p, c_double] + + def ov_time_seek_page(vf, pos): + return libvorbisfile.ov_time_seek_page(vf, pos) + + + + + libvorbisfile.ov_raw_seek_lap.restype = c_int + libvorbisfile.ov_raw_seek_lap.argtypes = [vf_p, ogg_int64_t] + + def ov_raw_seek_lap(vf, pos): + return libvorbisfile.ov_raw_seek_lap(vf, pos) + + libvorbisfile.ov_pcm_seek_lap.restype = c_int + libvorbisfile.ov_pcm_seek_lap.argtypes = [vf_p, ogg_int64_t] + + def ov_pcm_seek_lap(vf, pos): + return libvorbisfile.ov_pcm_seek_lap(vf, pos) + + libvorbisfile.ov_pcm_seek_page_lap.restype = c_int + libvorbisfile.ov_pcm_seek_page_lap.argtypes = [vf_p, ogg_int64_t] + + def ov_pcm_seek_page_lap(vf, pos): + return libvorbisfile.ov_pcm_seek_page_lap(vf, pos) + + libvorbisfile.ov_time_seek_lap.restype = c_int + libvorbisfile.ov_time_seek_lap.argtypes = [vf_p, c_double] + + def ov_time_seek_lap(vf, pos): + return libvorbisfile.ov_time_seek_lap(vf, pos) + + libvorbisfile.ov_time_seek_page_lap.restype = c_int + libvorbisfile.ov_time_seek_page_lap.argtypes = [vf_p, c_double] + + def ov_time_seek_page_lap(vf, pos): + return libvorbisfile.ov_time_seek_page_lap(vf, pos) + + + + libvorbisfile.ov_raw_tell.restype = ogg_int64_t + libvorbisfile.ov_raw_tell.argtypes = [vf_p] + + def ov_raw_tell(vf): + return libvorbisfile.ov_raw_tell(vf) + + libvorbisfile.ov_pcm_tell.restype = ogg_int64_t + libvorbisfile.ov_pcm_tell.argtypes = [vf_p] + + def ov_pcm_tell(vf): + return libvorbisfile.ov_pcm_tell(vf) + + libvorbisfile.ov_time_tell.restype = c_double + libvorbisfile.ov_time_tell.argtypes = [vf_p] + + def ov_time_tell(vf): + return libvorbisfile.ov_time_tell(vf) + + + + libvorbisfile.ov_info.restype = vi_p + libvorbisfile.ov_info.argtypes = [vf_p, c_int] + + def ov_info(vf, link): + return libvorbisfile.ov_info(vf, link) + + libvorbisfile.ov_comment.restype = vc_p + libvorbisfile.ov_comment.argtypes = [vf_p, c_int] + + def ov_comment(vf, link): + return libvorbisfile.ov_comment(vf, link) + + + + libvorbisfile.ov_read_float.restype = c_long + libvorbisfile.ov_read_float.argtypes = [vf_p, c_float_p_p_p, c_int, c_int_p] + + def ov_read_float(vf, pcm_channels, samples, bitstream): + return libvorbisfile.ov_read_float(vf, pcm_channels, samples, bitstream) + + filter_ = ctypes.CFUNCTYPE(None, + c_float_p_p, + c_long, + c_long, + c_void_p) + + try: + libvorbisfile.ov_read_filter.restype = c_long + libvorbisfile.ov_read_filter.argtypes = [vf_p, c_char_p, c_int, c_int, c_int, c_int, c_int_p, filter_, c_void_p] + + def ov_read_filter(vf, buffer, length, bigendianp, word, sgned, bitstream, filter_, filter_param): + return libvorbisfile.ov_read_filter(vf, buffer, length, bigendianp, word, sgned, bitstream, filter_, filter_param) + except: + pass + + libvorbisfile.ov_read.restype = c_long + libvorbisfile.ov_read.argtypes = [vf_p, c_char_p, c_int, c_int, c_int, c_int, c_int_p] + + def ov_read(vf, buffer, length, bigendianp, word, sgned, bitstream): + return libvorbisfile.ov_read(vf, buffer, length, bigendianp, word, sgned, bitstream) + + libvorbisfile.ov_crosslap.restype = c_int + libvorbisfile.ov_crosslap.argtypes = [vf_p, vf_p] + + def ov_crosslap(vf1, cf2): + return libvorbisfile.ov_crosslap(vf1, vf2) + + + + + libvorbisfile.ov_halfrate.restype = c_int + libvorbisfile.ov_halfrate.argtypes = [vf_p, c_int] + + def ov_halfrate(vf, flag): + return libvorbisfile.ov_halfrate(vf, flag) + + libvorbisfile.ov_halfrate_p.restype = c_int + libvorbisfile.ov_halfrate_p.argtypes = [vf_p] + + def ov_halfrate_p(vf): + return libvorbisfile.ov_halfrate_p(vf) + # end of vorbisfile + + try: + # vorbisenc + + # Sanity check also satisfies mypy type checking + assert libvorbisenc is not None + + libvorbisenc.vorbis_encode_init.restype = c_int + libvorbisenc.vorbis_encode_init.argtypes = [vi_p, c_long, c_long, c_long, c_long, c_long] + + def vorbis_encode_init(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate): + return libvorbisenc.vorbis_encode_init(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate) + + libvorbisenc.vorbis_encode_setup_managed.restype = c_int + libvorbisenc.vorbis_encode_setup_managed.argtypes = [vi_p, c_long, c_long, c_long, c_long, c_long] + + def vorbis_encode_setup_managed(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate): + return libvorbisenc.vorbis_encode_setup_managed(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate) + + libvorbisenc.vorbis_encode_setup_vbr.restype = c_int + libvorbisenc.vorbis_encode_setup_vbr.argtypes = [vi_p, c_long, c_long, c_float] + + def vorbis_encode_setup_vbr(vi, channels, rate, quality): + return libvorbisenc.vorbis_encode_setup_vbr(vi, channels, rate, quality) + + libvorbisenc.vorbis_encode_init_vbr.restype = c_int + libvorbisenc.vorbis_encode_init_vbr.argtypes = [vi_p, c_long, c_long, c_float] + + def vorbis_encode_init_vbr(vi, channels, rate, quality): + return libvorbisenc.vorbis_encode_init_vbr(vi, channels, rate, quality) + + libvorbisenc.vorbis_encode_setup_init.restype = c_int + libvorbisenc.vorbis_encode_setup_init.argtypes = [vi_p] + + def vorbis_encode_setup_init(vi): + return libvorbisenc.vorbis_encode_setup_init(vi) + + libvorbisenc.vorbis_encode_ctl.restype = c_int + libvorbisenc.vorbis_encode_ctl.argtypes = [vi_p, c_int, c_void_p] + + def vorbis_encode_ctl(vi, number, arg): + return libvorbisenc.vorbis_encode_ctl(vi, number, arg) + + class ovectl_ratemanage_arg(ctypes.Structure): + _fields_ = [("management_active", c_int), + ("bitrate_hard_min", c_long), + ("bitrate_hard_max", c_long), + ("bitrate_hard_window", c_double), + ("bitrate_av_lo", c_long), + ("bitrate_av_hi", c_long), + ("bitrate_av_window", c_double), + ("bitrate_av_window_center", c_double)] + + class ovectl_ratemanage2_arg(ctypes.Structure): + _fields_ = [("management_active", c_int), + ("bitrate_limit_min_kbps", c_long), + ("bitrate_limit_max_kbps", c_long), + ("bitrate_limit_reservoir_bits", c_long), + ("bitrate_limit_reservoir_bias", c_double), + ("bitrate_average_kbps", c_long), + ("bitrate_average_damping", c_double)] + + OV_ECTL_RATEMANAGE2_GET =0x14 + + OV_ECTL_RATEMANAGE2_SET =0x15 + + OV_ECTL_LOWPASS_GET =0x20 + + OV_ECTL_LOWPASS_SET =0x21 + + OV_ECTL_IBLOCK_GET =0x30 + + OV_ECTL_IBLOCK_SET =0x31 + + OV_ECTL_COUPLING_GET =0x40 + + OV_ECTL_COUPLING_SET =0x41 + + OV_ECTL_RATEMANAGE_GET =0x10 + + OV_ECTL_RATEMANAGE_SET =0x11 + + OV_ECTL_RATEMANAGE_AVG =0x12 + + OV_ECTL_RATEMANAGE_HARD =0x13 + # end of vorbisenc + except: + pass diff --git a/sbapp/pyogg/vorbis_file.py b/sbapp/pyogg/vorbis_file.py new file mode 100644 index 0000000..918f1e8 --- /dev/null +++ b/sbapp/pyogg/vorbis_file.py @@ -0,0 +1,161 @@ +import ctypes + +from . import vorbis +from .audio_file import AudioFile +from .pyogg_error import PyOggError + +# TODO: Issue #70: Vorbis files with multiple logical bitstreams could +# be supported by chaining VorbisFile instances (with say a 'next' +# attribute that points to the next VorbisFile that would contain the +# PCM for the next logical bitstream). A considerable constraint to +# implementing this was that examples files that demonstrated multiple +# logical bitstreams couldn't be found or created. Note that even +# Audacity doesn't handle multiple logical bitstreams (see +# https://wiki.audacityteam.org/wiki/OGG#Importing_multiple_stream_files). + +# TODO: Issue #53: Unicode file names are not well supported. +# They may work in macOS and Linux, they don't work under Windows. + +class VorbisFile(AudioFile): + def __init__(self, + path: str, + bytes_per_sample: int = 2, + signed:bool = True) -> None: + """Load an OggVorbis File. + + path specifies the location of the Vorbis file. Unicode + filenames may not work correctly under Windows. + + bytes_per_sample specifies the word size of the PCM. It may + be either 1 or 2. Specifying one byte per sample will save + memory but will likely decrease the quality of the decoded + audio. + + Only Vorbis files with a single logical bitstream are + supported. + + """ + # Sanity check the number of bytes per sample + assert bytes_per_sample==1 or bytes_per_sample==2 + + # Sanity check that the vorbis library is available (for mypy) + assert vorbis.libvorbisfile is not None + + #: Bytes per sample + self.bytes_per_sample = bytes_per_sample + + #: Samples are signed (rather than unsigned) + self.signed = signed + + # Create a Vorbis File structure + vf = vorbis.OggVorbis_File() + + # Attempt to open the Vorbis file + error = vorbis.libvorbisfile.ov_fopen( + vorbis.to_char_p(path), + ctypes.byref(vf) + ) + + # Check for errors during opening + if error != 0: + raise PyOggError( + ("File '{}' couldn't be opened or doesn't exist. "+ + "Error code : {}").format(path, error) + ) + + # Extract info from the Vorbis file + info = vorbis.libvorbisfile.ov_info( + ctypes.byref(vf), + -1 # the current logical bitstream + ) + + #: Number of channels in audio file. + self.channels = info.contents.channels + + #: Number of samples per second (per channel), 44100 for + # example. + self.frequency = info.contents.rate + + # Extract the total number of PCM samples for the first + # logical bitstream + pcm_length_samples = vorbis.libvorbisfile.ov_pcm_total( + ctypes.byref(vf), + 0 # to extract the length of the first logical bitstream + ) + + # Create a memory block to store the entire PCM + Buffer = ( + ctypes.c_char + * ( + pcm_length_samples + * self.bytes_per_sample + * self.channels + ) + ) + self.buffer = Buffer() + + # Create a pointer to the newly allocated memory. It + # seems we can only do pointer arithmetic on void + # pointers. See + # https://mattgwwalker.wordpress.com/2020/05/30/pointer-manipulation-in-python/ + buf_ptr = ctypes.cast( + ctypes.pointer(self.buffer), + ctypes.c_void_p + ) + + # Storage for the index of the logical bitstream + bitstream_previous = None + bitstream = ctypes.c_int() + + # Set bytes remaining to read into PCM + read_size = len(self.buffer) + + while True: + # Convert buffer pointer to the desired type + ptr = ctypes.cast( + buf_ptr, + ctypes.POINTER(ctypes.c_char) + ) + + # Attempt to decode PCM from the Vorbis file + result = vorbis.libvorbisfile.ov_read( + ctypes.byref(vf), + ptr, + read_size, + 0, # Little endian + self.bytes_per_sample, + int(self.signed), + ctypes.byref(bitstream) + ) + + # Check for errors + if result < 0: + raise PyOggError( + "An error occurred decoding the Vorbis file: "+ + f"Error code: {result}" + ) + + # Check that the bitstream hasn't changed as we only + # support Vorbis files with a single logical bitstream. + if bitstream_previous is None: + bitstream_previous = bitstream + else: + if bitstream_previous != bitstream: + raise PyOggError( + "PyOgg currently supports Vorbis files "+ + "with only one logical stream" + ) + + # Check for end of file + if result == 0: + break + + # Calculate the number of bytes remaining to read into PCM + read_size -= result + + # Update the pointer into the buffer + buf_ptr.value += result + + + # Close the file and clean up memory + vorbis.libvorbisfile.ov_clear(ctypes.byref(vf)) diff --git a/sbapp/pyogg/vorbis_file_stream.py b/sbapp/pyogg/vorbis_file_stream.py new file mode 100644 index 0000000..57677ba --- /dev/null +++ b/sbapp/pyogg/vorbis_file_stream.py @@ -0,0 +1,110 @@ +import ctypes + +from . import vorbis +from .pyogg_error import PyOggError + +class VorbisFileStream: + def __init__(self, path, buffer_size=8192): + self.exists = False + self._buffer_size = buffer_size + + self.vf = vorbis.OggVorbis_File() + error = vorbis.ov_fopen(path, ctypes.byref(self.vf)) + if error != 0: + raise PyOggError("file couldn't be opened or doesn't exist. Error code : {}".format(error)) + + info = vorbis.ov_info(ctypes.byref(self.vf), -1) + + #: Number of channels in audio file. + self.channels = info.contents.channels + + #: Number of samples per second (per channel). Always + # 48,000. + self.frequency = info.contents.rate + + array = (ctypes.c_char*(self._buffer_size*self.channels))() + + self.buffer_ = ctypes.cast(ctypes.pointer(array), ctypes.c_char_p) + + self.bitstream = ctypes.c_int() + self.bitstream_pointer = ctypes.pointer(self.bitstream) + + self.exists = True # TODO: is this the best place for this statement? + + #: Bytes per sample + self.bytes_per_sample = 2 # TODO: Where is this defined? + + def __del__(self): + if self.exists: + vorbis.ov_clear(ctypes.byref(self.vf)) + self.exists = False + + def clean_up(self): + vorbis.ov_clear(ctypes.byref(self.vf)) + + self.exists = False + + def get_buffer(self): + """get_buffer() -> bytesBuffer, bufferLength + + Returns None when all data has been read from the file. + + """ + if not self.exists: + return None + buffer = [] + total_bytes_written = 0 + + while True: + new_bytes = vorbis.ov_read(ctypes.byref(self.vf), self.buffer_, self._buffer_size*self.channels - total_bytes_written, 0, 2, 1, self.bitstream_pointer) + + array_ = ctypes.cast(self.buffer_, ctypes.POINTER(ctypes.c_char*(self._buffer_size*self.channels))).contents + + buffer.append(array_.raw[:new_bytes]) + + total_bytes_written += new_bytes + + if new_bytes == 0 or total_bytes_written >= self._buffer_size*self.channels: + break + + out_buffer = b"".join(buffer) + + if total_bytes_written == 0: + self.clean_up() + return(None) + + return out_buffer + + def get_buffer_as_array(self): + """Provides the buffer as a NumPy array. + + Note that the underlying data type is 16-bit signed + integers. + + Does not copy the underlying data, so the returned array + should either be processed or copied before the next call + to get_buffer() or get_buffer_as_array(). + + """ + import numpy # type: ignore + + # Read the next samples from the stream + buf = self.get_buffer() + + # Check if we've come to the end of the stream + if buf is None: + return None + + # Convert the bytes buffer to a NumPy array + array = numpy.frombuffer( + buf, + dtype=numpy.int16 + ) + + # Reshape the array + return array.reshape( + (len(buf) + // self.bytes_per_sample + // self.channels, + self.channels) + ) diff --git a/sbapp/services/sidebandservice.py b/sbapp/services/sidebandservice.py index e5a4b43..e718a6f 100644 --- a/sbapp/services/sidebandservice.py +++ b/sbapp/services/sidebandservice.py @@ -61,87 +61,75 @@ class SidebandService(): 0x0483: [0x5740], # ST CDC 0x2E8A: [0x0005, 0x000A], # Raspberry Pi Pico 0x239A: [0x8029], # Adafruit (RAK4631) - 0x303A: [0x1001, 0x4001], # ESP-32S3 + 0x303A: [0x1001], # ESP-32S3 } def android_notification(self, title="", content="", ticker="", group=None, context_id=None): - try: - if android_api_version < 26: return + if android_api_version < 26: + return + else: + package_name = "io.unsigned.sideband" + + if not self.notification_service: + self.notification_service = cast(NotificationManager, self.app_context.getSystemService( + Context.NOTIFICATION_SERVICE + )) + + channel_id = package_name + group_id = "" + if group != None: + channel_id += "."+str(group) + group_id += str(group) + if context_id != None: + channel_id += "."+str(context_id) + group_id += "."+str(context_id) + + if not title or title == "": + channel_name = "Sideband" else: - package_name = "io.unsigned.sideband" - silent_contexts = ["incoming_call"] + channel_name = title - if not self.notification_service: - self.notification_service = cast(NotificationManager, self.app_context.getSystemService(Context.NOTIFICATION_SERVICE)) + self.notification_channel = NotificationChannel(channel_id, channel_name, NotificationManager.IMPORTANCE_DEFAULT) + self.notification_channel.enableVibration(True) + self.notification_channel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), None) + self.notification_channel.setShowBadge(True) + self.notification_service.createNotificationChannel(self.notification_channel) - channel_id = package_name - group_id = "" - if group != None: - channel_id += "."+str(group) - group_id += str(group) - if context_id != None: - channel_id += "."+str(context_id) - group_id += "."+str(context_id) + notification = NotificationBuilder(self.app_context, channel_id) + notification.setContentTitle(title) + notification.setContentText(AndroidString(content)) + notification.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + + # if group != None: + # notification.setGroup(group_id) - if not title or title == "": channel_name = "Sideband" - else: channel_name = title + if not self.notification_small_icon: + # path = self.sideband.notification_icon + path = self.sideband.notif_icon_black + bitmap = BitmapFactory.decodeFile(path) + self.notification_small_icon = Icon.createWithBitmap(bitmap) - if context_id in silent_contexts: silent = True - else: silent = False + notification.setSmallIcon(self.notification_small_icon) + # notification.setLargeIcon(self.notification_small_icon) - if context_id == "incoming_call": channel_importance = NotificationManager.IMPORTANCE_HIGH - else: channel_importance = NotificationManager.IMPORTANCE_DEFAULT + # large_icon_path = self.sideband.icon + # bitmap_icon = BitmapFactory.decodeFile(large_icon_path) + # notification.setLargeIcon(bitmap_icon) - self.notification_channel = NotificationChannel(channel_id, channel_name, channel_importance) - self.notification_channel.enableVibration(True) - if not silent: self.notification_channel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), None) - else: self.notification_channel.setSound(None, None) - self.notification_channel.setShowBadge(True) - self.notification_service.createNotificationChannel(self.notification_channel) + notification_intent = Intent(self.app_context, python_act) + notification_intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + notification_intent.setAction(Intent.ACTION_MAIN) + notification_intent.addCategory(Intent.CATEGORY_LAUNCHER) + if context_id != None: + cstr = f"conversation.{context_id}" + notification_intent.putExtra(JString("intent_action"), JString(cstr)) + self.notification_intent = PendingIntent.getActivity(self.app_context, 0, notification_intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT) - notification = NotificationBuilder(self.app_context, channel_id) - notification.setContentTitle(title) - notification.setContentText(AndroidString(content)) - - if not silent: notification.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) - else: notification.setSound(None, None) - - if not self.notification_small_icon: - # path = self.sideband.notification_icon - path = self.sideband.notif_icon_black - bitmap = BitmapFactory.decodeFile(path) - self.notification_small_icon = Icon.createWithBitmap(bitmap) + notification.setContentIntent(self.notification_intent) + notification.setAutoCancel(True) - notification.setSmallIcon(self.notification_small_icon) - # notification.setLargeIcon(self.notification_small_icon) - - # large_icon_path = self.sideband.icon - # bitmap_icon = BitmapFactory.decodeFile(large_icon_path) - # notification.setLargeIcon(bitmap_icon) - - notification_intent = Intent(self.app_context, python_act) - notification_intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - notification_intent.setAction(Intent.ACTION_MAIN) - notification_intent.addCategory(Intent.CATEGORY_LAUNCHER) - if context_id != None: - if context_id == "incoming_call": - cstr = context_id - notification_intent.putExtra(JString("intent_action"), JString(cstr)) - else: - cstr = f"conversation.{context_id}" - notification_intent.putExtra(JString("intent_action"), JString(cstr)) - - self.notification_intent = PendingIntent.getActivity(self.app_context, 0, notification_intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT) - - notification.setContentIntent(self.notification_intent) - notification.setAutoCancel(True) - - built_notification = notification.build() - self.notification_service.notify(0, built_notification) - - except Exception as e: - RNS.log(f"Error while creating notification: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) + built_notification = notification.build() + self.notification_service.notify(0, built_notification) def check_permission(self, permission): if RNS.vendor.platformutils.is_android(): @@ -241,8 +229,6 @@ class SidebandService(): self.notification_intent = None self.notification_small_icon = None - self.rnode_ble_reset_required = False - if RNS.vendor.platformutils.is_android(): self.android_service = autoclass('org.kivy.android.PythonService').mService self.app_context = self.android_service.getApplication().getApplicationContext() @@ -392,22 +378,6 @@ class SidebandService(): stat += f"[b]RNode[/b]\n{rs}{bs}\n\n" - if self.sideband.interface_weave != None: - if self.sideband.interface_weave.online: rs = "Connected" - else: rs = "Interface Down" - - # bs = "" - # bat_state = self.sideband.interface_weave.get_battery_state_string() - # bat_percent = self.sideband.interface_weave.get_battery_percent() - # if bat_state != "unknown": - # bs = f"\nBattery at {bat_percent}%" - - np = len(self.sideband.interface_weave.peers) - if np == 1: ws = "1 reachable peer" - else: ws = str(np)+" reachable peers" - - stat += f"[b]Weave[/b]\n{rs}\n{ws}\n\n" - if self.sideband.interface_modem != None: if self.sideband.interface_modem.online: rm = "Connected" @@ -506,23 +476,11 @@ class SidebandService(): self.sideband.setstate("wants.settings_reload", False) self.sideband.reload_configuration() - if self.sideband.getstate("wants.rnode_ble_reset"): - self.sideband.setstate("wants.rnode_ble_reset", False) - RNS.log("RNode BLE hardware error detected, re-initializing Android BLE dispatcher", RNS.LOG_ERROR) - if hasattr(self.sideband, "interface_rnode") and self.sideband.interface_rnode != None: - self.sideband.interface_rnode.reset_ble() - RNS.log("BLE hardware reset executed", RNS.LOG_INFO) - else: - RNS.log("No RNode interface active, could not execute BLE hardware reset", RNS.LOG_ERROR) - time.sleep(sleep_time) self.sideband.cleanup() self.release_locks() - # TODO: Check if this works in all cases - self.android_service.stopSelf() - def handle_exception(exc_type, exc_value, exc_traceback): if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_traceback) diff --git a/sbapp/share/flasher.html b/sbapp/share/flasher.html index a45de5f..9033cc9 100644 --- a/sbapp/share/flasher.html +++ b/sbapp/share/flasher.html @@ -26,7 +26,7 @@ To use the flasher, you will need firmware packages for the boards you want use.
  • You can compile them yourself from the RNode Firmware source code package included in this repository.

  • -
    +


    diff --git a/sbapp/share/guides.html b/sbapp/share/guides.html index cc1741d..d4cf17a 100644 --- a/sbapp/share/guides.html +++ b/sbapp/share/guides.html @@ -13,9 +13,10 @@ Welcome to the Guide Section!

    From here, you can browse or download various included manuals, documentation, references and guides.
    diff --git a/sbapp/sideband/audioproc.py b/sbapp/sideband/audioproc.py index 215fcd8..fcca182 100644 --- a/sbapp/sideband/audioproc.py +++ b/sbapp/sideband/audioproc.py @@ -1,5 +1,6 @@ import os import io +import sh import math import time import struct @@ -8,11 +9,15 @@ import RNS import LXMF if RNS.vendor.platformutils.is_android(): - from LXST.Codecs.libs.pyogg import OpusFile, OpusBufferedEncoder, OggOpusWriter - from LXST.Codecs.libs.pydub import AudioSegment + from pyogg import OpusFile, OpusBufferedEncoder, OggOpusWriter + from pydub import AudioSegment else: - from LXST.Codecs.libs.pyogg import OpusFile, OpusBufferedEncoder, OggOpusWriter - from LXST.Codecs.libs.pydub import AudioSegment + if RNS.vendor.platformutils.is_linux(): + from sbapp.pyogg import OpusFile, OpusBufferedEncoder, OggOpusWriter + else: + from pyogg import OpusFile, OpusBufferedEncoder, OggOpusWriter + + from sbapp.pydub import AudioSegment codec2_modes = { # LXMF.AM_CODEC2_450PWB: ???, # No bindings @@ -97,8 +102,28 @@ def samples_to_wav(samples=None, file_path=None): def voice_processing(input_path): try: - # Moving to LXST native processing - return None + ffmpeg = None + ffmpeg = sh.ffmpeg + if ffmpeg: + filters = "highpass=f=250, lowpass=f=3000,speechnorm=e=12.5:r=0.0001:l=1" + output_bitrate = "12k" + opus_apptype = "audio" + output_path = input_path.replace(".ogg","")+".p.ogg" + args = [ + "-i", input_path, "-filter:a", filters, + "-c:a", "libopus", "-application", opus_apptype, + "-vbr", "on","-b:a", output_bitrate, output_path] + try: + try: + os.unlink(output_path) + except: + pass + ffmpeg(*args) + return output_path + except Exception as e: + RNS.log("Could not process audio with ffmpeg", RNS.LOG_ERROR) + RNS.trace_exception(e) + return None except Exception as e: return None diff --git a/sbapp/sideband/console.py b/sbapp/sideband/console.py deleted file mode 100644 index d6b9ec9..0000000 --- a/sbapp/sideband/console.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -import RNS -import threading -from prompt_toolkit.application import Application -from prompt_toolkit.document import Document -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout.containers import HSplit, Window -from prompt_toolkit.layout.layout import Layout -from prompt_toolkit.styles import Style -from prompt_toolkit.widgets import SearchToolbar, TextArea - -sideband = None -application = None -output_document = Document(text="", cursor_position=0) -output_field = None - -def attach(target_core): - global sideband - sideband = target_core - RNS.logdest = RNS.LOG_CALLBACK - RNS.logcall = receive_output - console() - -def parse(uin): - args = uin.split(" ") - cmd = args[0] - if cmd == "q" or cmd == "quit": quit_action() - elif cmd == "clear": cmd_clear(args) - elif cmd == "raw": cmd_raw(args, uin.replace("raw ", "")) - elif cmd == "log": cmd_log(args) - else: receive_output(f"Unknown command: {cmd}") - -def cmd_clear(args): - output_document = output_document = Document(text="", cursor_position=0) - output_field.buffer.document = output_document - -def cmd_raw(args, expr): - if expr != "" and expr != "raw": - try: receive_output(eval(expr)) - except Exception as e: receive_output(str(e)) - -def cmd_log(args): - try: - if len(args) == 1: receive_output(f"Current loglevel is {RNS.loglevel}") - else: RNS.loglevel = int(args[1]); receive_output(f"Loglevel set to {RNS.loglevel}") - except Exception as e: - receive_output("Invalid loglevel: {e}") - -def set_log(level=None): - if level: RNS.loglevel = level - if RNS.loglevel == 0: receive_output("Logging squelched. Use log command to print output to console.") - -def quit_action(): - receive_output("Shutting down Sideband...") - sideband.should_persist_data() - application.exit() - -def receive_output(msg): - global output_document, output_field - content = f"{output_field.text}\n{msg}" - output_document = output_document = Document(text=content, cursor_position=len(content)) - output_field.buffer.document = output_document - -def console(): - global output_document, output_field, application - search_field = SearchToolbar() - - output_field = TextArea(style="class:output-field", text="Sideband console ready") - input_field = TextArea( - height=1, - prompt="> ", - style="class:input-field", - multiline=False, - wrap_lines=False, - search_field=search_field) - - container = HSplit([ - output_field, - Window(height=1, char="-", style="class:line"), - input_field, - search_field]) - - def accept(buff): parse(input_field.text) - input_field.accept_handler = accept - - kb = KeyBindings() - @kb.add("c-c") - @kb.add("c-q") - def _(event): quit_action() - - style = Style([ - ("line", "#004444"), - ]) - - application = Application( - layout=Layout(container, focused_element=input_field), - key_bindings=kb, - style=style, - mouse_support=True, - full_screen=False) - - set_log() - application.run() \ No newline at end of file diff --git a/sbapp/sideband/core.py b/sbapp/sideband/core.py index fe8bcbf..e758455 100644 --- a/sbapp/sideband/core.py +++ b/sbapp/sideband/core.py @@ -8,7 +8,6 @@ import sqlite3 import random import shlex import re -import gc import RNS.vendor.umsgpack as msgpack import RNS.Interfaces.Interface as Interface @@ -53,7 +52,7 @@ class PropagationNodeDetector(): if app_data != None and len(app_data) > 0: if pn_announce_data_is_valid(app_data): unpacked = msgpack.unpackb(app_data) - node_active = unpacked[2] + node_active = unpacked[0] emitted = unpacked[1] hops = RNS.Transport.hops_to(destination_hash) @@ -67,8 +66,10 @@ class PropagationNodeDetector(): # age = 0 pass - if self.owner_app != None: stat_endpoint = self.owner_app.sideband - else: stat_endpoint = self.owner + if self.owner_app != None: + stat_endpoint = self.owner_app.sideband + else: + stat_endpoint = self.owner link_stats = {"rssi": stat_endpoint.reticulum.get_packet_rssi(announce_packet_hash), "snr": stat_endpoint.reticulum.get_packet_snr(announce_packet_hash), @@ -103,20 +104,20 @@ class PropagationNodeDetector(): self.owner_app = owner.owner_app class SidebandCore(): - CONV_P2P = 0x01 - CONV_GROUP = 0x02 - CONV_BROADCAST = 0x03 - CONV_VOICE = 0x04 + CONV_P2P = 0x01 + CONV_GROUP = 0x02 + CONV_BROADCAST = 0x03 + CONV_VOICE = 0x04 - MAX_ANNOUNCES = 24 + MAX_ANNOUNCES = 24 - SERVICE_JOB_INTERVAL = 1 - PERIODIC_JOBS_INTERVAL = 60 - PERIODIC_SYNC_RETRY = 360 - TELEMETRY_KEEP = 60*60*24*7 - TELEMETRY_INTERVAL = 60 - SERVICE_TELEMETRY_INTERVAL = 300 - TELEMETRY_CLEAN_INTERVAL = 3600 + SERVICE_JOB_INTERVAL = 1 + PERIODIC_JOBS_INTERVAL = 60 + PERIODIC_SYNC_RETRY = 360 + TELEMETRY_KEEP = 60*60*24*7 + TELEMETRY_INTERVAL = 60 + SERVICE_TELEMETRY_INTERVAL = 300 + TELEMETRY_CLEAN_INTERVAL = 3600 IF_CHANGE_ANNOUNCE_MIN_INTERVAL = 3.5 # In seconds AUTO_ANNOUNCE_RANDOM_MIN = 90 # In minutes @@ -126,8 +127,6 @@ class SidebandCore(): LOG_DEQUE_MAXLEN = 128 - ERROR_INVALID_BLE_MTU = 0x20 - aspect_filter = "lxmf.delivery" def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash): # Add the announce to the directory announce @@ -139,7 +138,7 @@ class SidebandCore(): "q": self.reticulum.get_packet_q(announce_packet_hash)} # This reformats the new v0.5.0 announce data back to the expected format - # for Sideband's database and other handling functions. + # for Sidebands database and other handling functions. dn = LXMF.display_name_from_app_data(app_data) sc = LXMF.stamp_cost_from_app_data(app_data) app_data = b"" @@ -148,13 +147,13 @@ class SidebandCore(): self.log_announce(destination_hash, app_data, dest_type=SidebandCore.aspect_filter, stamp_cost=sc, link_stats=link_stats) - def __init__(self, owner_app, config_path = None, is_service=False, is_client=False, android_app_dir=None, verbose=False, quiet=False, owner_service=None, service_context=None, is_daemon=False, load_config_only=False, rns_config_path=None): + def __init__(self, owner_app, config_path = None, is_service=False, is_client=False, android_app_dir=None, verbose=False, owner_service=None, service_context=None, is_daemon=False, load_config_only=False): self.is_service = is_service self.is_client = is_client self.is_daemon = is_daemon - self.ptt_player = None - self.ptt_player_lock = threading.Lock() + self.msg_audio = None self.last_msg_audio = None + self.ptt_playback_lock = threading.Lock() self.ui_recording = False self.db = None self.db_lock = threading.Lock() @@ -164,20 +163,17 @@ class SidebandCore(): else: self.is_standalone = False - self.log_verbose = (verbose and not quiet) - self.log_quiet = quiet + self.log_verbose = verbose self.log_deque = deque(maxlen=self.LOG_DEQUE_MAXLEN) self.owner_app = owner_app self.reticulum = None self.webshare_server = None self.voice_running = False - self.telephone = None self.telemeter = None self.telemetry_running = False self.latest_telemetry = None self.latest_packed_telemetry = None self.telemetry_changes = 0 - self.telemetry_response_excluded = [] self.pending_telemetry_send = False self.pending_telemetry_send_try = 0 self.pending_telemetry_send_maxtries = 2 @@ -202,28 +198,25 @@ class SidebandCore(): self.default_config_template = rns_config if config_path == None: - # h_dir = plyer.storagepath.get_home_dir() - # if type(h_dir) == bytes: h_dir = h_dir.decode("utf-8") - # self.app_dir = os.path.join(h_dir, ".config", "sideband") - # if self.app_dir.startswith("file://"): self.app_dir = self.app_dir.replace("file://", "") - self.app_dir = os.path.join(plyer.storagepath.get_home_dir(), ".config", "sideband") - if self.app_dir.startswith("file://"): self.app_dir = self.app_dir.replace("file://", "") + self.app_dir = plyer.storagepath.get_home_dir()+"/.config/sideband" + if self.app_dir.startswith("file://"): + self.app_dir = self.app_dir.replace("file://", "") else: self.app_dir = config_path - self.cache_dir = os.path.join(self.app_dir, "cache") + self.cache_dir = self.app_dir+"/cache" - self.rns_configdir = rns_config_path + self.rns_configdir = None core_path = os.path.abspath(__file__) if "core.pyc" in core_path: core_path = core_path.replace("core.pyc", "core.py") if RNS.vendor.platformutils.is_android(): - self.app_dir = os.path.join(android_app_dir, "io.unsigned.sideband", "files") - self.cache_dir = os.path.join(self.app_dir, "cache") - self.rns_configdir = os.path.join(self.app_dir, "app_storage", "reticulum") - self.asset_dir = os.path.join(self.app_dir, "app", "assets") + self.app_dir = android_app_dir+"/io.unsigned.sideband/files/" + self.cache_dir = self.app_dir+"/cache" + self.rns_configdir = self.app_dir+"/app_storage/reticulum" + self.asset_dir = self.app_dir+"/app/assets" elif RNS.vendor.platformutils.is_darwin(): self.asset_dir = core_path.replace("/sideband/core.py", "/assets") elif RNS.vendor.platformutils.get_platform() == "linux": @@ -231,53 +224,49 @@ class SidebandCore(): elif RNS.vendor.platformutils.is_windows(): self.asset_dir = core_path.replace("\\sideband\\core.py", "\\assets") else: - self.asset_dir = os.path.join(plyer.storagepath.get_application_dir(), "sbapp", "assets") + self.asset_dir = plyer.storagepath.get_application_dir()+"/sbapp/assets" - self.map_cache = os.path.join(self.cache_dir, "maps") + self.map_cache = self.cache_dir+"/maps" if not os.path.isdir(self.map_cache): os.makedirs(self.map_cache) - self.rec_cache = os.path.join(self.cache_dir, "rec") + self.rec_cache = self.cache_dir+"/rec" if not os.path.isdir(self.rec_cache): os.makedirs(self.rec_cache) - self.share_cache = os.path.join(self.cache_dir, "share") + self.share_cache = self.cache_dir+"/share" if not os.path.isdir(self.share_cache): os.makedirs(self.share_cache) - self.icon = os.path.join(self.asset_dir, "icon.png") - self.icon_48 = os.path.join(self.asset_dir, "icon_48.png") - self.icon_32 = os.path.join(self.asset_dir, "icon_32.png") - self.icon_macos = os.path.join(self.asset_dir, "icon_macos.png") - self.icon_windows = os.path.join(self.asset_dir, "icon.ico") - self.notification_icon = os.path.join(self.asset_dir, "notification_icon.png") - self.notif_icon_black = os.path.join(self.asset_dir, "notification_icon_black.png") + self.icon = self.asset_dir+"/icon.png" + self.icon_48 = self.asset_dir+"/icon_48.png" + self.icon_32 = self.asset_dir+"/icon_32.png" + self.icon_macos = self.asset_dir+"/icon_macos.png" + self.notification_icon = self.asset_dir+"/notification_icon.png" + self.notif_icon_black = self.asset_dir+"/notification_icon_black.png" os.environ["TELEMETER_GEOID_PATH"] = os.path.join(self.asset_dir, "geoids") - if not os.path.isdir(os.path.join(self.app_dir, "app_storage")): - os.makedirs(os.path.join(self.app_dir, "app_storage")) - - self.config_path = os.path.join(self.app_dir, "app_storage", "sideband_config") - self.identity_path = os.path.join(self.app_dir, "app_storage", "primary_identity") - self.db_path = os.path.join(self.app_dir, "app_storage", "sideband.db") - self.lxmf_storage = os.path.join(self.app_dir, "app_storage") - self.log_dir = os.path.join(self.app_dir, "app_storage") - self.tmp_dir = os.path.join(self.app_dir, "app_storage", "tmp") - self.exports_dir = os.path.join(self.app_dir, "exports") - self.telemetry_exclude_path = os.path.join(self.app_dir, "app_storage", "collector_response_excluded") + if not os.path.isdir(self.app_dir+"/app_storage"): + os.makedirs(self.app_dir+"/app_storage") + self.config_path = self.app_dir+"/app_storage/sideband_config" + self.identity_path = self.app_dir+"/app_storage/primary_identity" + self.db_path = self.app_dir+"/app_storage/sideband.db" + self.lxmf_storage = self.app_dir+"/app_storage/" + self.log_dir = self.app_dir+"/app_storage/" + self.tmp_dir = self.app_dir+"/app_storage/tmp" + self.exports_dir = self.app_dir+"/exports" if RNS.vendor.platformutils.is_android(): self.webshare_dir = "./share/" else: sideband_dir = os.path.dirname(os.path.abspath(__file__)) self.webshare_dir = os.path.abspath(os.path.join(sideband_dir, "..", "share")) - self.webshare_ssl_key_path = os.path.join(self.app_dir, "app_storage", "ssl_key.pem") - self.webshare_ssl_cert_path = os.path.join(self.app_dir, "app_storage", "ssl_cert.pem") + self.webshare_ssl_key_path = self.app_dir+"/app_storage/ssl_key.pem" + self.webshare_ssl_cert_path = self.app_dir+"/app_storage/ssl_cert.pem" self.mqtt = None - self.mqtt_handle_lock = threading.Lock() self.first_run = True self.saving_configuration = False @@ -285,7 +274,6 @@ class SidebandCore(): self.last_if_change_announce = 0 self.interface_local_adding = False self.interface_rnode_adding = False - self.interface_weave_adding = False self.next_auto_announce = time.time() + 60*(random.random()*(SidebandCore.AUTO_ANNOUNCE_RANDOM_MAX-SidebandCore.AUTO_ANNOUNCE_RANDOM_MIN)+SidebandCore.AUTO_ANNOUNCE_RANDOM_MIN) try: @@ -296,8 +284,6 @@ class SidebandCore(): try: self.__load_config() except Exception as e: - RNS.log(f"An error occurred while loading existing Sideband configuration: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) self.__init_config() self.__load_config() self.first_run = False @@ -314,8 +300,7 @@ class SidebandCore(): self.clear_exports_dir() except Exception as e: - RNS.log(f"Error while configuring Sideband: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) + RNS.log("Error while configuring Sideband: "+str(e), RNS.LOG_ERROR) if load_config_only: return @@ -323,9 +308,9 @@ class SidebandCore(): if RNS.vendor.platformutils.is_android(): if self.config["config_template"] != None: try: - if not os.path.isfile(os.path.join(self.rns_configdir, "config_template_invalid")): + if not os.path.isfile(self.rns_configdir+"/config_template_invalid"): if self.is_service: - with open(os.path.join(self.rns_configdir, "config_template_invalid"), "w") as invalidation_file: + with open(self.rns_configdir+"/config_template_invalid", "w") as invalidation_file: invalidation_file.write("\n") ct = self.config["config_template"] @@ -346,7 +331,7 @@ class SidebandCore(): # Initialise Reticulum configuration if RNS.vendor.platformutils.get_platform() == "android": try: - self.rns_configdir = os.path.join(self.app_dir, "app_storage", "reticulum") + self.rns_configdir = self.app_dir+"/app_storage/reticulum" if not os.path.isdir(self.rns_configdir): os.makedirs(self.rns_configdir) @@ -358,7 +343,7 @@ class SidebandCore(): RNS.log("Not enabling Reticulum Transport") generated_config = self.config_template.replace("TRANSPORT_IS_ENABLED", "No") - config_file = open(os.path.join(self.rns_configdir, "config"), "wb") + config_file = open(self.rns_configdir+"/config", "wb") config_file.write(generated_config.encode("utf-8")) config_file.close() @@ -393,10 +378,10 @@ class SidebandCore(): app_entry_dir = os.path.expanduser("~/.local/share/applications") icon_dir = os.path.expanduser("~/.local/share/icons/hicolor/512x512/apps") de_filename = "io.unsigned.sideband.desktop" - de_source = os.path.join(self.asset_dir, de_filename) - de_target = os.path.join(app_entry_dir, de_filename) - icn_source = os.path.join(self.asset_dir, "icon.png") - icn_target = os.path.join(icon_dir, "io.unsigned.sideband.png") + de_source = self.asset_dir+"/"+de_filename + de_target = app_entry_dir+"/"+de_filename + icn_source = self.asset_dir+"/icon.png" + icn_target = icon_dir+"/io.unsigned.sideband.png" if os.path.isdir(local_share_dir): if not os.path.exists(app_entry_dir): os.makedirs(app_entry_dir) @@ -439,13 +424,13 @@ class SidebandCore(): def clear_tmp_dir(self): if os.path.isdir(self.tmp_dir): for file in os.listdir(self.tmp_dir): - fpath = os.path.join(self.tmp_dir, file) + fpath = self.tmp_dir+"/"+file os.unlink(fpath) def clear_exports_dir(self): if os.path.isdir(self.exports_dir): for file in os.listdir(self.exports_dir): - fpath = os.path.join(self.exports_dir, file) + fpath = self.exports_dir+"/"+file RNS.log("Clearing "+str(fpath)) os.unlink(fpath) @@ -482,7 +467,6 @@ class SidebandCore(): self.config["lxm_limit_1mb"] = True self.config["trusted_markup_only"] = False self.config["compose_in_markdown"] = False - self.config["confirm_calls"] = True # Connectivity self.config["connect_transport"] = False @@ -548,8 +532,6 @@ class SidebandCore(): self.config["telemetry_icon"] = SidebandCore.DEFAULT_APPEARANCE[0] self.config["telemetry_send_to_trusted"] = False self.config["telemetry_send_to_collector"] = False - self.config["telemetry_allow_requests_from_anyone"] = False - self.config["telemetry_allow_requests_from_trusted"] = False # Voice self.config["voice_enabled"] = False @@ -565,7 +547,7 @@ class SidebandCore(): self._db_inittelemetry() self._db_upgradetables() - self.__save_config(no_thread=True) + self.__save_config() def clear_map_cache(self): for entry in os.scandir(self.map_cache): @@ -586,36 +568,11 @@ class SidebandCore(): self.save_configuration() - def __load_telemetry_collector_excluded(self): - if not os.path.isfile(self.telemetry_exclude_path): - try: - file = open(self.telemetry_exclude_path, "wb") - file.write("# To exclude destinations from telemetry\n# collector responses, add them to this\n# file with one destination hash per line\n".encode("utf-8")) - file.close() - except Exception as e: - RNS.log(f"Could not create telemetry collector exclude file at {self.telemetry_exclude_path}", RNS.LOG_ERROR) - - try: - with open(self.telemetry_exclude_path, "rb") as file: - data = file.read().decode("utf-8") - for line in data.splitlines(): - if not line.startswith("#"): - if len(line) >= RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: - try: - destination_hash = bytes.fromhex(line[:RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2]) - self.telemetry_response_excluded.append(destination_hash) - except Exception as e: - RNS.log(f"Invalid destination hash {line} in telemetry response exclude file: {e}", RNS.LOG_ERROR) - - except Exception as e: - RNS.log(f"Error while loading telemetry collector response excludes: {e}", RNS.LOG_ERROR) - - def __load_config(self): RNS.log("Loading Sideband identity...", RNS.LOG_DEBUG) self.identity = RNS.Identity.from_file(self.identity_path) - self.rpc_addr = f"\0sideband/rpc" + self.rpc_addr = ("127.0.0.1", 48165) self.rpc_key = RNS.Identity.full_hash(self.identity.get_private_key()) RNS.log("Loading Sideband configuration... "+str(self.config_path), RNS.LOG_DEBUG) @@ -624,163 +581,284 @@ class SidebandCore(): config_file.close() # Migration actions from earlier config formats - if not "debug" in self.config: self.config["debug"] = False - if not "dark_ui" in self.config: self.config["dark_ui"] = True - if not "advanced_stats" in self.config: self.config["advanced_stats"] = True - if not "start_at_boot" in self.config: self.config["start_at_boot"] = False - if not "lxmf_periodic_sync" in self.config: self.config["lxmf_periodic_sync"] = False - if not "lxmf_ignore_unknown" in self.config: self.config["lxmf_ignore_unknown"] = False - if not "lxmf_sync_interval" in self.config: self.config["lxmf_sync_interval"] = 43200 - if not "lxmf_try_propagation_on_fail" in self.config: self.config["lxmf_try_propagation_on_fail"] = True - if not "lxmf_require_stamps" in self.config: self.config["lxmf_require_stamps"] = False - if not "lxmf_ignore_invalid_stamps" in self.config: self.config["lxmf_ignore_invalid_stamps"] = True - if not "lxmf_inbound_stamp_cost" in self.config: self.config["lxmf_inbound_stamp_cost"] = None - if not "notifications_on" in self.config: self.config["notifications_on"] = True - if not "print_command" in self.config: self.config["print_command"] = "lp" - if not "eink_mode" in self.config: self.config["eink_mode"] = True - if not "classic_message_colors" in self.config: self.config["classic_message_colors"] = False - if not "display_style_in_contact_list" in self.config: self.config["display_style_in_contact_list"] = True - if not "lxm_limit_1mb" in self.config: self.config["lxm_limit_1mb"] = True - if not "hq_ptt" in self.config: self.config["hq_ptt"] = True - if not "trusted_markup_only" in self.config: self.config["trusted_markup_only"] = False - if not "compose_in_markdown" in self.config: self.config["compose_in_markdown"] = False - if not "confirm_calls" in self.config: self.config["confirm_calls"] = True + if not "debug" in self.config: + self.config["debug"] = False + if not "dark_ui" in self.config: + self.config["dark_ui"] = True + if not "advanced_stats" in self.config: + self.config["advanced_stats"] = True + if not "lxmf_periodic_sync" in self.config: + self.config["lxmf_periodic_sync"] = False + if not "lxmf_ignore_unknown" in self.config: + self.config["lxmf_ignore_unknown"] = False + if not "lxmf_sync_interval" in self.config: + self.config["lxmf_sync_interval"] = 43200 + if not "lxmf_try_propagation_on_fail" in self.config: + self.config["lxmf_try_propagation_on_fail"] = True + if not "lxmf_require_stamps" in self.config: + self.config["lxmf_require_stamps"] = False + if not "lxmf_ignore_invalid_stamps" in self.config: + self.config["lxmf_ignore_invalid_stamps"] = True + if not "lxmf_inbound_stamp_cost" in self.config: + self.config["lxmf_inbound_stamp_cost"] = None + if not "notifications_on" in self.config: + self.config["notifications_on"] = True + if not "print_command" in self.config: + self.config["print_command"] = "lp" + if not "eink_mode" in self.config: + self.config["eink_mode"] = True + if not "classic_message_colors" in self.config: + self.config["classic_message_colors"] = False + if not "display_style_in_contact_list" in self.config: + self.config["display_style_in_contact_list"] = True + if not "lxm_limit_1mb" in self.config: + self.config["lxm_limit_1mb"] = True + if not "hq_ptt" in self.config: + self.config["hq_ptt"] = False + if not "trusted_markup_only" in self.config: + self.config["trusted_markup_only"] = False + if not "compose_in_markdown" in self.config: + self.config["compose_in_markdown"] = False - if not "input_language" in self.config: self.config["input_language"] = None - if not "allow_predictive_text" in self.config: self.config["allow_predictive_text"] = False - if not "block_predictive_text" in self.config: self.config["block_predictive_text"] = False + if not "input_language" in self.config: + self.config["input_language"] = None + if not "allow_predictive_text" in self.config: + self.config["allow_predictive_text"] = False + if not "block_predictive_text" in self.config: + self.config["block_predictive_text"] = False - if not "config_template" in self.config: self.config["config_template"] = None - if not "connect_transport" in self.config: self.config["connect_transport"] = False - if not "connect_share_instance" in self.config: self.config["connect_share_instance"] = False - if not "connect_rnode" in self.config: self.config["connect_rnode"] = False - if not "connect_rnode_ifac_netname" in self.config: self.config["connect_rnode_ifac_netname"] = "" - if not "connect_rnode_ifac_passphrase" in self.config: self.config["connect_rnode_ifac_passphrase"] = "" - if not "connect_serial" in self.config: self.config["connect_serial"] = False - if not "connect_serial_ifac_netname" in self.config: self.config["connect_serial_ifac_netname"] = "" - if not "connect_serial_ifac_passphrase" in self.config: self.config["connect_serial_ifac_passphrase"] = "" - if not "connect_modem" in self.config: self.config["connect_modem"] = False - if not "connect_modem_ifac_netname" in self.config: self.config["connect_modem_ifac_netname"] = "" - if not "connect_modem_ifac_passphrase" in self.config: self.config["connect_modem_ifac_passphrase"] = "" - if not "connect_weave" in self.config: self.config["connect_weave"] = False - if not "connect_weave_ifac_netname" in self.config: self.config["connect_weave_ifac_netname"] = "" - if not "connect_weave_ifac_passphrase" in self.config: self.config["connect_weave_ifac_passphrase"] = "" + if not "config_template" in self.config: + self.config["config_template"] = None + if not "connect_transport" in self.config: + self.config["connect_transport"] = False + if not "connect_rnode" in self.config: + self.config["connect_rnode"] = False + if not "connect_rnode_ifac_netname" in self.config: + self.config["connect_rnode_ifac_netname"] = "" + if not "connect_rnode_ifac_passphrase" in self.config: + self.config["connect_rnode_ifac_passphrase"] = "" + if not "connect_serial" in self.config: + self.config["connect_serial"] = False + if not "connect_serial_ifac_netname" in self.config: + self.config["connect_serial_ifac_netname"] = "" + if not "connect_serial_ifac_passphrase" in self.config: + self.config["connect_serial_ifac_passphrase"] = "" + if not "connect_modem" in self.config: + self.config["connect_modem"] = False + if not "connect_modem_ifac_netname" in self.config: + self.config["connect_modem_ifac_netname"] = "" + if not "connect_modem_ifac_passphrase" in self.config: + self.config["connect_modem_ifac_passphrase"] = "" - if not "connect_ifmode_local" in self.config: self.config["connect_ifmode_local"] = "full" - if not "connect_ifmode_tcp" in self.config: self.config["connect_ifmode_tcp"] = "full" - if not "connect_ifmode_i2p" in self.config: self.config["connect_ifmode_i2p"] = "full" - if not "connect_ifmode_rnode" in self.config: self.config["connect_ifmode_rnode"] = "full" - if not "connect_ifmode_modem" in self.config: self.config["connect_ifmode_modem"] = "full" - if not "connect_ifmode_serial" in self.config: self.config["connect_ifmode_serial"] = "full" - if not "connect_ifmode_bluetooth" in self.config: self.config["connect_ifmode_bluetooth"] = "full" - if not "connect_ifmode_weave" in self.config: self.config["connect_ifmode_weave"] = "full" + if not "connect_ifmode_local" in self.config: + self.config["connect_ifmode_local"] = "full" + if not "connect_ifmode_tcp" in self.config: + self.config["connect_ifmode_tcp"] = "full" + if not "connect_ifmode_i2p" in self.config: + self.config["connect_ifmode_i2p"] = "full" + if not "connect_ifmode_rnode" in self.config: + self.config["connect_ifmode_rnode"] = "full" + if not "connect_ifmode_modem" in self.config: + self.config["connect_ifmode_modem"] = "full" + if not "connect_ifmode_serial" in self.config: + self.config["connect_ifmode_serial"] = "full" + if not "connect_ifmode_bluetooth" in self.config: + self.config["connect_ifmode_bluetooth"] = "full" - if not "hw_rnode_frequency" in self.config: self.config["hw_rnode_frequency"] = None - if not "hw_rnode_modulation" in self.config: self.config["hw_rnode_modulation"] = "LoRa" - if not "hw_rnode_bandwidth" in self.config: self.config["hw_rnode_bandwidth"] = 62500 - if not "hw_rnode_spreading_factor" in self.config: self.config["hw_rnode_spreading_factor"] = 8 - if not "hw_rnode_coding_rate" in self.config: self.config["hw_rnode_coding_rate"] = 6 - if not "hw_rnode_tx_power" in self.config: self.config["hw_rnode_tx_power"] = 0 - if not "hw_rnode_beaconinterval" in self.config: self.config["hw_rnode_beaconinterval"] = None - if not "hw_rnode_beacondata" in self.config: self.config["hw_rnode_beacondata"] = None - if not "hw_rnode_bluetooth" in self.config: self.config["hw_rnode_bluetooth"] = False - if not "hw_rnode_ble" in self.config: self.config["hw_rnode_ble"] = False - if not "hw_rnode_tcp" in self.config: self.config["hw_rnode_tcp"] = False - if not "hw_rnode_enable_framebuffer" in self.config: self.config["hw_rnode_enable_framebuffer"] = False - if not "hw_rnode_bt_device" in self.config: self.config["hw_rnode_bt_device"] = None - if not "hw_rnode_tcp_host" in self.config: self.config["hw_rnode_tcp_host"] = None - if not "hw_rnode_atl_short" in self.config: self.config["hw_rnode_atl_short"] = None - if not "hw_rnode_atl_long" in self.config: self.config["hw_rnode_atl_long"] = None + if not "hw_rnode_frequency" in self.config: + self.config["hw_rnode_frequency"] = None + if not "hw_rnode_modulation" in self.config: + self.config["hw_rnode_modulation"] = "LoRa" + if not "hw_rnode_bandwidth" in self.config: + self.config["hw_rnode_bandwidth"] = 62500 + if not "hw_rnode_spreading_factor" in self.config: + self.config["hw_rnode_spreading_factor"] = 8 + if not "hw_rnode_coding_rate" in self.config: + self.config["hw_rnode_coding_rate"] = 6 + if not "hw_rnode_tx_power" in self.config: + self.config["hw_rnode_tx_power"] = 0 + if not "hw_rnode_beaconinterval" in self.config: + self.config["hw_rnode_beaconinterval"] = None + if not "hw_rnode_beacondata" in self.config: + self.config["hw_rnode_beacondata"] = None + if not "hw_rnode_bluetooth" in self.config: + self.config["hw_rnode_bluetooth"] = False + if not "hw_rnode_ble" in self.config: + self.config["hw_rnode_ble"] = False + if not "hw_rnode_enable_framebuffer" in self.config: + self.config["hw_rnode_enable_framebuffer"] = False + if not "hw_rnode_bt_device" in self.config: + self.config["hw_rnode_bt_device"] = None + if not "hw_rnode_atl_short" in self.config: + self.config["hw_rnode_atl_short"] = None + if not "hw_rnode_atl_long" in self.config: + self.config["hw_rnode_atl_long"] = None - if not "hw_modem_baudrate" in self.config: self.config["hw_modem_baudrate"] = 115200 - if not "hw_modem_databits" in self.config: self.config["hw_modem_databits"] = 8 - if not "hw_modem_stopbits" in self.config: self.config["hw_modem_stopbits"] = 1 - if not "hw_modem_parity" in self.config: self.config["hw_modem_parity"] = "none" - if not "hw_modem_preamble" in self.config: self.config["hw_modem_preamble"] = 150 - if not "hw_modem_tail" in self.config: self.config["hw_modem_tail"] = 20 - if not "hw_modem_persistence" in self.config: self.config["hw_modem_persistence"] = 220 - if not "hw_modem_slottime" in self.config: self.config["hw_modem_slottime"] = 20 - if not "hw_modem_beaconinterval" in self.config: self.config["hw_modem_beaconinterval"] = None - if not "hw_modem_beacondata" in self.config: self.config["hw_modem_beacondata"] = None + if not "hw_modem_baudrate" in self.config: + self.config["hw_modem_baudrate"] = 115200 + if not "hw_modem_databits" in self.config: + self.config["hw_modem_databits"] = 8 + if not "hw_modem_stopbits" in self.config: + self.config["hw_modem_stopbits"] = 1 + if not "hw_modem_parity" in self.config: + self.config["hw_modem_parity"] = "none" + if not "hw_modem_preamble" in self.config: + self.config["hw_modem_preamble"] = 150 + if not "hw_modem_tail" in self.config: + self.config["hw_modem_tail"] = 20 + if not "hw_modem_persistence" in self.config: + self.config["hw_modem_persistence"] = 220 + if not "hw_modem_slottime" in self.config: + self.config["hw_modem_slottime"] = 20 + if not "hw_modem_beaconinterval" in self.config: + self.config["hw_modem_beaconinterval"] = None + if not "hw_modem_beacondata" in self.config: + self.config["hw_modem_beacondata"] = None - if not "hw_serial_baudrate" in self.config: self.config["hw_serial_baudrate"] = 57600 - if not "hw_serial_databits" in self.config: self.config["hw_serial_databits"] = 8 - if not "hw_serial_stopbits" in self.config: self.config["hw_serial_stopbits"] = 1 - if not "hw_serial_parity" in self.config: self.config["hw_serial_parity"] = "none" + if not "hw_serial_baudrate" in self.config: + self.config["hw_serial_baudrate"] = 57600 + if not "hw_serial_databits" in self.config: + self.config["hw_serial_databits"] = 8 + if not "hw_serial_stopbits" in self.config: + self.config["hw_serial_stopbits"] = 1 + if not "hw_serial_parity" in self.config: + self.config["hw_serial_parity"] = "none" - if not "telemetry_enabled" in self.config: self.config["telemetry_enabled"] = False - if not "telemetry_collector" in self.config: self.config["telemetry_collector"] = None - if not "telemetry_send_to_trusted" in self.config: self.config["telemetry_send_to_trusted"] = False - if not "telemetry_send_to_collector" in self.config: self.config["telemetry_send_to_collector"] = False - if not "telemetry_request_from_collector" in self.config: self.config["telemetry_request_from_collector"] = False - if not "telemetry_send_interval" in self.config: self.config["telemetry_send_interval"] = 43200 - if not "telemetry_request_interval" in self.config: self.config["telemetry_request_interval"] = 43200 - if not "telemetry_collector_enabled" in self.config: self.config["telemetry_collector_enabled"] = False - if not "telemetry_to_mqtt" in self.config: self.config["telemetry_to_mqtt"] = False - if not "telemetry_mqtt_host" in self.config: self.config["telemetry_mqtt_host"] = None - if not "telemetry_mqtt_port" in self.config: self.config["telemetry_mqtt_port"] = None - if not "telemetry_mqtt_user" in self.config: self.config["telemetry_mqtt_user"] = None - if not "telemetry_mqtt_pass" in self.config: self.config["telemetry_mqtt_pass"] = None - if not "telemetry_mqtt_validate_ssl" in self.config: self.config["telemetry_mqtt_validate_ssl"] = False + if not "telemetry_enabled" in self.config: + self.config["telemetry_enabled"] = False + if not "telemetry_collector" in self.config: + self.config["telemetry_collector"] = None + if not "telemetry_send_to_trusted" in self.config: + self.config["telemetry_send_to_trusted"] = False + if not "telemetry_send_to_collector" in self.config: + self.config["telemetry_send_to_collector"] = False + if not "telemetry_request_from_collector" in self.config: + self.config["telemetry_request_from_collector"] = False + if not "telemetry_send_interval" in self.config: + self.config["telemetry_send_interval"] = 43200 + if not "telemetry_request_interval" in self.config: + self.config["telemetry_request_interval"] = 43200 + if not "telemetry_collector_enabled" in self.config: + self.config["telemetry_collector_enabled"] = False + if not "telemetry_to_mqtt" in self.config: + self.config["telemetry_to_mqtt"] = False + if not "telemetry_mqtt_host" in self.config: + self.config["telemetry_mqtt_host"] = None + if not "telemetry_mqtt_port" in self.config: + self.config["telemetry_mqtt_port"] = None + if not "telemetry_mqtt_user" in self.config: + self.config["telemetry_mqtt_user"] = None + if not "telemetry_mqtt_pass" in self.config: + self.config["telemetry_mqtt_pass"] = None + if not "telemetry_mqtt_validate_ssl" in self.config: + self.config["telemetry_mqtt_validate_ssl"] = False - if not "telemetry_icon" in self.config: self.config["telemetry_icon"] = SidebandCore.DEFAULT_APPEARANCE[0] - if not "telemetry_fg" in self.config: self.config["telemetry_fg"] = SidebandCore.DEFAULT_APPEARANCE[1] - if not "telemetry_bg" in self.config: self.config["telemetry_bg"] = SidebandCore.DEFAULT_APPEARANCE[2] - if not "telemetry_send_appearance" in self.config: self.config["telemetry_send_appearance"] = True - if not "telemetry_display_trusted_only" in self.config: self.config["telemetry_display_trusted_only"] = False - if not "display_style_from_all" in self.config: self.config["display_style_from_all"] = True - if not "telemetry_receive_trusted_only" in self.config: self.config["telemetry_receive_trusted_only"] = False + if not "telemetry_icon" in self.config: + self.config["telemetry_icon"] = SidebandCore.DEFAULT_APPEARANCE[0] + if not "telemetry_fg" in self.config: + self.config["telemetry_fg"] = SidebandCore.DEFAULT_APPEARANCE[1] + if not "telemetry_bg" in self.config: + self.config["telemetry_bg"] = SidebandCore.DEFAULT_APPEARANCE[2] + if not "telemetry_send_appearance" in self.config: + self.config["telemetry_send_appearance"] = True + if not "telemetry_display_trusted_only" in self.config: + self.config["telemetry_display_trusted_only"] = False + if not "display_style_from_all" in self.config: + self.config["display_style_from_all"] = True + if not "telemetry_receive_trusted_only" in self.config: + self.config["telemetry_receive_trusted_only"] = False - if not "telemetry_send_all_to_collector" in self.config: self.config["telemetry_send_all_to_collector"] = False - if not "telemetry_use_propagation_only" in self.config: self.config["telemetry_use_propagation_only"] = False - if not "telemetry_try_propagation_on_fail" in self.config: self.config["telemetry_try_propagation_on_fail"] = True - if not "telemetry_requests_only_send_latest" in self.config: self.config["telemetry_requests_only_send_latest"] = True - if not "telemetry_allow_requests_from_trusted" in self.config: self.config["telemetry_allow_requests_from_trusted"] = False - if not "telemetry_allow_requests_from_anyone" in self.config: self.config["telemetry_allow_requests_from_anyone"] = False + if not "telemetry_send_all_to_collector" in self.config: + self.config["telemetry_send_all_to_collector"] = False + if not "telemetry_use_propagation_only" in self.config: + self.config["telemetry_use_propagation_only"] = False + if not "telemetry_try_propagation_on_fail" in self.config: + self.config["telemetry_try_propagation_on_fail"] = True + if not "telemetry_requests_only_send_latest" in self.config: + self.config["telemetry_requests_only_send_latest"] = True + if not "telemetry_allow_requests_from_trusted" in self.config: + self.config["telemetry_allow_requests_from_trusted"] = False + if not "telemetry_allow_requests_from_anyone" in self.config: + self.config["telemetry_allow_requests_from_anyone"] = False - if not "telemetry_s_location" in self.config: self.config["telemetry_s_location"] = False - if not "telemetry_s_battery" in self.config: self.config["telemetry_s_battery"] = False - if not "telemetry_s_pressure" in self.config: self.config["telemetry_s_pressure"] = False - if not "telemetry_s_temperature" in self.config: self.config["telemetry_s_temperature"] = False - if not "telemetry_s_humidity" in self.config: self.config["telemetry_s_humidity"] = False - if not "telemetry_s_magnetic_field" in self.config: self.config["telemetry_s_magnetic_field"] = False - if not "telemetry_s_ambient_light" in self.config: self.config["telemetry_s_ambient_light"] = False - if not "telemetry_s_gravity" in self.config: self.config["telemetry_s_gravity"] = False - if not "telemetry_s_angular_velocity" in self.config: self.config["telemetry_s_angular_velocity"] = False - if not "telemetry_s_acceleration" in self.config: self.config["telemetry_s_acceleration"] = False - if not "telemetry_s_proximity" in self.config: self.config["telemetry_s_proximity"] = False - if not "telemetry_s_rns_transport" in self.config: self.config["telemetry_s_rns_transport"] = False - if not "telemetry_s_fixed_location" in self.config: self.config["telemetry_s_fixed_location"] = False - if not "telemetry_s_fixed_latlon" in self.config: self.config["telemetry_s_fixed_latlon"] = [0.0, 0.0] - if not "telemetry_s_fixed_altitude" in self.config: self.config["telemetry_s_fixed_altitude"] = 0.0 - if not "telemetry_s_information" in self.config: self.config["telemetry_s_information"] = False - if not "telemetry_s_information_text" in self.config: self.config["telemetry_s_information_text"] = "" + if not "telemetry_s_location" in self.config: + self.config["telemetry_s_location"] = False + if not "telemetry_s_battery" in self.config: + self.config["telemetry_s_battery"] = False + if not "telemetry_s_pressure" in self.config: + self.config["telemetry_s_pressure"] = False + if not "telemetry_s_temperature" in self.config: + self.config["telemetry_s_temperature"] = False + if not "telemetry_s_humidity" in self.config: + self.config["telemetry_s_humidity"] = False + if not "telemetry_s_magnetic_field" in self.config: + self.config["telemetry_s_magnetic_field"] = False + if not "telemetry_s_ambient_light" in self.config: + self.config["telemetry_s_ambient_light"] = False + if not "telemetry_s_gravity" in self.config: + self.config["telemetry_s_gravity"] = False + if not "telemetry_s_angular_velocity" in self.config: + self.config["telemetry_s_angular_velocity"] = False + if not "telemetry_s_acceleration" in self.config: + self.config["telemetry_s_acceleration"] = False + if not "telemetry_s_proximity" in self.config: + self.config["telemetry_s_proximity"] = False + if not "telemetry_s_rns_transport" in self.config: + self.config["telemetry_s_rns_transport"] = False + if not "telemetry_s_fixed_location" in self.config: + self.config["telemetry_s_fixed_location"] = False + if not "telemetry_s_fixed_latlon" in self.config: + self.config["telemetry_s_fixed_latlon"] = [0.0, 0.0] + if not "telemetry_s_fixed_altitude" in self.config: + self.config["telemetry_s_fixed_altitude"] = 0.0 + if not "telemetry_s_information" in self.config: + self.config["telemetry_s_information"] = False + if not "telemetry_s_information_text" in self.config: + self.config["telemetry_s_information_text"] = "" - if not "service_plugins_enabled" in self.config: self.config["service_plugins_enabled"] = False - if not "command_plugins_enabled" in self.config: self.config["command_plugins_enabled"] = False - if not "command_plugins_path" in self.config: self.config["command_plugins_path"] = None + if not "service_plugins_enabled" in self.config: + self.config["service_plugins_enabled"] = False + if not "command_plugins_enabled" in self.config: + self.config["command_plugins_enabled"] = False + if not "command_plugins_path" in self.config: + self.config["command_plugins_path"] = None - if not "map_history_limit" in self.config: self.config["map_history_limit"] = 7*24*60*60 - if not "map_lat" in self.config: self.config["map_lat"] = 0.0 - if not "map_lon" in self.config: self.config["map_lon"] = 0.0 - if not "map_zoom" in self.config: self.config["map_zoom"] = 3 - if not "map_storage_external" in self.config: self.config["map_storage_external"] = False - if not "map_use_offline" in self.config: self.config["map_use_offline"] = False - if not "map_use_online" in self.config: self.config["map_use_online"] = True - if not "map_layer" in self.config: self.config["map_layer"] = None + if not "map_history_limit" in self.config: + self.config["map_history_limit"] = 7*24*60*60 + if not "map_lat" in self.config: + self.config["map_lat"] = 0.0 + if not "map_lon" in self.config: + self.config["map_lon"] = 0.0 + if not "map_zoom" in self.config: + self.config["map_zoom"] = 3 + if not "map_storage_external" in self.config: + self.config["map_storage_external"] = False + if not "map_use_offline" in self.config: + self.config["map_use_offline"] = False + if not "map_use_online" in self.config: + self.config["map_use_online"] = True + if not "map_layer" in self.config: + self.config["map_layer"] = None - if not "map_storage_path" in self.config: self.config["map_storage_path"] = None - if not "map_storage_file" in self.config: self.config["map_storage_file"] = None + if not "map_storage_path" in self.config: + self.config["map_storage_path"] = None + if not "map_storage_file" in self.config: + self.config["map_storage_file"] = None - if not "voice_enabled" in self.config: self.config["voice_enabled"] = False - if not "voice_output" in self.config: self.config["voice_output"] = None - if not "voice_input" in self.config: self.config["voice_input"] = None - if not "voice_ringer" in self.config: self.config["voice_ringer"] = None - if not "voice_trusted_only" in self.config: self.config["voice_trusted_only"] = False - if not "voice_low_latency" in self.config: self.config["voice_low_latency"] = False + if not "voice_enabled" in self.config: + self.config["voice_enabled"] = False + if not "voice_output" in self.config: + self.config["voice_output"] = None + if not "voice_input" in self.config: + self.config["voice_input"] = None + if not "voice_ringer" in self.config: + self.config["voice_ringer"] = None + if not "voice_trusted_only" in self.config: + self.config["voice_trusted_only"] = False # Make sure we have a database - if not os.path.isfile(self.db_path): self.__db_init() + if not os.path.isfile(self.db_path): + self.__db_init() else: self._db_initstate() self._db_initpersistent() @@ -788,8 +866,6 @@ class SidebandCore(): self._db_upgradetables() self.__db_indices() - self.__load_telemetry_collector_excluded() - def __reload_config(self): RNS.log("Reloading Sideband configuration... ", RNS.LOG_DEBUG) with open(self.config_path, "rb") as config_file: @@ -805,28 +881,21 @@ class SidebandCore(): RNS.log("Error while reloading configuration: "+str(e), RNS.LOG_ERROR) RNS.trace_exception(e) - def __save_config(self, no_thread=False): + def __save_config(self): RNS.log("Saving Sideband configuration...", RNS.LOG_DEBUG) def save_function(): while self.saving_configuration: time.sleep(0.15) try: self.saving_configuration = True - with open(self.config_path, "wb") as config_file: config_file.write(msgpack.packb(self.config)) + with open(self.config_path, "wb") as config_file: + config_file.write(msgpack.packb(self.config)) self.saving_configuration = False - if RNS.vendor.platformutils.is_android(): - boot_toggle_path = os.path.join(self.app_dir, "app_storage", "boot_toggle") - boot_toggle_exists = os.path.isfile(boot_toggle_path) - if self.config["start_at_boot"] == True and not boot_toggle_exists: - with open(boot_toggle_path, "w") as f: f.write("true") - elif self.config["start_at_boot"] == False and boot_toggle_exists: - os.unlink(boot_toggle_path) except Exception as e: self.saving_configuration = False RNS.log("Error while saving Sideband configuration: "+str(e), RNS.LOG_ERROR) - if no_thread: save_function() - else: threading.Thread(target=save_function, daemon=True).start() + threading.Thread(target=save_function, daemon=True).start() if self.is_client: self.setstate("wants.settings_reload", True) @@ -909,7 +978,6 @@ class SidebandCore(): def save_configuration(self): self.__save_config() - if self.is_standalone and not self.is_daemon: self.owner_app.save_window_config() def set_active_propagation_node(self, dest): if dest == None: @@ -946,11 +1014,12 @@ class SidebandCore(): if notifications_permitted: try: if RNS.vendor.platformutils.get_platform() == "android": - if self.is_service: self.owner_service.android_notification(title, content, group=group, context_id=context_id) - else: plyer.notification.notify(title, content, notification_icon=self.notification_icon, context_override=None) + if self.is_service: + self.owner_service.android_notification(title, content, group=group, context_id=context_id) + else: + plyer.notification.notify(title, content, notification_icon=self.notification_icon, context_override=None) else: - if not RNS.vendor.platformutils.is_windows(): plyer.notification.notify(title, content, app_name="Sideband", app_icon=self.icon_48) - else: plyer.notification.notify(title, content, app_name="Sideband", app_icon=self.icon_windows) + plyer.notification.notify(title, content, app_icon=self.icon_32) except Exception as e: RNS.log("An error occurred while posting a notification to the operating system: {e}", RNS.LOG_ERROR) @@ -1298,13 +1367,13 @@ class SidebandCore(): else: self.setstate(f"telemetry.{RNS.hexrep(message.destination_hash, delimit=False)}.request_sending", False) - def _service_request_latest_telemetry(self, from_addr=None, is_collector_request=False): + def _service_request_latest_telemetry(self, from_addr=None): if not RNS.vendor.platformutils.is_android(): return False else: if self.is_client: try: - return self.service_rpc_request({"request_latest_telemetry": {"from_addr": from_addr, "is_collector_request": is_collector_request}}) + return self.service_rpc_request({"request_latest_telemetry": {"from_addr": from_addr}}) except Exception as e: RNS.log("Error while requesting latest telemetry over RPC: "+str(e), RNS.LOG_DEBUG) @@ -1313,10 +1382,10 @@ class SidebandCore(): else: return False - def request_latest_telemetry(self, from_addr=None, is_livetrack=False, is_collector_request=False): + def request_latest_telemetry(self, from_addr=None, is_livetrack=False): if self.allow_service_dispatch and self.is_client: try: - return self._service_request_latest_telemetry(from_addr, is_collector_request=is_collector_request) + return self._service_request_latest_telemetry(from_addr) except Exception as e: RNS.log("Error requesting latest telemetry: "+str(e), RNS.LOG_ERROR) @@ -1355,7 +1424,7 @@ class SidebandCore(): request_timebase = self.getpersistent(f"telemetry.{RNS.hexrep(from_addr, delimit=False)}.timebase") or now - self.telemetry_request_max_history lxm_fields = { LXMF.FIELD_COMMANDS: [ - {Commands.TELEMETRY_REQUEST: [request_timebase, is_collector_request]}, + {Commands.TELEMETRY_REQUEST: request_timebase}, ]} lxm = LXMF.LXMessage(dest, source, "", desired_method=desired_method, fields = lxm_fields, include_ticket=True) @@ -1451,7 +1520,7 @@ class SidebandCore(): else: return False - def send_latest_telemetry(self, to_addr=None, stream=None, is_authorized_telemetry_request=False, is_collector_response=False): + def send_latest_telemetry(self, to_addr=None, stream=None, is_authorized_telemetry_request=False): if self.allow_service_dispatch and self.is_client: try: return self._service_send_latest_telemetry(to_addr, stream, is_authorized_telemetry_request) @@ -1493,7 +1562,7 @@ class SidebandCore(): else: desired_method = LXMF.LXMessage.DIRECT - lxm_fields = self.get_message_fields(to_addr, is_authorized_telemetry_request=is_authorized_telemetry_request, signal_already_sent=True, is_collector_response=is_collector_response) + lxm_fields = self.get_message_fields(to_addr, is_authorized_telemetry_request=is_authorized_telemetry_request, signal_already_sent=True) if lxm_fields == False and stream == None: return "already_sent" @@ -1643,9 +1712,6 @@ class SidebandCore(): def gui_conversation(self): return self.getstate("app.active_conversation") - def service_voice_running(self): - return self.getstate("voice.running") - def setstate(self, prop, val): with self.state_lock: if not self.service_stopped: @@ -1721,14 +1787,13 @@ class SidebandCore(): try: with self.rpc_lock: if self.rpc_connection == None: - self.rpc_connection = multiprocessing.connection.Client(self.rpc_addr, family="AF_UNIX", authkey=self.rpc_key) + self.rpc_connection = multiprocessing.connection.Client(self.rpc_addr, authkey=self.rpc_key) self.rpc_connection.send(request) response = self.rpc_connection.recv() return response except Exception as e: if not type(e) == ConnectionRefusedError: - self.rpc_connection = None RNS.log(f"An error occurred while executing the service RPC request: {request}", RNS.LOG_ERROR) RNS.log(f"The contained exception was: {e}", RNS.LOG_ERROR) @@ -1795,12 +1860,12 @@ class SidebandCore(): mr = self.message_router oh = destination_hash ol = None - if oh in mr.direct_links and mr.direct_links[oh].status == RNS.Link.ACTIVE: + if oh in mr.direct_links: ol = mr.direct_links[oh] elif oh in mr.backchannel_links: ol = mr.backchannel_links[oh] - if ol != None and ol.status == RNS.Link.ACTIVE: + if ol != None: ler = ol.get_establishment_rate() if ler: return ler @@ -1830,12 +1895,12 @@ class SidebandCore(): mr = self.message_router oh = destination_hash ol = None - if oh in mr.direct_links and mr.direct_links[oh].status == RNS.Link.ACTIVE: + if oh in mr.direct_links: ol = mr.direct_links[oh] elif oh in mr.backchannel_links: ol = mr.backchannel_links[oh] - if ol != None and ol.status == RNS.Link.ACTIVE: + if ol != None: return ol.get_mtu() return None @@ -1863,12 +1928,12 @@ class SidebandCore(): mr = self.message_router oh = destination_hash ol = None - if oh in mr.direct_links and mr.direct_links[oh].status == RNS.Link.ACTIVE: + if oh in mr.direct_links: ol = mr.direct_links[oh] elif oh in mr.backchannel_links: ol = mr.backchannel_links[oh] - if ol != None and ol.status == RNS.Link.ACTIVE: + if ol != None: return ol.get_expected_rate() return None @@ -1891,47 +1956,15 @@ class SidebandCore(): RNS.log(ed, RNS.LOG_DEBUG) return None - def _get_destination_lmd(self, destination_hash): - try: - mr = self.message_router - oh = destination_hash - ol = None - if oh in mr.direct_links and mr.direct_links[oh].status == RNS.Link.ACTIVE: - ol = mr.direct_links[oh] - elif oh in mr.backchannel_links: - ol = mr.backchannel_links[oh] - - if ol != None and ol.status == RNS.Link.ACTIVE: return ol.get_mode() - - return None - - except Exception as e: - RNS.trace_exception(e) - return None - - def get_destination_lmd(self, destination_hash): - if not RNS.vendor.platformutils.is_android(): - return self._get_destination_lmd(destination_hash) - else: - if self.is_service: - return self._get_destination_lmd(destination_hash) - else: - try: - return self.service_rpc_request({"get_destination_lmd": destination_hash}) - except Exception as e: - ed = "Error while getting destination link mode over RPC: "+str(e) - RNS.log(ed, RNS.LOG_DEBUG) - return None - def __start_rpc_listener(self): try: RNS.log("Starting RPC listener", RNS.LOG_DEBUG) - self.rpc_listener = multiprocessing.connection.Listener(self.rpc_addr, family="AF_UNIX", authkey=self.rpc_key) + self.rpc_listener = multiprocessing.connection.Listener(self.rpc_addr, authkey=self.rpc_key) thread = threading.Thread(target=self.__rpc_loop) thread.daemon = True thread.start() except Exception as e: - RNS.log("Could not start RPC listener on @"+str(self.rpc_addr[1:])+". Terminating now. Clear up anything using the port and try again.", RNS.LOG_ERROR) + RNS.log("Could not start RPC listener on "+str(self.rpc_addr)+". Terminating now. Clear up anything using the port and try again.", RNS.LOG_ERROR) RNS.panic() def __rpc_loop(self): @@ -1963,6 +1996,14 @@ class SidebandCore(): elif "set_ui_recording" in call: self.service_rpc_set_ui_recording(call["set_ui_recording"]) connection.send(True) + elif "get_plugins_info" in call: + connection.send(self._get_plugins_info()) + elif "get_destination_establishment_rate" in call: + connection.send(self._get_destination_establishment_rate(call["get_destination_establishment_rate"])) + elif "get_destination_mtu" in call: + connection.send(self._get_destination_mtu(call["get_destination_mtu"])) + elif "get_destination_edr" in call: + connection.send(self._get_destination_edr(call["get_destination_edr"])) elif "send_message" in call: args = call["send_message"] send_result = self.send_message( @@ -1988,7 +2029,7 @@ class SidebandCore(): connection.send(send_result) elif "request_latest_telemetry" in call: args = call["request_latest_telemetry"] - send_result = self.request_latest_telemetry(args["from_addr"], is_collector_request=args["is_collector_request"]) + send_result = self.request_latest_telemetry(args["from_addr"]) connection.send(send_result) elif "send_latest_telemetry" in call: args = call["send_latest_telemetry"] @@ -1998,46 +2039,30 @@ class SidebandCore(): is_authorized_telemetry_request=args["is_authorized_telemetry_request"] ) connection.send(send_result) - elif "get_plugins_info" in call: connection.send(self._get_plugins_info()) - elif "get_destination_establishment_rate" in call: connection.send(self._get_destination_establishment_rate(call["get_destination_establishment_rate"])) - elif "get_destination_mtu" in call: connection.send(self._get_destination_mtu(call["get_destination_mtu"])) - elif "get_destination_edr" in call: connection.send(self._get_destination_edr(call["get_destination_edr"])) - elif "get_destination_lmd" in call: connection.send(self._get_destination_lmd(call["get_destination_lmd"])) - elif "get_lxm_progress" in call: connection.send(self.get_lxm_progress(call["get_lxm_progress"]["lxm_hash"])) - elif "get_lxm_stamp_cost" in call: connection.send(self.get_lxm_stamp_cost(call["get_lxm_stamp_cost"]["lxm_hash"])) - elif "get_lxm_propagation_cost" in call: connection.send(self.get_lxm_propagation_cost(call["get_lxm_propagation_cost"]["lxm_hash"])) - elif "is_tracking" in call: connection.send(self.is_tracking(call["is_tracking"])) - elif "start_tracking" in call: connection.send(self.start_tracking(object_addr=call["start_tracking"]["object_addr"], interval=call["start_tracking"]["interval"], duration=call["start_tracking"]["duration"])) - elif "stop_tracking" in call: connection.send(self.stop_tracking(object_addr=call["stop_tracking"]["object_addr"])) - elif "get_service_log" in call: connection.send(self.get_service_log()) - elif "start_voice" in call: connection.send(self.start_voice()) - elif "stop_voice" in call: connection.send(self.stop_voice()) - elif "telephone_is_available" in call: connection.send(self.telephone.is_available if self.telephone else False) - elif "telephone_is_in_call" in call: connection.send(self.telephone.is_in_call if self.telephone else False) - elif "telephone_call_is_connecting" in call: connection.send(self.telephone.call_is_connecting if self.telephone else False) - elif "telephone_is_ringing" in call: connection.send(self.telephone.is_ringing if self.telephone else False) - elif "telephone_caller_info" in call: connection.send(self.telephone.caller.hash if self.telephone and self.telephone.caller else None) - elif "telephone_active_profile" in call: connection.send(self.telephone.active_profile if self.telephone else None) - elif "telephone_set_busy" in call: connection.send(self.telephone.set_busy(call["telephone_set_busy"]) if self.telephone else False) - elif "telephone_dial" in call: connection.send(self.telephone.dial(call["telephone_dial"], profile=call["profile"]) if self.telephone else False) - elif "telephone_hangup" in call: connection.send(self.telephone.hangup() if self.telephone else False) - elif "telephone_answer" in call: connection.send(self.telephone.answer() if self.telephone else False) - elif "telephone_set_speaker" in call: connection.send(self.telephone.set_speaker(call["telephone_set_speaker"]) if self.telephone else False) - elif "telephone_set_microphone" in call: connection.send(self.telephone.set_microphone(call["telephone_set_microphone"]) if self.telephone else False) - elif "telephone_set_ringer" in call: connection.send(self.telephone.set_ringer(call["telephone_set_ringer"]) if self.telephone else False) - elif "telephone_set_low_latency_output" in call: connection.send(self.telephone.set_low_latency_output(call["telephone_set_low_latency_output"]) if self.telephone else False) - elif "telephone_announce" in call: connection.send(self.telephone.announce() if self.telephone else False) - elif "telephone_get_call_log" in call: connection.send(self.telephone.get_call_log() if self.telephone else []) - elif "telephone_clear_call_log" in call: connection.send(self.telephone.clear_call_log() if self.telephone else False) - elif "telephone_switch_profile" in call: connection.send(self.telephone.switch_profile(call["telephone_switch_profile"]) if self.telephone else False) + elif "get_lxm_progress" in call: + args = call["get_lxm_progress"] + connection.send(self.get_lxm_progress(args["lxm_hash"])) + elif "get_lxm_stamp_cost" in call: + args = call["get_lxm_stamp_cost"] + connection.send(self.get_lxm_stamp_cost(args["lxm_hash"])) + elif "is_tracking" in call: + connection.send(self.is_tracking(call["is_tracking"])) + elif "start_tracking" in call: + args = call["start_tracking"] + connection.send(self.start_tracking(object_addr=args["object_addr"], interval=args["interval"], duration=args["duration"])) + elif "stop_tracking" in call: + args = call["stop_tracking"] + connection.send(self.stop_tracking(object_addr=args["object_addr"])) else: connection.send(None) except Exception as e: RNS.log("Error on client RPC connection: "+str(e), RNS.LOG_ERROR) RNS.trace_exception(e) - try: connection.close() - except: pass + try: + connection.close() + except: + pass return rpc_client_job @@ -2115,7 +2140,6 @@ class SidebandCore(): dbc = db.cursor() dbc.execute("CREATE TABLE IF NOT EXISTS telemetry (id INTEGER PRIMARY KEY, dest_context BLOB, ts INTEGER, data BLOB)") - dbc.execute("CREATE INDEX IF NOT EXISTS idx_telemetry_ts ON telemetry(ts)") db.commit() def _db_upgradetables(self): @@ -2418,7 +2442,7 @@ class SidebandCore(): if not "appearance" in data_dict: data_dict["appearance"] = None - if from_bulk_telemetry and data_dict["appearance"] != SidebandCore.DEFAULT_APPEARANCE and data_dict["appearance"] != None: + if from_bulk_telemetry and data_dict["appearance"] != SidebandCore.DEFAULT_APPEARANCE: RNS.log("Aborting appearance update from bulk transfer, since conversation already has appearance set: "+str(appearance)+" / "+str(data_dict["appearance"]), RNS.LOG_DEBUG) return @@ -2702,8 +2726,8 @@ class SidebandCore(): announced_name = LXMF.display_name_from_app_data(app_data) announced_cost = self.message_router.get_outbound_stamp_cost(entry[2]) else: - announced_name = LXMF.pn_name_from_app_data(app_data) - announced_cost = LXMF.pn_stamp_cost_from_app_data(app_data) + announced_name = None + announced_cost = None announce = { "dest" : entry[2], "name" : announced_name, @@ -3085,7 +3109,6 @@ class SidebandCore(): tpacked = telemetry_entry[2] appearance = telemetry_entry[3] max_timebase = max(max_timebase, ttstamp) - if self._db_save_telemetry(tsource, tpacked, via = context_dest): RNS.log("Saved telemetry stream entry from "+RNS.prettyhexrep(tsource), RNS.LOG_DEBUG) if appearance != None: @@ -3097,13 +3120,7 @@ class SidebandCore(): else: RNS.log("Received telemetry stream field with no data: "+str(lxm.fields[LXMF.FIELD_TELEMETRY_STREAM]), RNS.LOG_DEBUG) - attach_msg = False - if lxm.fields != None and len(lxm.fields) > 0: - if LXMF.FIELD_IMAGE in lxm.fields: attach_msg = True - if LXMF.FIELD_AUDIO in lxm.fields: attach_msg = True - if LXMF.FIELD_FILE_ATTACHMENTS in lxm.fields: attach_msg = True - - if own_command or len(lxm.content) != 0 or len(lxm.title) != 0 or attach_msg: + if own_command or len(lxm.content) != 0 or len(lxm.title) != 0: with self.db_lock: db = self.__db_connect() dbc = db.cursor() @@ -3251,20 +3268,12 @@ class SidebandCore(): self.setstate("app.flags.last_telemetry", time.time()) def mqtt_handle_telemetry(self, context_dest, telemetry): - with self.mqtt_handle_lock: - if hasattr(self, "last_mqtt_recycle") and time.time() > self.last_mqtt_recycle + 60*4: - self.mqtt.stop() - self.mqtt.client = None - self.mqtt = None - gc.collect() + if self.mqtt == None: + self.mqtt = MQTT() - if self.mqtt == None: - self.mqtt = MQTT() - self.last_mqtt_recycle = time.time() - - self.mqtt.set_server(self.config["telemetry_mqtt_host"], self.config["telemetry_mqtt_port"]) - self.mqtt.set_auth(self.config["telemetry_mqtt_user"], self.config["telemetry_mqtt_pass"]) - self.mqtt.handle(context_dest, telemetry) + self.mqtt.set_server(self.config["telemetry_mqtt_host"], self.config["telemetry_mqtt_port"]) + self.mqtt.set_auth(self.config["telemetry_mqtt_user"], self.config["telemetry_mqtt_pass"]) + self.mqtt.handle(context_dest, telemetry) def update_telemetry(self): try: @@ -3474,13 +3483,23 @@ class SidebandCore(): if hasattr(self, "interface_rnode") and self.interface_rnode != None: if len(self.interface_rnode.hw_errors) > 0: - if self.interface_rnode.hw_errors[0]["error"] == self.ERROR_INVALID_BLE_MTU: - self.setstate("wants.rnode_ble_reset", True) - else: - self.setpersistent("runtime.errors.rnode", self.interface_rnode.hw_errors[0]) - + self.setpersistent("runtime.errors.rnode", self.interface_rnode.hw_errors[0]) self.interface_rnode.hw_errors = [] + # if not self.interface_rnode_adding: + # RNS.log("Hardware error on RNodeInterface, scheduling re-init", RNS.LOG_DEBUG) + # if self.interface_rnode in RNS.Transport.interfaces: + # RNS.Transport.interfaces.remove(self.interface_rnode) + # del self.interface_rnode + # self.interface_rnode = None + # self.interface_rnode_adding = True + # def job(): + # self.__add_rnodeinterface(delay=5) + # if self.config["start_announce"] == True: + # time.sleep(12) + # self.lxmf_announce(attached_interface=self.interface_rnode) + # threading.Thread(target=job, daemon=True).start() + if (now - last_multicast_lock_check > 120): RNS.log("Checking multicast and wake locks", RNS.LOG_DEBUG) self.owner_service.take_locks() @@ -3506,6 +3525,7 @@ class SidebandCore(): if not self.interface_local.had_peers and have_peers: RNS.log("Peers became reachable on the interface "+str(self.interface_local), RNS.LOG_DEBUG) needs_if_change_announce = True + announce_attached_interface = self.interface_local announce_delay = 10 if self.interface_local.had_peers and not have_peers: @@ -3621,18 +3641,6 @@ class SidebandCore(): if self.interface_serial.port != target_device["port"]: RNS.log("Updating serial device to "+str(target_device)) self.interface_serial.port = target_device["port"] - - if self.interface_weave != None and not self.interface_weave.online: - self.owner_app.discover_usb_devices() - last_usb_discovery = time.time() - - if hasattr(self.owner_app, "usb_devices") and self.owner_app.usb_devices != None: - if len(self.owner_app.usb_devices) > 0: - target_device = self.owner_app.usb_devices[0] - if self.interface_weave.port != target_device["port"]: - RNS.log("Updating Weave device to "+str(target_device)) - self.interface_weave.port = target_device["port"] - self.interface_weave.connection.port = target_device["port"] if self.interface_modem != None and not self.interface_modem.online: self.owner_app.discover_usb_devices() @@ -3734,7 +3742,7 @@ class SidebandCore(): if now > last_request_timebase+request_interval: try: RNS.log("Initiating telemetry request to collector", RNS.LOG_DEBUG) - self.request_latest_telemetry(from_addr=self.config["telemetry_collector"], is_collector_request=True) + self.request_latest_telemetry(from_addr=self.config["telemetry_collector"]) except Exception as e: RNS.log("An error occurred while requesting a telemetry update from collector. The contained exception was: "+str(e), RNS.LOG_ERROR) @@ -3782,14 +3790,8 @@ class SidebandCore(): if self.is_standalone or self.is_client: if self.config["telemetry_enabled"]: self.run_telemetry() - if self.config["voice_enabled"]: - if not RNS.vendor.platformutils.is_android(): self.start_voice() - else: - from .voice import ReticulumTelephoneProxy - self.telephone = ReticulumTelephoneProxy(owner=self) - - elif self.is_service: if self.config["voice_enabled"]: self.start_voice() + elif self.is_service: self.run_service_telemetry() def __add_localinterface(self, delay=None): @@ -3844,58 +3846,6 @@ class SidebandCore(): self.interface_local = None self.interface_local_adding = False - def __add_weaveinterface(self, delay=None): - self.interface_weave_adding = True - if delay: time.sleep(delay) - try: - RNS.log("Adding Weave Interface...", RNS.LOG_DEBUG) - target_device = None - - # TODO: Add more intelligent selection here - if len(self.owner_app.usb_devices) > 0: target_device = self.owner_app.usb_devices[0] - - if target_device != None: target_port = target_device["port"] - else: target_port = None - - if self.config["connect_weave_ifac_netname"] == "": ifac_netname = None - else: ifac_netname = self.config["connect_weave_ifac_netname"] - - if self.config["connect_weave_ifac_passphrase"] == "": ifac_netkey = None - else: ifac_netkey = self.config["connect_weave_ifac_passphrase"] - - interface_config = { "name": "WeaveInterface", - "port": target_port } - - weaveinterface = RNS.Interfaces.WeaveInterface.WeaveInterface(RNS.Transport, interface_config) - weaveinterface.OUT = True - - if RNS.Reticulum.transport_enabled(): - if_mode = Interface.Interface.MODE_FULL - if self.config["connect_ifmode_weave"] == "gateway": - if_mode = Interface.Interface.MODE_GATEWAY - elif self.config["connect_ifmode_weave"] == "access point": - if_mode = Interface.Interface.MODE_ACCESS_POINT - elif self.config["connect_ifmode_weave"] == "roaming": - if_mode = Interface.Interface.MODE_ROAMING - elif self.config["connect_ifmode_weave"] == "boundary": - if_mode = Interface.Interface.MODE_BOUNDARY - else: - if_mode = None - - self.reticulum._add_interface(weaveinterface, mode = if_mode, ifac_netname = ifac_netname, ifac_netkey = ifac_netkey) - self.interface_weave = weaveinterface - self.interface_weave_adding = False - - if weaveinterface != None: - if len(weaveinterface.hw_errors) > 0: - self.setpersistent("startup.errors.weave", weaveinterface.hw_errors[0]) - - except Exception as e: - RNS.log("Error while adding Weave Interface. The contained exception was: "+str(e)) - RNS.trace_exception(e) - self.interface_weave = None - self.interface_weave_adding = False - def __add_rnodeinterface(self, delay=None): self.interface_rnode_adding = True if delay: @@ -3917,7 +3867,6 @@ class SidebandCore(): bt_device_name = None rnode_allow_bluetooth = False rnode_allow_ble = False - rnode_allow_tcp = False if self.getpersistent("permissions.bluetooth"): if self.config["hw_rnode_bluetooth"]: RNS.log("Allowing RNode bluetooth", RNS.LOG_DEBUG) @@ -3940,19 +3889,25 @@ class SidebandCore(): RNS.log("Disallowing RNode bluetooth due to missing permission", RNS.LOG_DEBUG) rnode_allow_bluetooth = False - if self.config["hw_rnode_tcp"] and self.config["hw_rnode_tcp_host"]: - rnode_allow_bluetooth = False - rnode_allow_ble = False - rnode_allow_tcp = True + if self.config["connect_rnode_ifac_netname"] == "": + ifac_netname = None + else: + ifac_netname = self.config["connect_rnode_ifac_netname"] - if self.config["connect_rnode_ifac_netname"] == "": ifac_netname = None - else: ifac_netname = self.config["connect_rnode_ifac_netname"] - if self.config["connect_rnode_ifac_passphrase"] == "": ifac_netkey = None - else: ifac_netkey = self.config["connect_rnode_ifac_passphrase"] - if self.config["hw_rnode_atl_short"] == "": atl_short = None - else: atl_short = self.config["hw_rnode_atl_short"] - if self.config["hw_rnode_atl_long"] == "": atl_long = None - else: atl_long = self.config["hw_rnode_atl_long"] + if self.config["connect_rnode_ifac_passphrase"] == "": + ifac_netkey = None + else: + ifac_netkey = self.config["connect_rnode_ifac_passphrase"] + + if self.config["hw_rnode_atl_short"] == "": + atl_short = None + else: + atl_short = self.config["hw_rnode_atl_short"] + + if self.config["hw_rnode_atl_long"] == "": + atl_long = None + else: + atl_long = self.config["hw_rnode_atl_long"] interface_config = None if rnode_allow_ble: @@ -3976,28 +3931,6 @@ class SidebandCore(): "ble_addr": None, } - elif rnode_allow_tcp: - interface_config = { - "name": "RNodeInterface", - "port": None, - "frequency": self.config["hw_rnode_frequency"], - "bandwidth": self.config["hw_rnode_bandwidth"], - "txpower": self.config["hw_rnode_tx_power"], - "spreadingfactor": self.config["hw_rnode_spreading_factor"], - "codingrate": self.config["hw_rnode_coding_rate"], - "flow_control": False, - "id_interval": self.config["hw_rnode_beaconinterval"], - "id_callsign": self.config["hw_rnode_beacondata"], - "st_alock": atl_short, - "lt_alock": atl_long, - "allow_bluetooth": False, - "target_device_name": None, - "force_ble": False, - "ble_name": None, - "ble_addr": None, - "tcp_host": self.config["hw_rnode_tcp_host"], - } - else: interface_config = { "name": "RNodeInterface", @@ -4024,12 +3957,16 @@ class SidebandCore(): if RNS.Reticulum.transport_enabled(): if_mode = Interface.Interface.MODE_FULL - if self.config["connect_ifmode_rnode"] == "gateway": if_mode = Interface.Interface.MODE_GATEWAY - elif self.config["connect_ifmode_rnode"] == "access point": if_mode = Interface.Interface.MODE_ACCESS_POINT - elif self.config["connect_ifmode_rnode"] == "roaming": if_mode = Interface.Interface.MODE_ROAMING - elif self.config["connect_ifmode_rnode"] == "boundary": if_mode = Interface.Interface.MODE_BOUNDARY - - else: if_mode = None + if self.config["connect_ifmode_rnode"] == "gateway": + if_mode = Interface.Interface.MODE_GATEWAY + elif self.config["connect_ifmode_rnode"] == "access point": + if_mode = Interface.Interface.MODE_ACCESS_POINT + elif self.config["connect_ifmode_rnode"] == "roaming": + if_mode = Interface.Interface.MODE_ROAMING + elif self.config["connect_ifmode_rnode"] == "boundary": + if_mode = Interface.Interface.MODE_BOUNDARY + else: + if_mode = None self.reticulum._add_interface(rnodeinterface, mode = if_mode, ifac_netname = ifac_netname, ifac_netkey = ifac_netkey) self.interface_rnode = rnodeinterface @@ -4057,9 +3994,10 @@ class SidebandCore(): def _reticulum_log_debug(self, debug=False): self.log_verbose = debug - if self.log_quiet: selected_level = 0 - elif self.log_verbose: selected_level = 6 - else: selected_level = 2 + if self.log_verbose: + selected_level = 6 + else: + selected_level = 2 RNS.loglevel = selected_level if self.is_client: @@ -4073,38 +4011,8 @@ class SidebandCore(): def get_log(self): return "\n".join(self.log_deque) - def _service_get_service_log(self): - if not RNS.vendor.platformutils.is_android(): - return False - else: - if self.is_client: - try: return self.service_rpc_request({"get_service_log": True }) - except Exception as e: - RNS.log("Error while sending message over RPC: "+str(e), RNS.LOG_DEBUG) - RNS.trace_exception(e) - return False - else: - return False - - def get_service_log(self): - if self.allow_service_dispatch and self.is_client: - try: return self._service_get_service_log() - - except Exception as e: - RNS.log("Error while getting service log: "+str(e), RNS.LOG_ERROR) - RNS.trace_exception(e) - return False - - else: - try: return "\n".join(self.log_deque) - except Exception as e: - RNS.log("An error occurred while getting message transfer stamp cost: "+str(e), RNS.LOG_ERROR) - return None - def __start_jobs_immediate(self): - if self.log_quiet: - selected_level = 0 - elif self.log_verbose: + if self.log_verbose: selected_level = 6 else: selected_level = 2 @@ -4112,15 +4020,11 @@ class SidebandCore(): self.setstate("init.loadingstate", "Substantiating Reticulum") try: - if RNS.vendor.platformutils.is_android() and self.config["connect_share_instance"] == True: - self.reticulum = RNS.Reticulum(configdir=self.rns_configdir, loglevel=selected_level, logdest=self._log_handler, shared_instance_type="tcp") - else: - self.reticulum = RNS.Reticulum(configdir=self.rns_configdir, loglevel=selected_level, logdest=self._log_handler) - + self.reticulum = RNS.Reticulum(configdir=self.rns_configdir, loglevel=selected_level, logdest=self._log_handler) if RNS.vendor.platformutils.is_android(): if self.is_service: - if os.path.isfile(os.path.join(self.rns_configdir, "config_template_invalid")): - os.unlink(os.path.join(self.rns_configdir, "config_template_invalid")) + if os.path.isfile(self.rns_configdir+"/config_template_invalid"): + os.unlink(self.rns_configdir+"/config_template_invalid") else: pass @@ -4149,7 +4053,6 @@ class SidebandCore(): self.interface_tcp = None self.interface_i2p = None self.interface_rnode = None - self.interface_weave = None self.interface_modem = None self.interface_serial = None @@ -4183,13 +4086,13 @@ class SidebandCore(): ifac_size = None interface_config = { - "name": "TCP Client", + "name": "TCPClientInterface", "target_host": tcp_host, "target_port": tcp_port, "kiss_framing": False, "i2p_tunneled": False, } - tcpinterface = RNS.Interfaces.BackboneInterface.BackboneClientInterface(RNS.Transport, interface_config) + tcpinterface = RNS.Interfaces.TCPInterface.TCPClientInterface(RNS.Transport, interface_config) tcpinterface.OUT = True if RNS.Reticulum.transport_enabled(): @@ -4272,10 +4175,6 @@ class SidebandCore(): self.setstate("init.loadingstate", "Starting RNode") self.__add_rnodeinterface() - if self.config["connect_weave"]: - self.setstate("init.loadingstate", "Starting Weave") - self.__add_weaveinterface() - elif self.config["connect_serial"]: self.setstate("init.loadingstate", "Starting Serial Interface") try: @@ -4440,16 +4339,17 @@ class SidebandCore(): if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: if hasattr(message, "stamp_generation_failed") and message.stamp_generation_failed == True: RNS.log(f"Could not send {message} due to a stamp generation failure", RNS.LOG_ERROR) - if not no_display: self.lxm_ingest(message, originator=True) + if not no_display: + self.lxm_ingest(message, originator=True) else: RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) message.try_propagation_on_fail = None message.delivery_attempts = 0 - if hasattr(message, "next_delivery_attempt"): del message.next_delivery_attempt + if hasattr(message, "next_delivery_attempt"): + del message.next_delivery_attempt message.packed = None message.desired_method = LXMF.LXMessage.PROPAGATED self._db_message_set_method(message.hash, LXMF.LXMessage.PROPAGATED) - self._db_message_set_state(message.hash, LXMF.LXMessage.OUTBOUND) self.message_router.handle_outbound(message) else: if not no_display: @@ -4465,7 +4365,7 @@ class SidebandCore(): except Exception as e: RNS.log("Error while setting last successul telemetry timebase for "+RNS.prettyhexrep(message.destination_hash), RNS.LOG_DEBUG) - def get_message_fields(self, context_dest, telemetry_update=False, is_authorized_telemetry_request=False, signal_already_sent=False, is_collector_response=False): + def get_message_fields(self, context_dest, telemetry_update=False, is_authorized_telemetry_request=False, signal_already_sent=False): fields = {} send_telemetry = (telemetry_update == True) or (self.should_send_telemetry(context_dest) or is_authorized_telemetry_request) send_appearance = self.config["telemetry_send_appearance"] or send_telemetry @@ -4474,10 +4374,7 @@ class SidebandCore(): telemeter = Telemeter.from_packed(self.latest_packed_telemetry) telemetry_timebase = telemeter.read_all()["time"]["utc"] last_success_tb = (self.getpersistent(f"telemetry.{RNS.hexrep(context_dest, delimit=False)}.last_send_success_timebase") or 0) - if is_collector_response and self.lxmf_destination.hash in self.telemetry_response_excluded: - RNS.log("Not embedding own telemetry collector response since own destination hash is excluded", RNS.LOG_DEBUG) - send_telemetry = False - elif telemetry_timebase > last_success_tb: + if telemetry_timebase > last_success_tb: RNS.log("Embedding own telemetry in message since current telemetry is newer than latest successful timebase", RNS.LOG_DEBUG) else: RNS.log("Not embedding own telemetry in message since current telemetry timebase ("+str(telemetry_timebase)+") is not newer than latest successful timebase ("+str(last_success_tb)+")", RNS.LOG_DEBUG) @@ -4589,32 +4486,6 @@ class SidebandCore(): RNS.log("An error occurred while getting message transfer stamp cost: "+str(e), RNS.LOG_ERROR) return None - def _service_get_lxm_propagation_cost(self, lxm_hash): - if not RNS.vendor.platformutils.is_android(): return False - else: - if self.is_client: - try: return self.service_rpc_request({"get_lxm_propagation_cost": { "lxm_hash": lxm_hash } }) - except Exception as e: - RNS.log("Error while sending message over RPC: "+str(e), RNS.LOG_DEBUG) - RNS.trace_exception(e) - return False - - else: return False - - def get_lxm_propagation_cost(self, lxm_hash): - if self.allow_service_dispatch and self.is_client: - try: return self._service_get_lxm_propagation_cost(lxm_hash) - except Exception as e: - RNS.log("Error while getting message transfer stamp cost: "+str(e), RNS.LOG_ERROR) - RNS.trace_exception(e) - return False - - else: - try: return self.message_router.get_outbound_lxm_propagation_stamp_cost(lxm_hash) - except Exception as e: - RNS.log("An error occurred while getting message propagation stamp cost: "+str(e), RNS.LOG_ERROR) - return None - def _service_cancel_message(self, message_id): if not RNS.vendor.platformutils.is_android(): return False @@ -4994,63 +4865,80 @@ class SidebandCore(): def ptt_playback(self, message): ptt_timeout = 60 event_time = time.time() - with self.ptt_player_lock: - time.sleep(0.2) + while hasattr(self, "msg_sound") and self.msg_sound != None and self.msg_sound.playing() and time.time() < event_time+ptt_timeout: + time.sleep(0.1) + time.sleep(0.5) - try: - temp_path = None - audio_field = message.fields[LXMF.FIELD_AUDIO] - if self.last_msg_audio != audio_field[1]: - RNS.log("Reloading audio source", RNS.LOG_DEBUG) - if len(audio_field[1]) > 10: self.last_msg_audio = audio_field[1] + if self.msg_audio == None: + if RNS.vendor.platformutils.is_android(): + from plyer import audio + else: + from sbapp.plyer import audio + + RNS.log("Audio init done") + self.msg_audio = audio + try: + temp_path = None + audio_field = message.fields[LXMF.FIELD_AUDIO] + if self.last_msg_audio != audio_field[1]: + RNS.log("Reloading audio source", RNS.LOG_DEBUG) + if len(audio_field[1]) > 10: + self.last_msg_audio = audio_field[1] + else: + self.last_msg_audio = None + return + + if audio_field[0] == LXMF.AM_OPUS_OGG: + temp_path = self.rec_cache+"/msg.ogg" + with open(temp_path, "wb") as af: + af.write(self.last_msg_audio) + + elif audio_field[0] >= LXMF.AM_CODEC2_700C and audio_field[0] <= LXMF.AM_CODEC2_3200: + temp_path = self.rec_cache+"/msg.ogg" + from sideband.audioproc import samples_to_ogg, decode_codec2, detect_codec2 + + target_rate = 8000 + if RNS.vendor.platformutils.is_linux(): + target_rate = 48000 + + if detect_codec2(): + if samples_to_ogg(decode_codec2(audio_field[1], audio_field[0]), temp_path, input_rate=8000, output_rate=target_rate): + RNS.log("Wrote OGG file to: "+temp_path, RNS.LOG_DEBUG) + else: + RNS.log("OGG write failed", RNS.LOG_DEBUG) else: self.last_msg_audio = None return - - if audio_field[0] == LXMF.AM_OPUS_OGG: - temp_path = os.path.join(self.rec_cache, "ptt_msg.ogg") - with open(temp_path, "wb") as af: af.write(self.last_msg_audio) - - elif audio_field[0] >= LXMF.AM_CODEC2_700C and audio_field[0] <= LXMF.AM_CODEC2_3200: - temp_path = os.path.join(self.rec_cache, "ptt_msg.ogg") - from sideband.audioproc import samples_to_ogg, decode_codec2, detect_codec2 - - target_rate = 48000 - if detect_codec2(): - if samples_to_ogg(decode_codec2(audio_field[1], audio_field[0]), temp_path, input_rate=8000, output_rate=target_rate): RNS.log("Wrote OGG file to: "+temp_path, RNS.LOG_DEBUG) - else: RNS.log("OGG write failed", RNS.LOG_DEBUG) - else: - self.last_msg_audio = None - return - - # Unimplemented audio type - else: pass - - if self.ptt_player == None: - from LXST.Primitives.Players import FilePlayer - self.ptt_player = FilePlayer() - RNS.log("LXMF PTT audio init done", RNS.LOG_DEBUG) - - self.ptt_player.set_source(temp_path) - - if self.ptt_player != None: - RNS.log("Starting playback", RNS.LOG_DEBUG) - self.ptt_player.play() - while self.ptt_player.playing and time.time() < event_time+ptt_timeout: time.sleep(0.25) - if self.ptt_player.playing: self.ptt_player.stop() - time.sleep(0.2) + else: - RNS.log("Playback was requested, but no audio data was loaded for playback", RNS.LOG_ERROR) + # Unimplemented audio type + pass - except Exception as e: - RNS.log("Error while playing message audio:"+str(e)) - RNS.trace_exception(e) + self.msg_sound = self.msg_audio + self.msg_sound._file_path = temp_path + self.msg_sound.reload() + + if self.msg_sound != None: + RNS.log("Starting playback", RNS.LOG_DEBUG) + self.msg_sound.play() + else: + RNS.log("Playback was requested, but no audio data was loaded for playback", RNS.LOG_ERROR) + + except Exception as e: + RNS.log("Error while playing message audio:"+str(e)) + RNS.trace_exception(e) def ptt_event(self, message): def ptt_job(): - while self.ui_recording: time.sleep(0.5) - try: self.ptt_playback(message) - except Exception as e: RNS.log("Error while starting playback for PTT-enabled conversation: "+str(e), RNS.LOG_ERROR) + try: + self.ptt_playback_lock.acquire() + while self.ui_recording: + time.sleep(0.5) + self.ptt_playback(message) + except Exception as e: + RNS.log("Error while starting playback for PTT-enabled conversation: "+str(e), RNS.LOG_ERROR) + finally: + self.ptt_playback_lock.release() threading.Thread(target=ptt_job, daemon=True).start() @@ -5116,7 +5004,8 @@ class SidebandCore(): self.wfile.write(es.encode("utf-8")) else: try: - with open(path.replace("%20", " "), "rb") as f: data = f.read() + with open(path, 'rb') as f: + data = f.read() self.send_response(200) if path.lower().endswith(".apk"): self.send_header("Content-type", "application/vnd.android.package-archive") @@ -5248,19 +5137,11 @@ class SidebandCore(): RNS.log("Handling commands from "+RNS.prettyhexrep(context_dest), RNS.LOG_DEBUG) for command in commands: if Commands.TELEMETRY_REQUEST in command: - if type(command[Commands.TELEMETRY_REQUEST]) == list: - command_timebase = command[Commands.TELEMETRY_REQUEST][0] - enable_collector_request = command[Commands.TELEMETRY_REQUEST][1] - else: - # Handle old request format - command_timebase = command[Commands.TELEMETRY_REQUEST] - enable_collector_request = True - - timebase = int(command_timebase) + timebase = int(command[Commands.TELEMETRY_REQUEST]) RNS.log("Handling telemetry request with timebase "+str(timebase), RNS.LOG_DEBUG) - if self.config["telemetry_collector_enabled"] and enable_collector_request: + if self.config["telemetry_collector_enabled"]: RNS.log(f"Collector requests enabled, returning complete telemetry response for all known objects since {timebase}", RNS.LOG_DEBUG) - self.create_telemetry_collector_response(to_addr=context_dest, timebase=timebase, is_authorized_telemetry_request=True, is_collector_response=True) + self.create_telemetry_collector_response(to_addr=context_dest, timebase=timebase, is_authorized_telemetry_request=True) else: RNS.log("Responding with own latest telemetry", RNS.LOG_DEBUG) self.send_latest_telemetry(to_addr=context_dest) @@ -5296,7 +5177,7 @@ class SidebandCore(): except Exception as e: RNS.log("Error while handling commands: "+str(e), RNS.LOG_ERROR) - def create_telemetry_collector_response(self, to_addr, timebase, is_authorized_telemetry_request=False, is_collector_response=False): + def create_telemetry_collector_response(self, to_addr, timebase, is_authorized_telemetry_request=False): if self.getstate(f"telemetry.{RNS.hexrep(to_addr, delimit=False)}.update_sending") == True: RNS.log("Not sending new telemetry collector response, since an earlier transfer is already in progress", RNS.LOG_DEBUG) return "in_progress" @@ -5308,23 +5189,20 @@ class SidebandCore(): elements = 0; added = 0 telemetry_stream = [] for source in sources: - if source in self.telemetry_response_excluded: - RNS.log(f"Excluding {RNS.prettyhexrep(source)} from collector response", RNS.LOG_DEBUG) - else: - if source != to_addr: - for entry in sources[source]: - elements += 1 - timestamp = entry[0]; packed_telemetry = entry[1] - appearance = self._db_get_appearance(source, raw=True) - te = [source, timestamp, packed_telemetry, appearance] - if only_latest: - if not source in added_sources: - added_sources[source] = True - telemetry_stream.append(te) - added += 1 - else: + if source != to_addr: + for entry in sources[source]: + elements += 1 + timestamp = entry[0]; packed_telemetry = entry[1] + appearance = self._db_get_appearance(source, raw=True) + te = [source, timestamp, packed_telemetry, appearance] + if only_latest: + if not source in added_sources: + added_sources[source] = True telemetry_stream.append(te) added += 1 + else: + telemetry_stream.append(te) + added += 1 if len(telemetry_stream) == 0: RNS.log(f"No new telemetry for request with timebase {timebase}", RNS.LOG_DEBUG) @@ -5332,8 +5210,7 @@ class SidebandCore(): return self.send_latest_telemetry( to_addr=to_addr, stream=telemetry_stream, - is_authorized_telemetry_request=is_authorized_telemetry_request, - is_collector_response=is_collector_response, + is_authorized_telemetry_request=is_authorized_telemetry_request ) @@ -5368,61 +5245,35 @@ class SidebandCore(): elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_FAILED: return "Sync failed" elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: - msgs_dld = self.message_router.propagation_transfer_last_result - duplicates = self.message_router.propagation_transfer_last_duplicates or 0 - new_msgs = msgs_dld-duplicates - nms = "" if new_msgs == 1 else "s" - dms = "" if duplicates == 1 else "s" + new_msgs = self.message_router.propagation_transfer_last_result if new_msgs == 0: - if duplicates == 0: return f"Done, no new messages" - else: return f"Done, no new messages\nDiscarded {duplicates} existing message{dms}" + return "Done, no new messages" else: - if duplicates == 0: return f"Downloaded {new_msgs} new message{nms}" - else: return f"Downloaded {new_msgs} new message{nms}\nDiscarded {duplicates} existing message{dms}" + return "Downloaded "+str(new_msgs)+" new messages" else: return "Unknown" def cleanup(self): if RNS.vendor.platformutils.get_platform() == "android": if not self.reticulum.is_connected_to_shared_instance: - RNS.Reticulum.exit_handler() + RNS.Transport.detach_interfaces() - def _start_voice(self): + def start_voice(self): try: if not self.voice_running: RNS.log("Starting voice service", RNS.LOG_DEBUG) self.voice_running = True - self.setstate("voice.running", self.voice_running) - ringtone_path = os.path.join(self.asset_dir, "audio", "notifications", "soft1.opus") - call_log_path = os.path.join(self.app_dir, "app_storage", "lxst_call_log") from .voice import ReticulumTelephone - self.telephone = ReticulumTelephone(self.identity, owner=self, speaker=self.config["voice_output"], microphone=self.config["voice_input"], ringer=self.config["voice_ringer"], logpath=call_log_path) + self.telephone = ReticulumTelephone(self.identity, owner=self, speaker=self.config["voice_output"], microphone=self.config["voice_input"], ringer=self.config["voice_ringer"]) + ringtone_path = os.path.join(self.asset_dir, "audio", "notifications", "soft1.opus") self.telephone.set_ringtone(ringtone_path) - self.telephone.set_low_latency_output(self.config["voice_low_latency"]) - return True except Exception as e: self.voice_running = False RNS.log(f"An error occurred while starting voice services, the contained exception was: {e}", RNS.LOG_ERROR) RNS.trace_exception(e) - return False - def start_voice(self): - if not RNS.vendor.platformutils.is_android(): return self._start_voice() - else: - if self.is_service: return self._start_voice() - else: - try: - if self.service_rpc_request({"start_voice": True}): - from .voice import ReticulumTelephoneProxy - self.telephone = ReticulumTelephoneProxy(owner=self) - self.voice_running = True - - except Exception as e: - RNS.log("Error while starting voice service over RPC: "+str(e), RNS.LOG_DEBUG) - return False - - def _stop_voice(self): + def stop_voice(self): try: if self.voice_running: RNS.log("Stopping voice service", RNS.LOG_DEBUG) @@ -5432,41 +5283,14 @@ class SidebandCore(): self.telephone = None self.voice_running = False - self.setstate("voice.running", self.voice_running) - return True except Exception as e: RNS.log(f"An error occurred while stopping voice services, the contained exception was: {e}", RNS.LOG_ERROR) RNS.trace_exception(e) - return False - - def stop_voice(self): - if not RNS.vendor.platformutils.is_android(): return self._stop_voice() - else: - if self.is_service: return self._stop_voice() - else: - try: - if self.service_rpc_request({"stop_voice": True}): - self.telephone = None - self.voice_running = False - - except Exception as e: - RNS.log("Error while stopping voice service over RPC: "+str(e), RNS.LOG_DEBUG) - return False def incoming_call(self, remote_identity): display_name = self.voice_display_name(remote_identity.hash) self.setstate("voice.incoming_call", display_name) - if self.gui_foreground() and self.getstate("app.displaying") == "voice_screen": RNS.log("Squelching call notification since voice screen is active", RNS.LOG_DEBUG) - else: self.notify(title="Incoming call", content=f"From {display_name}", group="lxst.telephony.call", context_id="incoming_call") - - def missed_call(self, remote_identity): - display_name = self.voice_display_name(remote_identity.hash) - self.setstate("voice.missed_call", display_name) - self.notify(title="Missed call", content=f"From {display_name}", group="lxst.telephony.call", context_id="incoming_call") - - def ended_call(self, remote_identity): - self.setstate("voice.ongoing_ended", True) rns_config = """# This template is used to generate a # running configuration for Sideband's diff --git a/sbapp/sideband/mqtt.py b/sbapp/sideband/mqtt.py index 57efe00..bed0e6d 100644 --- a/sbapp/sideband/mqtt.py +++ b/sbapp/sideband/mqtt.py @@ -44,14 +44,11 @@ class MQTT(): RNS.log("All MQTT messages processed", RNS.LOG_DEBUG) except Exception as e: - RNS.log(f"An error occurred while running MQTT scheduler jobs: {e}", RNS.LOG_ERROR) + RNS.log("An error occurred while running MQTT scheduler jobs: {e}", RNS.LOG_ERROR) RNS.trace_exception(e) time.sleep(MQTT.SCHEDULER_SLEEP) - try: self.disconnect() - except Exception as e: RNS.log(f"An error occurred while disconnecting MQTT server: {e}", RNS.LOG_ERROR) - RNS.log("Stopped MQTT scheduler", RNS.LOG_DEBUG) def connect_failed(self, client, userdata): @@ -81,9 +78,8 @@ class MQTT(): def disconnect(self): RNS.log("Disconnecting from MQTT server", RNS.LOG_EXTREME) # TODO: Remove debug - if self.client: - self.client.disconnect() - self.client.loop_stop() + self.client.disconnect() + self.client.loop_stop() self.is_connected = False def post_message(self, topic, data): diff --git a/sbapp/sideband/sense.py b/sbapp/sideband/sense.py index 0a22767..34c2e14 100644 --- a/sbapp/sideband/sense.py +++ b/sbapp/sideband/sense.py @@ -120,11 +120,9 @@ class Telemeter(): def stop_all(self): if not self.from_packed: - sensors = self.sensors.copy() - for sensor in sensors: + for sensor in self.sensors: if not sensor == "time": self.sensors[sensor].stop() - del sensors def read(self, sensor): if not self.from_packed: @@ -139,38 +137,31 @@ class Telemeter(): def read_all(self): readings = {} - sensors = self.sensors.copy() - for sensor in sensors: + for sensor in self.sensors: if self.sensors[sensor].active: if not self.from_packed: readings[sensor] = self.sensors[sensor].data else: readings[sensor] = self.sensors[sensor]._data - del sensors return readings def packed(self): packed = {} packed[Sensor.SID_TIME] = int(time.time()) - sensors = self.sensors.copy() - for sensor in sensors: + for sensor in self.sensors: if self.sensors[sensor].active: packed[self.sensors[sensor].sid] = self.sensors[sensor].pack() - - del sensors return umsgpack.packb(packed) def render(self, relative_to=None): rendered = [] - sensors = self.sensors.copy() - for sensor in sensors: + for sensor in self.sensors: s = self.sensors[sensor] if s.active: r = s.render(relative_to) if r: rendered.append(r) - del sensors return rendered def check_permission(self, permission): diff --git a/sbapp/sideband/voice.py b/sbapp/sideband/voice.py index 9e39ced..67a91ca 100644 --- a/sbapp/sideband/voice.py +++ b/sbapp/sideband/voice.py @@ -6,7 +6,6 @@ import time from LXST._version import __version__ from LXST.Primitives.Telephony import Telephone from RNS.vendor.configobj import ConfigObj -import RNS.vendor.umsgpack as msgpack class ReticulumTelephone(): STATE_AVAILABLE = 0x00 @@ -23,9 +22,7 @@ class ReticulumTelephone(): WAIT_TIME = 60 PATH_TIME = 10 - CALL_LOG_KEEP = 14*24*60*60 - - def __init__(self, identity, owner = None, service = False, speaker=None, microphone=None, ringer=None, logpath=None): + def __init__(self, identity, owner = None, service = False, speaker=None, microphone=None, ringer=None): self.identity = identity self.service = service self.owner = owner @@ -39,18 +36,15 @@ class ReticulumTelephone(): self.direction = None self.last_input = None self.first_run = False - self.user_hung_up = False self.ringtone_path = None self.speaker_device = speaker self.microphone_device = microphone self.ringer_device = ringer - self.logpath = logpath - self.call_log = None self.phonebook = {} self.aliases = {} self.names = {} - self.telephone = Telephone(self.identity, ring_time=self.RING_TIME, wait_time=self.WAIT_TIME) + self.telephone = Telephone(self.identity, ring_time=self.RING_TIME, wait_time=self.WAIT_TIME) self.telephone.set_ringing_callback(self.ringing) self.telephone.set_established_callback(self.call_established) self.telephone.set_ended_callback(self.call_ended) @@ -81,25 +75,28 @@ class ReticulumTelephone(): self.telephone.announce(attached_interface=attached_interface) @property - def is_available(self): return self.state == self.STATE_AVAILABLE + def is_available(self): + return self.state == self.STATE_AVAILABLE @property - def is_in_call(self): return self.state == self.STATE_IN_CALL + def is_in_call(self): + return self.state == self.STATE_IN_CALL @property - def is_ringing(self): return self.state == self.STATE_RINGING + def is_ringing(self): + return self.state == self.STATE_RINGING @property - def call_is_connecting(self): return self.state == self.STATE_CONNECTING + def call_is_connecting(self): + return self.state == self.STATE_CONNECTING @property - def hw_is_idle(self): return self.hw_state == self.HW_STATE_IDLE + def hw_is_idle(self): + return self.hw_state == self.HW_STATE_IDLE @property - def hw_is_dialing(self): return self.hw_state == self.HW_STATE_DIAL - - @property - def active_profile(self): return self.telephone.active_profile + def hw_is_dialing(self): + return self.hw_state == self.HW_STATE_DIAL def start(self): if not self.should_run: @@ -111,70 +108,11 @@ class ReticulumTelephone(): self.telephone.teardown() self.telephone = None - def hangup(self): - self.user_hung_up = True - self.telephone.hangup() - + def hangup(self): self.telephone.hangup() def answer(self): self.telephone.answer(self.caller) def set_busy(self, busy): self.telephone.set_busy(busy) - def set_low_latency_output(self, enabled): self.telephone.set_low_latency_output(enabled) - def clear_call_log(self): - self.call_log = [] - try: - with open(self.logpath, "wb") as logfile: logfile.write(msgpack.packb([])) - except Exception as e: raise OSError("Could not clear call log file") - - def get_call_log(self): - if self.call_log: return self.call_log - else: - call_log = [] - try: - if os.path.isfile(self.logpath): - with open(self.logpath, "rb") as logfile: - read_call_log = msgpack.unpackb(logfile.read()) - - for entry in read_call_log: - age = time.time()-entry["time"] - if age < self.CALL_LOG_KEEP: call_log.append(entry) - - except Exception as e: RNS.log(f"Could not read call log file: {e}", RNS.LOG_ERROR) - self.call_log = call_log - return self.call_log - - def log_call(self, event, identity): - if identity: - RNS.log(f"Logging call event {event} for {RNS.prettyhexrep(identity.hash)}", RNS.LOG_DEBUG) - if self.logpath: - try: - if not os.path.isfile(self.logpath): - try: - with open(self.logpath, "wb") as logfile: logfile.write(msgpack.packb([])) - except Exception as e: raise OSError("Could not create call log file") - - call_log = [] - read_call_log = [] - try: - with open(self.logpath, "rb") as logfile: read_call_log = msgpack.unpackb(logfile.read()) - except Exception as e: - RNS.log(f"Error while reading call log file: {e}", RNS.LOG_ERROR) - RNS.log(f"Call log file will be re-created", RNS.LOG_ERROR) - - for entry in read_call_log: - age = time.time()-entry["time"] - if age < self.CALL_LOG_KEEP: call_log.append(entry) - - entry = {"time": time.time(), "event": event, "identity": identity.hash} - call_log.append(entry) - - with open(self.logpath, "wb") as logfile: logfile.write(msgpack.packb(call_log)) - self.call_log = call_log - - except Exception as e: - RNS.log(f"An error occurred while updating call log: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) - - def dial(self, identity_hash, profile=None): + def dial(self, identity_hash): self.last_dialled_identity_hash = identity_hash destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", identity_hash) if RNS.Transport.has_path(destination_hash): @@ -182,22 +120,19 @@ class ReticulumTelephone(): cs = "" if call_hops == 1 else "s" RNS.log(f"Connecting call over {call_hops} hop{cs}...", RNS.LOG_DEBUG) identity = RNS.Identity.recall(destination_hash) - self.call(identity, profile=profile) + self.call(identity) else: return "no_path" def redial(self, args=None): if self.last_dialled_identity_hash: self.dial(self.last_dialled_identity_hash) - def call(self, remote_identity, profile=None): + def call(self, remote_identity): RNS.log(f"Calling {RNS.prettyhexrep(remote_identity.hash)}...", RNS.LOG_DEBUG) self.state = self.STATE_CONNECTING self.caller = remote_identity self.direction = "to" - self.telephone.call(self.caller, profile=profile) - - def switch_profile(self, profile): - self.telephone.switch_profile(profile) + self.telephone.call(self.caller) def ringing(self, remote_identity): if self.hw_state == self.HW_STATE_SLEEP: self.hw_state = self.HW_STATE_IDLE @@ -209,12 +144,6 @@ class ReticulumTelephone(): self.owner.incoming_call(remote_identity) def call_ended(self, remote_identity): - call_was_connecting = self.call_is_connecting - was_ringing = self.is_ringing - was_in_call = self.is_in_call - user_hung_up = self.user_hung_up - self.user_hung_up = False - if self.is_in_call or self.is_ringing or self.call_is_connecting: if self.is_in_call: RNS.log(f"Call with {RNS.prettyhexrep(self.caller.hash)} ended\n", RNS.LOG_DEBUG) if self.is_ringing: RNS.log(f"Call {self.direction} {RNS.prettyhexrep(self.caller.hash)} was not answered\n", RNS.LOG_DEBUG) @@ -222,27 +151,11 @@ class ReticulumTelephone(): self.direction = None self.state = self.STATE_AVAILABLE - if call_was_connecting: - self.log_call("outgoing-failure", remote_identity) - if not user_hung_up: self.owner.setstate("voice.connection_failure", True) - elif was_in_call: - self.log_call("ongoing-ended", remote_identity) - self.owner.ended_call(remote_identity) - elif was_ringing: - self.log_call("incoming-missed", remote_identity) - self.owner.missed_call(remote_identity) - def call_established(self, remote_identity): - call_was_connecting = self.call_is_connecting - was_ringing = self.is_ringing - if self.call_is_connecting or self.is_ringing: self.state = self.STATE_IN_CALL RNS.log(f"Call established with {RNS.prettyhexrep(self.caller.hash)}", RNS.LOG_DEBUG) - if call_was_connecting: self.log_call("outgoing-success", remote_identity) - elif was_ringing: self.log_call("incoming-success", remote_identity) - def __is_allowed(self, identity_hash): if self.owner.config["voice_trusted_only"]: return self.owner.voice_is_trusted(identity_hash) @@ -256,42 +169,3 @@ class ReticulumTelephone(): return False else: return True - -class CallerProxy(): - def __init__(self, hash=None): - self.hash = hash - -class ReticulumTelephoneProxy(): - PATH_TIME = ReticulumTelephone.PATH_TIME - def __init__(self, owner=None): self.owner = owner - - @property - def is_available(self): return self.owner.service_rpc_request({"telephone_is_available": True }) - - @property - def is_in_call(self): return self.owner.service_rpc_request({"telephone_is_in_call": True }) - - @property - def call_is_connecting(self): return self.owner.service_rpc_request({"telephone_call_is_connecting": True }) - - @property - def is_ringing(self): return self.owner.service_rpc_request({"telephone_is_ringing": True }) - - @property - def caller(self): return CallerProxy(hash=self.owner.service_rpc_request({"telephone_caller_info": True })) - - @property - def active_profile(self): return self.owner.service_rpc_request({"telephone_active_profile": True }) - - def set_busy(self, busy): return self.owner.service_rpc_request({"telephone_set_busy": busy }) - def dial(self, dial_target, profile=None): return self.owner.service_rpc_request({"telephone_dial": dial_target, "profile": profile }) - def hangup(self): return self.owner.service_rpc_request({"telephone_hangup": True }) - def answer(self): return self.owner.service_rpc_request({"telephone_answer": True }) - def set_speaker(self, speaker): return self.owner.service_rpc_request({"telephone_set_speaker": speaker }) - def set_microphone(self, microphone): return self.owner.service_rpc_request({"telephone_set_microphone": microphone }) - def set_ringer(self, ringer): return self.owner.service_rpc_request({"telephone_set_ringer": ringer }) - def set_low_latency_output(self, enabled): return self.owner.service_rpc_request({"telephone_set_low_latency_output": enabled}) - def announce(self): return self.owner.service_rpc_request({"telephone_announce": True}) - def get_call_log(self): return self.owner.service_rpc_request({"telephone_get_call_log": True}) - def clear_call_log(self): return self.owner.service_rpc_request({"telephone_clear_call_log": True}) - def switch_profile(self, profile): return self.owner.service_rpc_request({"telephone_switch_profile": profile}) \ No newline at end of file diff --git a/sbapp/ui/announces.py b/sbapp/ui/announces.py index 52ed461..f9bc28d 100644 --- a/sbapp/ui/announces.py +++ b/sbapp/ui/announces.py @@ -127,16 +127,7 @@ class Announces(): ad_text = "[size=22dp]LXMF Peer[/size]\n\n[b]Received[/b] "+ts+"\n[b]Address[/b] "+RNS.prettyhexrep(dest)+"\n[b]Name[/b] "+name+"\n[b]Stamp Cost[/b] "+cost+link_extras if dtype == "lxmf.propagation": - if a_name: - disp_name = multilingual_markup(escape_markup(str(a_name)).encode("utf-8")).decode("utf-8") - disp_name = f"\n[b]Name[/b] {disp_name}" - else: disp_name = "" - if a_cost: - disp_cost = str(a_cost) - disp_cost = f"\n[b]Stamp Cost[/b] {disp_cost}" - else: disp_cost = "" - - ad_text = f"[size=22dp]LXMF Propagation Node[/size]\n\n[b]Received[/b] {ts}\n[b]Address[/b] {RNS.prettyhexrep(dest)+link_extras}{disp_name}{disp_cost}" + ad_text = "[size=22dp]LXMF Propagation Node[/size]\n\n[b]Received[/b] "+ts+"\n[b]Address[/b] "+RNS.prettyhexrep(dest)+link_extras dialog = MDDialog( text=ad_text, @@ -159,9 +150,7 @@ class Announces(): iconl = IconLeftWidget(icon=trust_icon) elif dest_type == "lxmf.propagation": - if a_name: disp_name = multilingual_markup(escape_markup(str(a_name)).encode("utf-8")).decode("utf-8") - else: disp_name = f"Propagation Node" - disp_name = f"{disp_name} {RNS.prettyhexrep(context_dest)}" + disp_name = "Propagation Node "+RNS.prettyhexrep(context_dest) iconl = IconLeftWidget(icon="upload-network") else: diff --git a/sbapp/ui/conversations.py b/sbapp/ui/conversations.py index 54694a2..ac263a5 100644 --- a/sbapp/ui/conversations.py +++ b/sbapp/ui/conversations.py @@ -3,9 +3,8 @@ import time from kivy.metrics import dp,sp from kivy.uix.boxlayout import BoxLayout -from kivy.properties import StringProperty, BooleanProperty, OptionProperty, ColorProperty, Property +from kivy.properties import StringProperty, BooleanProperty from kivymd.uix.list import MDList, IconLeftWidget, IconRightWidget, OneLineAvatarIconListItem -from kivymd.uix.recycleview import MDRecycleView from kivymd.uix.menu import MDDropdownMenu from kivymd.toast import toast from kivy.uix.gridlayout import GridLayout @@ -19,15 +18,6 @@ from kivymd.uix.dialog import MDDialog from kivy.lang.builder import Builder -theme_text_color_options = ( - "Primary", - "Secondary", - "Hint", - "Error", - "Custom", - "ContrastParentBackground", -) - from kivy.utils import escape_markup if RNS.vendor.platformutils.get_platform() == "android": from ui.helpers import multilingual_markup @@ -49,209 +39,12 @@ class ConvSettings(BoxLayout): is_object = BooleanProperty() ptt_enabled = BooleanProperty() -class ConversationEntry(OneLineAvatarIconListItem): - app = None - owner_screen = None - conversation_dropdown = None - voice_dropdown = None - clear_dialog = None - clear_telemetry_dialog = None - delete_dialog = None - - icon = StringProperty() - ti_color = OptionProperty(None, options=theme_text_color_options) - icon_fg = Property(None, allownone=True) - icon_bg = Property(None, allownone=True) - - def __init__(self): - super().__init__() - self.bind(on_release=self.app.conversation_action) - self.ids.left_icon.bind(on_release=self.left_icon_action) - self.ids.right_icon.bind(on_release=self.right_icon_action) - self.__init_menus() - - def __init_menus(self): - dmi_h = 40 - if ConversationEntry.voice_dropdown == None: - dmv_items = [ { "viewclass": "OneLineListItem", "text": "Edit", "height": dp(dmi_h), "on_release": self.edit_action}, - { "text": "Copy Identity Hash", "viewclass": "OneLineListItem", "height": dp(dmi_h), "on_release": self.copy_address_action}, - { "text": "Delete", "viewclass": "OneLineListItem", "height": dp(dmi_h), "on_release": self.delete_conversation_action } ] - - ConversationEntry.voice_dropdown = MDDropdownMenu(caller=None, items=dmv_items, position="auto", width=dp(256), elevation=0, radius=dp(3)) - ConversationEntry.voice_dropdown.effect_cls = ScrollEffect - ConversationEntry.voice_dropdown.md_bg_color = self.app.color_hover - - if ConversationEntry.conversation_dropdown == None: - dm_items = [ { "viewclass": "OneLineListItem", "text": "Edit", "height": dp(dmi_h), "on_release": self.edit_action }, - { "viewclass": "OneLineListItem", "text": "Call", "height": dp(dmi_h), "on_release": self.call_action }, - { "text": "Copy Address", "viewclass": "OneLineListItem", "height": dp(dmi_h), "on_release": self.copy_address_action }, - { "text": "Clear Messages", "viewclass": "OneLineListItem", "height": dp(dmi_h), "on_release": self.clear_messages_action }, - { "text": "Clear Telemetry", "viewclass": "OneLineListItem", "height": dp(dmi_h), "on_release": self.clear_telemetry_action }, - { "text": "Delete Conversation", "viewclass": "OneLineListItem", "height": dp(dmi_h), "on_release": self.delete_conversation_action } ] - - ConversationEntry.conversation_dropdown = MDDropdownMenu(caller=None, items=dm_items, position="auto", width=dp(256), elevation=0, radius=dp(3)) - ConversationEntry.conversation_dropdown.effect_cls = ScrollEffect - ConversationEntry.conversation_dropdown.md_bg_color = self.app.color_hover - - def dispatch_update(self): - self.owner_screen.update() - - def dropdown_dismiss(self): - self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() - - def copy_address_action(self): - context_dest = ConversationEntry.conversation_dropdown.context_dest; caller = ConversationEntry.conversation_dropdown.caller - Clipboard.copy(RNS.hexrep(context_dest, delimit=False)) - self.dropdown_dismiss() - - def edit_action(self): - context_dest = ConversationEntry.conversation_dropdown.context_dest; caller = ConversationEntry.conversation_dropdown.caller - RNS.log(f"Menu action from {caller} for {RNS.prettyhexrep(context_dest)}") - dest = context_dest - try: - t_s = time.time() - cd = self.app.sideband._db_conversation(dest) - disp_name = self.app.sideband.raw_display_name(dest) - is_trusted = self.app.sideband.is_trusted(dest, conv_data=cd) - is_object = self.app.sideband.is_object(dest, conv_data=cd) - ptt_enabled = self.app.sideband.ptt_enabled(dest, conv_data=cd) - send_telemetry = self.app.sideband.should_send_telemetry(dest, conv_data=cd) - allow_requests = self.app.sideband.requests_allowed_from(dest, conv_data=cd) - yes_button = MDRectangleFlatButton(text="Save",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_accept, text_color=self.app.color_accept) - no_button = MDRectangleFlatButton(text="Cancel",font_size=dp(18)) - dialog_content = ConvSettings(disp_name=disp_name, context_dest=RNS.hexrep(dest, delimit=False), trusted=is_trusted, - telemetry=send_telemetry, allow_requests=allow_requests, is_object=is_object, ptt_enabled=ptt_enabled) - dialog_content.ids.name_field.font_name = self.app.input_font - dialog = MDDialog(title="Edit Conversation", text="With "+RNS.prettyhexrep(dest), - type="custom", content_cls=dialog_content, buttons=[yes_button, no_button]) - dialog.d_content = dialog_content - def dl_no(s): dialog.dismiss() - def dl_yes(s): - try: - name = dialog.d_content.ids["name_field"].text - trusted = dialog.d_content.ids["trusted_switch"].active - telemetry = dialog.d_content.ids["telemetry_switch"].active - allow_requests = dialog.d_content.ids["allow_requests_switch"].active - conv_is_object = dialog.d_content.ids["is_object_switch"].active - ptt_is_enabled = dialog.d_content.ids["ptt_enabled_switch"].active - if trusted: self.app.sideband.trusted_conversation(dest) - else: self.app.sideband.untrusted_conversation(dest) - if telemetry: self.app.sideband.send_telemetry_in_conversation(dest) - else: self.app.sideband.no_telemetry_in_conversation(dest) - if allow_requests: self.app.sideband.allow_requests_from(dest) - else: self.app.sideband.disallow_requests_from(dest) - if conv_is_object: self.app.sideband.conversation_set_object(dest, True) - else: self.app.sideband.conversation_set_object(dest, False) - if ptt_is_enabled:self.app.sideband.conversation_set_ptt_enabled(dest, True) - else: self.app.sideband.conversation_set_ptt_enabled(dest, False) - self.app.sideband.named_conversation(name, dest) - - except Exception as e: RNS.log("Error while saving conversation settings: "+str(e), RNS.LOG_ERROR) - - dialog.dismiss() - - def cb(dt): self.dispatch_update() - Clock.schedule_once(cb, 0.2) - - yes_button.bind(on_release=dl_yes) - no_button.bind(on_release=dl_no) - self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() - dialog.open() - RNS.log("Generated edit dialog in "+str(RNS.prettytime(time.time()-t_s)), RNS.LOG_DEBUG) - - except Exception as e: - RNS.log("Error while creating conversation settings: "+str(e), RNS.LOG_ERROR) - - def delete_conversation_action(self): - context_dest = ConversationEntry.conversation_dropdown.context_dest; caller = ConversationEntry.conversation_dropdown.caller - if ConversationEntry.delete_dialog == None: - yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) - no_button = MDRectangleFlatButton(text="No",font_size=dp(18)) - ConversationEntry.delete_dialog = MDDialog( title="Delete conversation?", buttons=[ yes_button, no_button ]) - def dl_yes(s): - ConversationEntry.delete_dialog.dismiss() - self.app.sideband.delete_conversation(ConversationEntry.conversation_dropdown.context_dest) - def cb(dt): self.dispatch_update() - Clock.schedule_once(cb, 0.1) - def dl_no(s): - self.delete_dialog.dismiss() - - yes_button.bind(on_release=dl_yes) - no_button.bind(on_release=dl_no) - - self.dropdown_dismiss() - ConversationEntry.delete_dialog.open() - - def call_action(self): - context_dest = ConversationEntry.conversation_dropdown.context_dest; caller = ConversationEntry.conversation_dropdown.caller - identity = RNS.Identity.recall(ConversationEntry.conversation_dropdown.context_dest) - if identity: self.app.dial_action(identity.hash) - else: toast("Can't call, identity unknown") - self.dropdown_dismiss() - - def clear_messages_action(self): - context_dest = ConversationEntry.conversation_dropdown.context_dest; caller = ConversationEntry.conversation_dropdown.caller - if ConversationEntry.clear_dialog == None: - yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) - no_button = MDRectangleFlatButton(text="No",font_size=dp(18)) - ConversationEntry.clear_dialog = MDDialog(title="Clear all messages in conversation?", buttons=[ yes_button, no_button ]) - - def dl_yes(s): - ConversationEntry.clear_dialog.dismiss() - self.app.sideband.clear_conversation(ConversationEntry.conversation_dropdown.context_dest) - def dl_no(s): - self.clear_dialog.dismiss() - - yes_button.bind(on_release=dl_yes) - no_button.bind(on_release=dl_no) - - ConversationEntry.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() - self.clear_dialog.open() - RNS.log(f"Menu action from {caller} for {RNS.prettyhexrep(context_dest)}") - - def clear_telemetry_action(self): - context_dest = ConversationEntry.conversation_dropdown.context_dest; caller = ConversationEntry.conversation_dropdown.caller - if ConversationEntry.clear_telemetry_dialog == None: - yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) - no_button = MDRectangleFlatButton(text="No",font_size=dp(18)) - ConversationEntry.clear_telemetry_dialog = MDDialog( title="Clear all telemetry related to this peer?", buttons=[ yes_button, no_button ]) - def dl_yes(s): - ConversationEntry.clear_telemetry_dialog.dismiss() - self.app.sideband.clear_telemetry(self.conversation_dropdown.context_dest) - def dl_no(s): - ConversationEntry.clear_telemetry_dialog.dismiss() - - yes_button.bind(on_release=dl_yes) - no_button.bind(on_release=dl_no) - - ConversationEntry.voice_dropdown.dismiss(); ConversationEntry.conversation_dropdown.dismiss() - ConversationEntry.clear_telemetry_dialog.open() - - def left_icon_action(self, sender): - RNS.log(f"Action from: {sender} {self.text}") - if self.object: self.app.object_details_action(sender=self, source_dest=self.sb_uid, from_objects=True) - else: self.app.conversation_action(self) - - def right_icon_action(self, sender): - ConversationEntry.voice_dropdown.context_dest = self.sb_uid - ConversationEntry.voice_dropdown.caller = self.ids.right_icon - ConversationEntry.conversation_dropdown.caller = self.ids.right_icon - ConversationEntry.conversation_dropdown.context_dest = self.sb_uid - if self.conv_type == self.app.sideband.CONV_VOICE: ConversationEntry.voice_dropdown.open() - else: ConversationEntry.conversation_dropdown.open() - -class ConversationList(MDRecycleView): - def __init__(self): - super().__init__() - self.data = [] - class Conversations(): def __init__(self, app): self.app = app self.context_dests = [] self.added_item_dests = [] self.list = None - self.conversation_list = None self.ids = None if not self.app.root.ids.screen_manager.has_screen("conversations_screen"): @@ -268,7 +61,21 @@ class Conversations(): self.update() + def reload(self): + self.clear_list() + self.update() + + def clear_list(self): + if self.list != None: + self.list.clear_widgets() + + self.context_dests = [] + self.added_item_dests = [] + def update(self): + # if self.app.sideband.getstate("app.flags.unread_conversations"): + # self.clear_list() + self.context_dests = self.app.sideband.list_conversations(conversations=self.app.include_conversations, objects=self.app.include_objects) view_title = "Conversations" @@ -369,68 +176,362 @@ class Conversations(): return iconl - def get_icon_colors(self, conv): - context_dest = conv["dest"] - trusted = conv["trust"] == 1 - appearance_from_all = self.app.sideband.config["display_style_from_all"] - appearance = self.app.sideband.peer_appearance(context_dest, conv=conv) - da = self.app.sideband.DEFAULT_APPEARANCE - ic_s = 24; ic_p = 14 - - fg = None; bg = None; ti_color = None - - if (trusted or appearance_from_all) and self.app.sideband.config["display_style_in_contact_list"] and appearance != None and appearance != da: - fg = appearance[1] or da[1]; bg = appearance[2] or da[2] - ti_color = "Custom" - else: ti_color = None - - return {"ti_color": ti_color, "fg": fg, "bg": bg} - - def update_conversation_list(self): + def update_widget(self): us = time.time() - ConversationEntry.app = self.app - ConversationEntry.owner_screen = self - entries = [] + RNS.log("Updating conversation list widgets", RNS.LOG_DEBUG) + if self.list == None: + self.list = MDList() + + remove_widgets = [] + for w in self.list.children: + if not w.sb_uid in [e["dest"] for e in self.context_dests]: + remove_widgets.append(w) + self.added_item_dests.remove(w.sb_uid) + + for w in remove_widgets: + self.list.remove_widget(w) + + for conv in self.context_dests: context_dest = conv["dest"] conv_type = conv["type"] unread = conv["unread"] last_activity = conv["last_activity"] - colors = self.get_icon_colors(conv) peer_disp_name = multilingual_markup(escape_markup(str(self.app.sideband.peer_display_name(context_dest))).encode("utf-8")).decode("utf-8") - existing_conv = self.app.sideband._db_conversation(context_dest) - is_object = self.app.sideband.is_object(context_dest, conv_data=existing_conv) - is_trusted = self.app.sideband.is_trusted(context_dest, conv_data=existing_conv) - ptt_enabled = self.app.sideband.ptt_enabled(context_dest, conv_data=existing_conv) - icon = self.trust_icon(conv) + if not context_dest in self.added_item_dests: + existing_conv = self.app.sideband._db_conversation(context_dest) + is_object = self.app.sideband.is_object(context_dest, conv_data=existing_conv) + ptt_enabled = self.app.sideband.ptt_enabled(context_dest, conv_data=existing_conv) + iconl = self.get_icon(conv) + item = OneLineAvatarIconListItem(text=peer_disp_name, on_release=self.app.conversation_action) + item.add_widget(iconl) + item.last_activity = last_activity + item.iconl = iconl + item.sb_uid = context_dest + item.sb_unread = unread + iconl.sb_uid = context_dest + item.conv_type = conv_type - cl_entry = {"icon": icon, "text": peer_disp_name, "conv_type": conv_type, - "ti_color": "Primary", "icon_fg": "#FFFFFF", "icon_bg": "#00000000", - "conv_type": conv_type, "sb_uid": context_dest, "sb_unread": unread, - "trusted": is_trusted, "object": is_object, "last_activity": last_activity} + def gen_edit(item): + def x(): + t_s = time.time() + dest = self.conversation_dropdown.context_dest + try: + cd = self.app.sideband._db_conversation(dest) + disp_name = self.app.sideband.raw_display_name(dest) + is_trusted = self.app.sideband.is_trusted(dest, conv_data=cd) + is_object = self.app.sideband.is_object(dest, conv_data=cd) + ptt_enabled = self.app.sideband.ptt_enabled(dest, conv_data=cd) + send_telemetry = self.app.sideband.should_send_telemetry(dest, conv_data=cd) + allow_requests = self.app.sideband.requests_allowed_from(dest, conv_data=cd) - if colors["ti_color"]: cl_entry["ti_color"] = colors["ti_color"] - if colors["fg"]: cl_entry["icon_fg"] = colors["fg"] - if colors["bg"]: cl_entry["icon_bg"] = colors["bg"] - entries.append(cl_entry) + yes_button = MDRectangleFlatButton(text="Save",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_accept, text_color=self.app.color_accept) + no_button = MDRectangleFlatButton(text="Cancel",font_size=dp(18)) + dialog_content = ConvSettings(disp_name=disp_name, context_dest=RNS.hexrep(dest, delimit=False), trusted=is_trusted, + telemetry=send_telemetry, allow_requests=allow_requests, is_object=is_object, ptt_enabled=ptt_enabled) + dialog_content.ids.name_field.font_name = self.app.input_font - entries.sort(key=lambda w: (w["trusted"], w["last_activity"]), reverse=True) - self.conversation_list.data = entries + dialog = MDDialog( + title="Edit Conversation", + text= "With "+RNS.prettyhexrep(dest), + type="custom", + content_cls=dialog_content, + buttons=[ yes_button, no_button ], + # elevation=0, + ) + dialog.d_content = dialog_content + def dl_yes(s): + try: + name = dialog.d_content.ids["name_field"].text + trusted = dialog.d_content.ids["trusted_switch"].active + telemetry = dialog.d_content.ids["telemetry_switch"].active + allow_requests = dialog.d_content.ids["allow_requests_switch"].active + conv_is_object = dialog.d_content.ids["is_object_switch"].active + ptt_is_enabled = dialog.d_content.ids["ptt_enabled_switch"].active + if trusted: + self.app.sideband.trusted_conversation(dest) + else: + self.app.sideband.untrusted_conversation(dest) - RNS.log("Updated recycle list widgets in "+RNS.prettytime(time.time()-us), RNS.LOG_DEBUG) + if telemetry: + self.app.sideband.send_telemetry_in_conversation(dest) + else: + self.app.sideband.no_telemetry_in_conversation(dest) - def update_widget(self): - RNS.log("Updating conversation list widgets", RNS.LOG_DEBUG) - if self.conversation_list == None: - self.conversation_list = ConversationList() - self.list = self.conversation_list - self.ids.conversation_list_container.add_widget(self.conversation_list) - - self.update_conversation_list() + if allow_requests: + self.app.sideband.allow_requests_from(dest) + else: + self.app.sideband.disallow_requests_from(dest) + + if conv_is_object: + self.app.sideband.conversation_set_object(dest, True) + else: + self.app.sideband.conversation_set_object(dest, False) + + if ptt_is_enabled: + RNS.log("Setting PTT enabled") + self.app.sideband.conversation_set_ptt_enabled(dest, True) + else: + RNS.log("Setting PTT disabled") + self.app.sideband.conversation_set_ptt_enabled(dest, False) + + self.app.sideband.named_conversation(name, dest) + + except Exception as e: + RNS.log("Error while saving conversation settings: "+str(e), RNS.LOG_ERROR) + + dialog.dismiss() + + def cb(dt): + self.update() + Clock.schedule_once(cb, 0.2) + + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() + dialog.open() + RNS.log("Generated edit dialog in "+str(RNS.prettytime(time.time()-t_s)), RNS.LOG_DEBUG) + + except Exception as e: + RNS.log("Error while creating conversation settings: "+str(e), RNS.LOG_ERROR) + + return x + + def gen_clear(item): + def x(): + if self.clear_dialog == None: + yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) + no_button = MDRectangleFlatButton(text="No",font_size=dp(18)) + + self.clear_dialog = MDDialog( + title="Clear all messages in conversation?", + buttons=[ yes_button, no_button ], + # elevation=0, + ) + def dl_yes(s): + self.clear_dialog.dismiss() + self.app.sideband.clear_conversation(self.conversation_dropdown.context_dest) + def dl_no(s): + self.clear_dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + + self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() + self.clear_dialog.open() + return x + + def gen_clear_telemetry(item): + def x(): + if self.clear_telemetry_dialog == None: + yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) + no_button = MDRectangleFlatButton(text="No",font_size=dp(18)) + + self.clear_telemetry_dialog = MDDialog( + title="Clear all telemetry related to this peer?", + buttons=[ yes_button, no_button ], + # elevation=0, + ) + def dl_yes(s): + self.clear_telemetry_dialog.dismiss() + self.app.sideband.clear_telemetry(self.conversation_dropdown.context_dest) + def dl_no(s): + self.clear_telemetry_dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + + self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() + self.clear_telemetry_dialog.open() + return x + + def gen_del(item): + def x(): + if self.delete_dialog == None: + yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) + no_button = MDRectangleFlatButton(text="No",font_size=dp(18)) + self.delete_dialog = MDDialog( + title="Delete conversation?", + buttons=[ yes_button, no_button ], + # elevation=0, + ) + def dl_yes(s): + self.delete_dialog.dismiss() + self.app.sideband.delete_conversation(self.conversation_dropdown.context_dest) + def cb(dt): + self.update() + Clock.schedule_once(cb, 0.2) + def dl_no(s): + self.delete_dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + + self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() + self.delete_dialog.open() + return x + + def gen_copy_addr(item): + def x(): + Clipboard.copy(RNS.hexrep(self.conversation_dropdown.context_dest, delimit=False)) + self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() + return x + + def gen_call(item): + def x(): + identity = RNS.Identity.recall(self.conversation_dropdown.context_dest) + if identity: self.app.dial_action(identity.hash) + else: toast("Can't call, identity unknown") + self.voice_dropdown.dismiss(); self.conversation_dropdown.dismiss() + return x + + item.iconr = IconRightWidget(icon="dots-vertical"); + + if self.voice_dropdown == None: + dmi_h = 40 + dmv_items = [ + { + "viewclass": "OneLineListItem", + "text": "Edit", + "height": dp(dmi_h), + "on_release": gen_edit(item) + }, + { + "text": "Copy Identity Hash", + "viewclass": "OneLineListItem", + "height": dp(dmi_h), + "on_release": gen_copy_addr(item) + }, + { + "text": "Delete", + "viewclass": "OneLineListItem", + "height": dp(dmi_h), + "on_release": gen_del(item) + } + ] + + self.voice_dropdown = MDDropdownMenu( + caller=item.iconr, + items=dmv_items, + position="auto", + width=dp(256), + elevation=0, + radius=dp(3), + ) + self.voice_dropdown.effect_cls = ScrollEffect + self.voice_dropdown.md_bg_color = self.app.color_hover + + if self.conversation_dropdown == None: + dmi_h = 40 + dm_items = [ + { + "viewclass": "OneLineListItem", + "text": "Edit", + "height": dp(dmi_h), + "on_release": gen_edit(item) + }, + { + "viewclass": "OneLineListItem", + "text": "Call", + "height": dp(dmi_h), + "on_release": gen_call(item) + }, + { + "text": "Copy Address", + "viewclass": "OneLineListItem", + "height": dp(dmi_h), + "on_release": gen_copy_addr(item) + }, + { + "text": "Clear Messages", + "viewclass": "OneLineListItem", + "height": dp(dmi_h), + "on_release": gen_clear(item) + }, + { + "text": "Clear Telemetry", + "viewclass": "OneLineListItem", + "height": dp(dmi_h), + "on_release": gen_clear_telemetry(item) + }, + { + "text": "Delete Conversation", + "viewclass": "OneLineListItem", + "height": dp(dmi_h), + "on_release": gen_del(item) + } + ] + + self.conversation_dropdown = MDDropdownMenu( + caller=item.iconr, + items=dm_items, + position="auto", + width=dp(256), + elevation=0, + radius=dp(3), + ) + self.conversation_dropdown.effect_cls = ScrollEffect + self.conversation_dropdown.md_bg_color = self.app.color_hover + + if conv_type == self.app.sideband.CONV_VOICE: + item.dmenu = self.voice_dropdown + else: + item.dmenu = self.conversation_dropdown + + def callback_factory(ref, dest): + def x(sender): + self.conversation_dropdown.context_dest = dest + self.voice_dropdown.context_dest = dest + ref.dmenu.caller = ref.iconr + ref.dmenu.open() + return x + + item.iconr.bind(on_release=callback_factory(item, context_dest)) + + item.add_widget(item.iconr) + + item.trusted = self.app.sideband.is_trusted(context_dest, conv_data=existing_conv) + item.conv_type = conv_type + + self.added_item_dests.append(context_dest) + self.list.add_widget(item) + + else: + for w in self.list.children: + if w.sb_uid == context_dest: + trust_icon = self.trust_icon(conv) + trusted = conv["trust"] == 1 + da = self.app.sideband.DEFAULT_APPEARANCE + appearance = self.app.sideband.peer_appearance(context_dest, conv) + if trusted and self.app.sideband.config["display_style_in_contact_list"] and appearance != None and appearance != da: + fg = appearance[1] or da[1]; bg = appearance[2] or da[2] + ti_color = "Custom" + else: + ti_color = None + + w.last_activity = last_activity + if ti_color != None: + w.iconl.theme_icon_color = ti_color + if bg != None: w.iconl.md_bg_color = bg + if fg != None: w.iconl.icon_color = fg + else: + w.iconl.theme_icon_color = "Primary" + w.iconl.md_bg_color = [0,0,0,0] + + if w.iconl.icon != trust_icon: w.iconl.icon = trust_icon + if w.sb_unread != unread: w.sb_unread = unread + if w.text != peer_disp_name: w.text = peer_disp_name + + self.list.children.sort(key=lambda w: (w.trusted, w.last_activity)) + + RNS.log("Updated conversation list widgets in "+RNS.prettytime(time.time()-us), RNS.LOG_DEBUG) def get_widget(self): - return self.conversation_list + return self.list conv_screen_kv = """ MDScreen: @@ -457,39 +558,11 @@ MDScreen: ['account-plus', lambda x: root.app.new_conversation_action(self)], ] - MDBoxLayout: - orientation: "vertical" - id: conversation_list_container + ScrollView: + id: conversations_scrollview """ Builder.load_string(""" - - - IconLeftWidget: - id: left_icon - theme_icon_color: root.ti_color - icon_color: root.icon_fg - md_bg_color: root.icon_bg - icon: root.icon - _default_icon_pad: dp(14) - icon_size: dp(24) - - IconRightWidget: - id: right_icon - icon: "dots-vertical" - -: - id: conversations_scrollview - viewclass: "ConversationEntry" - effect_cls: "ScrollEffect" - - RecycleBoxLayout: - default_size: None, dp(57) - default_size_hint: 1, None - size_hint_y: None - height: self.minimum_height - orientation: "vertical" - orientation: "vertical" spacing: "24dp" diff --git a/sbapp/ui/guide.py b/sbapp/ui/guide.py deleted file mode 100644 index ff16126..0000000 --- a/sbapp/ui/guide.py +++ /dev/null @@ -1,266 +0,0 @@ -import time -import RNS - -from typing import Union -from kivy.metrics import dp,sp -from kivy.lang.builder import Builder -from kivy.core.clipboard import Clipboard -from kivy.utils import escape_markup -from kivymd.uix.recycleview import MDRecycleView -from kivymd.uix.list import OneLineIconListItem -from kivymd.uix.pickers import MDColorPicker -from kivymd.uix.button import MDRectangleFlatButton -from kivymd.uix.button import MDRectangleFlatIconButton -from kivymd.uix.dialog import MDDialog -from kivy.properties import StringProperty, BooleanProperty, OptionProperty, ColorProperty, Property -from kivymd.uix.list import MDList, IconLeftWidget, IconRightWidget, OneLineAvatarIconListItem -from kivymd.icon_definitions import md_icons -from kivymd.toast import toast -from kivy.properties import StringProperty, BooleanProperty -from kivy.effects.scroll import ScrollEffect - -if RNS.vendor.platformutils.get_platform() == "android": - from ui.helpers import dark_theme_text_color -else: - from .helpers import dark_theme_text_color - -class Guide(): - def __init__(self, app): - self.app = app - self.screen = None - - if not self.app.root.ids.screen_manager.has_screen("guide_screen"): - self.screen = Builder.load_string(layout_guide_screen) - self.screen.app = self - self.app.root.ids.screen_manager.add_widget(self.screen) - - def link_exec(sender=None, event=None): - def lj(): webbrowser.open("https://unsigned.io/donate") - threading.Thread(target=lj, daemon=True).start() - - guide_text1 = """ -[size=18dp][b]Introduction[/b][/size][size=5dp]\n \n[/size]Welcome to [i]Sideband[/i], an LXMF client for Android, Linux, macOS and Windows. With Sideband, you can communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, or anything else Reticulum supports. - -This short guide will give you a basic introduction to the concepts that underpin Sideband and LXMF (which is the protocol that Sideband uses to communicate). If you are not already familiar with LXMF and Reticulum, it is probably a good idea to read this guide, since Sideband is very different from other messaging apps.""" - guide_text2 = """ -[size=18dp][b]Communication Without Subjection[/b][/size][size=5dp]\n \n[/size]Sideband is completely free, permission-less, anonymous and infrastructure-less. Sideband uses the peer-to-peer and distributed messaging system LXMF. There is no sign-up, no service providers, no "end-user license agreements", no data theft and no surveillance. You own the system. - -This also means that Sideband operates differently than what you might be used to. It does not need a connection to a server on the Internet to function, and you do not have an account anywhere.""" - - guide_text3 = """ -[size=18dp][b]Operating Principles[/b][/size][size=5dp]\n \n[/size]When Sideband is started on your device for the first time, it randomly generates a 512-bit Reticulum Identity Key. This cryptographic key is then used to create an LXMF address for your use, and in turn to secure any communication to your address. Any other endpoint in [i]any[/i] Reticulum network will be able to send data to your address, as long as there is [i]some sort of physical connection[/i] between your device and the remote endpoint. You can also move around to other Reticulum networks with this address, even ones that were never connected to the network the address was created on, or that didn't exist when the address was created.\n\nYour LXMF address is yours to keep and control for as long (or short) a time you need it, and you can always delete it and create a new one. You identity keys and corresponding addresses are never registered on or controlled by any external servers or services, and will never leave your device, unless you manually export them for backup.""" - - guide_text10 = """ -[size=18dp][b]Getting Connected[/b][/size][size=5dp]\n \n[/size]If you already have Reticulum connectivity set up on the device you are running Sideband on, no further configuration should be necessary, and Sideband will simply use the available Reticulum connectivity.\n\nIf you are running Sideband on a computer, you can configure interfaces in the Reticulum configuration file ([b]~/.reticulum/config[/b] by default). If you are running Sideband on an Android device, you can configure various interface types in the [b]Connectivity[/b] section. By default, only an [i]AutoInterface[/i] is enabled, which will connect you automatically with any other local devices on the same WiFi and/or Ethernet networks. This may or may not include Reticulum Transport Nodes, which can route your traffic to wider networks.\n\nYou can enable any or all of the other available interface types to gain wider connectivity. For more specific information on interface types, configuration options, and how to effectively build your own Reticulum networks, see the [b]Reticulum Manual[b].""" - - guide_text4 = """ -[size=18dp][b]Becoming Reachable[/b][/size][size=5dp]\n \n[/size]To establish reachability for any Reticulum destination on a network, an [i]announce[/i] must be sent. By default, Sideband will announce automatically when necessary, but if you want to stay silent, automatic announces can be disabled in [b]Preferences[/b].\n\nTo send an announce manually, press the [i]Announce[/i] button in the [i]Conversations[/i] section of the program. When you send an announce, you make your LXMF address reachable for real-time messaging to the entire network you are connected to. Even in very large networks, you can expect global reachability for your address to be established in under a minute. - -If you don't move to other places in the network, and keep connected through the same hubs or gateways, it is generally not necessary to send an announce more often than once every week. If you change your entry point to the network, you may want to send an announce, or you may just want to stay quiet.""" - - guide_text5 = """ -[size=18dp][b]Relax & Disconnect[/b][/size][size=5dp]\n \n[/size]If you are not connected to the network, it is still possible for other people to message you, as long as one or more [i]Propagation Nodes[/i] exist on the network. These nodes pick up and hold encrypted in-transit messages for offline users. Messages are always encrypted before leaving the originators device, and nobody else than the intended recipient can decrypt messages in transit. - -The Propagation Nodes also distribute copies of messages between each other, such that even the failure of almost every node in the network will still allow users to sync their waiting messages. If all Propagation Nodes disappear or are destroyed, users can still communicate directly.\n\nReticulum and LXMF will degrade gracefully all the way down to single users communicating directly via long-range data radios. Anyone can start up new propagation nodes and integrate them into existing networks without permission or coordination. Even a small and cheap device like a Rasperry Pi can handle messages for millions of users. LXMF networks are designed to be quite resilient, as long as there are people using them.""" - - guide_text6 = """ -[size=18dp][b]Packets Find A Way[/b][/size][size=5dp]\n \n[/size]Connections in Reticulum networks can be wired or wireless, span many intermediary hops, run over fast links or ultra-low bandwidth radio, tunnel over the Invisible Internet (I2P), private networks, satellite connections, serial lines or anything else that Reticulum can carry data over.\n\nIn most cases it will not be possible to know what path packets takes in a Reticulum network, and apart from a destination hash, no transmitted packets carries any identifying characteristics. In Reticulum, [i]there is no source addresses[/i].\n\nAs long as you do not reveal any connecting details between your person and your LXMF address, you can remain anonymous. Sending messages to others does not reveal [i]your[/i] address to anyone else than the intended recipient.""" - - guide_text7 = """ -[size=18dp][b]Be Yourself, Be Unknown, Stay Free[/b][/size][size=5dp]\n \n[/size]Even with the above characteristics in mind, you [b]must remember[/b] that LXMF and Reticulum is not a technology that can guarantee anonymising connections that are already de-anonymised! If you use Sideband to connect to TCP Reticulum hubs over the clear Internet, from a network that can be tied to your personal identity, an adversary may learn that you are generating LXMF traffic.\n\nIf you want to avoid this, it is recommended to use I2P to connect to Reticulum hubs on the Internet. Or only connecting from within pure Reticulum networks, that take one or more hops to reach connections that span the Internet. This is a complex topic, with many more nuances than can be covered here. You are encouraged to ask on the various Reticulum discussion forums if you are in doubt. - -If you use Reticulum and LXMF on hardware that does not carry any identifiers tied to you, it is possible to establish a completely free and identification-less communication system with Reticulum and LXMF clients.""" - - guide_text8 = """ -[size=18dp][b]Keyboard Shortcuts[/b][/size][size=5dp]\n \n[/size]To ease navigation and operation of the program, Sideband has keyboard shortcuts mapped to the most common actions. A reference is included below. - -[b]Quick Actions[/b] - - [b]Ctrl-W[/b] Go back - - [b]Ctrl-Q[/b] Shut down Sideband - - [b]Ctrl-R[/b] Start LXMF sync (from Conversations screen) - - [b]Ctrl-N[/b] Create new conversation - - [b]Message Actions[/b] - - [b]Ctrl-Shift-A[/b] add message attachment - - [b]Ctrl-Shift-V[/b] add high-quality voice - - [b]Ctrl-Shift-C[/b] add low-bandwidth voice - - [b]Ctrl-Shift-I[/b] add medium-quality image - - [b]Ctrl-Shift-F[/b] add file - - [b]Ctrl-D[/b] or [b]Ctrl-S[/b] Send message - - [b]Voice & PTT Messages[/b] - - [b]Space[/b] Start/stop recording - - [b]Enter[/b] Save recording to message - - With PTT enabled, hold [b]Space[/b] to talk - - [b]Voice Calls[/b] - - [b]Ctrl-Space[/b] Answer incoming call - - [b]Ctrl-.[/b] Reject incoming call - - [b]Ctrl-.[/b] Hang up active call - - [b]Navigation[/b] - - [b]Ctrl-[i]n[/i][/b] Go to conversation number [i]n[/i] - - [b]Ctrl-R[/b] Go to Conversations - - [b]Ctrl-O[/b] Go to Objects & Devices - - [b]Ctrl-E[/b] Go to Voice - - [b]Ctrl-L[/b] Go to Announce Stream - - [b]Ctrl-M[/b] Go to Situation Map - - [b]Ctrl-U[/b] Go to Utilities - - [b]Ctrl-T[/b] Go to Telemetry configuration - - [b]Ctrl-G[/b] Go to Guide - - [b]Ctrl-Y[/b] Display own telemetry - -[b]Map Controls[/b] - - [b]Up[/b], [b]down[/b], [b]left[/b], [b]right[/b] Navigate - - [b]W[/b], [b]A[/b], [b]S[/b], [b]D[/b] Navigate - - [b]H[/b], [b]J[/b], [b]L[/b], [b]K[/b] Navigate - - [b]E[/b] or [b]+[/b] Zoom in - - [b]Q[/b] or [b]-[/b] Zoom out - - Hold [b]Shift[/b] to navigate more coarsely - - Hold [b]Alt[/b] to navigate more finely""" - - guide_text9 = """ -[size=18dp][b]Please Support This Project[/b][/size][size=5dp]\n \n[/size]It took me more than eight years to design and build the entire ecosystem of software and hardware that makes this possible. If this project is valuable to you, please go to [u][ref=link]https://unsigned.io/donate[/ref][/u] to support the project with a donation. Every donation directly makes the entire Reticulum project possible. - -Thank you very much for using Free Communications Systems. -""" - info1 = guide_text1 - info2 = guide_text8 - info3 = guide_text2 - info4 = guide_text3 - info10 = guide_text10 - info5 = guide_text4 - info6 = guide_text5 - info7 = guide_text6 - info8 = guide_text7 - info9 = guide_text9 - - if self.app.theme_cls.theme_style == "Dark": - info1 = "[color=#"+dark_theme_text_color+"]"+info1+"[/color]" - info2 = "[color=#"+dark_theme_text_color+"]"+info2+"[/color]" - info3 = "[color=#"+dark_theme_text_color+"]"+info3+"[/color]" - info4 = "[color=#"+dark_theme_text_color+"]"+info4+"[/color]" - info5 = "[color=#"+dark_theme_text_color+"]"+info5+"[/color]" - info6 = "[color=#"+dark_theme_text_color+"]"+info6+"[/color]" - info7 = "[color=#"+dark_theme_text_color+"]"+info7+"[/color]" - info8 = "[color=#"+dark_theme_text_color+"]"+info8+"[/color]" - info9 = "[color=#"+dark_theme_text_color+"]"+info9+"[/color]" - info10 = "[color=#"+dark_theme_text_color+"]"+info10+"[/color]" - self.screen.ids.guide_info1.text = info1 - self.screen.ids.guide_info2.text = info2 - self.screen.ids.guide_info3.text = info3 - self.screen.ids.guide_info4.text = info4 - self.screen.ids.guide_info5.text = info5 - self.screen.ids.guide_info6.text = info6 - self.screen.ids.guide_info7.text = info7 - self.screen.ids.guide_info8.text = info8 - self.screen.ids.guide_info9.text = info9 - self.screen.ids.guide_info10.text = info10 - self.screen.ids.guide_info9.bind(on_ref_press=link_exec) - self.screen.ids.guide_scrollview.effect_cls = ScrollEffect - -layout_guide_screen = """ -MDScreen: - name: "guide_screen" - - BoxLayout: - orientation: "vertical" - - MDTopAppBar: - title: "Guide" - anchor_title: "left" - elevation: 0 - left_action_items: - [ ['menu', lambda x: root.app.app.nav_drawer.set_state("open")] ] - right_action_items: - [ ['close', lambda x: root.app.app.close_guide_action(self)] ] - - ScrollView: - id:guide_scrollview - - MDBoxLayout: - orientation: "vertical" - size_hint_y: None - height: self.minimum_height - padding: [dp(35), dp(16), dp(35), dp(16)] - - MDLabel: - id: guide_info1 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info2 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info3 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info4 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info10 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info5 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info6 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info7 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info8 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - id: guide_info9 - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] -""" \ No newline at end of file diff --git a/sbapp/ui/hardware.py b/sbapp/ui/hardware.py deleted file mode 100644 index cf0b5c8..0000000 --- a/sbapp/ui/hardware.py +++ /dev/null @@ -1,1690 +0,0 @@ -import time -import RNS - -import base64 -import threading -import RNS.vendor.umsgpack as msgpack -from kivy.metrics import dp,sp -from kivy.lang.builder import Builder -from kivy.core.clipboard import Clipboard -from kivymd.uix.button import MDRectangleFlatButton -from kivymd.uix.dialog import MDDialog -from kivymd.toast import toast -from kivy.effects.scroll import ScrollEffect -from kivy.clock import Clock -from kivy.uix.screenmanager import NoTransition, SlideTransition - -TRANSITION_DURATION = 0.25 -if RNS.vendor.platformutils.is_android(): - ll_ot = 0.55 - ll_ft = 0.275 -else: - ll_ot = 0.4 - ll_ft = 0.275 - -if RNS.vendor.platformutils.is_android(): - from ui.helpers import dark_theme_text_color - from jnius import autoclass - BluetoothAdapter = autoclass('android.bluetooth.BluetoothAdapter') -else: - from .helpers import dark_theme_text_color - - -class Hardware(): - - def __init__(self, app): - self.app = app - self.keys_screen = None - self.hardware_rnode_ready = False - self.hardware_modem_ready = False - self.hardware_serial_ready = False - - self.no_transition = NoTransition() - self.slide_transition = SlideTransition() - - if not self.app.hardware_ready: - if not self.app.root.ids.screen_manager.has_screen("hardware_screen"): - self.hardware_screen = Builder.load_string(layout_hardware_screen) - self.hardware_screen.app = self - self.app.root.ids.screen_manager.add_widget(self.hardware_screen) - - self.hardware_screen.ids.hardware_scrollview.effect_cls = ScrollEffect - - def con_hide_settings(): - self.app.widget_hide(self.hardware_screen.ids.hardware_rnode_button) - self.app.widget_hide(self.hardware_screen.ids.hardware_modem_button) - self.app.widget_hide(self.hardware_screen.ids.hardware_serial_button) - - if RNS.vendor.platformutils.get_platform() == "android": - if not self.app.sideband.getpersistent("service.is_controlling_connectivity"): - info = "Sideband is connected via a shared Reticulum instance running on this system.\n\n" - info += "To configure hardware parameters, edit the relevant configuration file for the instance." - self.hardware_screen.ids.hardware_info.text = info - con_hide_settings() - - else: - info = "When using external hardware for communicating, you may configure various parameters, such as channel settings, modulation schemes, interface speeds and access parameters. You can set up these parameters per device type, and Sideband will apply the configuration when opening a device of that type.\n\n" - info += "Hardware configurations can also be exported or imported as [i]config motes[/i], which are self-contained plaintext strings that are easy to share with others. When importing a config mote, Sideband will automatically set all relevant parameters as specified within it.\n\n" - info += "For changes to hardware parameters to take effect, you must shut down and restart Sideband.\n" - self.hardware_screen.ids.hardware_info.text = info - - else: - info = "" - - if self.app.sideband.reticulum.is_connected_to_shared_instance: - info = "Sideband is connected via a shared Reticulum instance running on this system.\n\n" - info += "To configure hardware parameters, edit the configuration file located at:\n\n" - if not RNS.vendor.platformutils.is_windows(): info += str(RNS.Reticulum.configpath) - else: info += str(RNS.Reticulum.configpath.replace("/", "\\")) - else: - info = "Sideband is currently running a standalone or master Reticulum instance on this system.\n\n" - info += "To configure hardware parameters, edit the configuration file located at:\n\n" - if not RNS.vendor.platformutils.is_windows(): info += str(RNS.Reticulum.configpath) - else: info += str(RNS.Reticulum.configpath.replace("/", "\\")) - - self.hardware_screen.ids.hardware_info.text = info - - con_hide_settings() - - self.app.hardware_ready = True - - ## RNode hardware screen - def hardware_rnode_action(self, sender=None, direction="left"): - if self.hardware_rnode_ready: - self.hardware_rnode_open(direction=direction) - else: - self.app.loader_action(direction=direction) - def final(dt): - self.hardware_rnode_init() - def o(dt): - self.hardware_rnode_open(no_transition=True) - Clock.schedule_once(o, ll_ot) - Clock.schedule_once(final, ll_ft) - - def hardware_rnode_open(self, sender=None, direction="left", no_transition=False): - if no_transition: - self.app.root.ids.screen_manager.transition = self.no_transition - else: - self.app.root.ids.screen_manager.transition = self.slide_transition - self.app.root.ids.screen_manager.transition.direction = direction - - self.app.root.ids.screen_manager.transition.direction = "left" - self.app.root.ids.screen_manager.current = "hardware_rnode_screen" - self.app.root.ids.nav_drawer.set_state("closed") - self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current) - - if no_transition: - self.app.root.ids.screen_manager.transition = self.slide_transition - - def hardware_rnode_save(self): - try: self.app.sideband.config["hw_rnode_frequency"] = int(float(self.hardware_rnode_screen.ids.hardware_rnode_frequency.text)*1000000) - except: pass - - try: self.app.sideband.config["hw_rnode_bandwidth"] = int(float(self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text)*1000) - except: pass - - try: self.app.sideband.config["hw_rnode_tx_power"] = int(self.hardware_rnode_screen.ids.hardware_rnode_txpower.text) - except: pass - - try: self.app.sideband.config["hw_rnode_spreading_factor"] = int(self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text) - except: pass - - try: self.app.sideband.config["hw_rnode_coding_rate"] = int(self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text) - except: pass - - try: self.app.sideband.config["hw_rnode_atl_short"] = float(self.hardware_rnode_screen.ids.hardware_rnode_atl_short.text) - except: self.app.sideband.config["hw_rnode_atl_short"] = None - - try: self.app.sideband.config["hw_rnode_atl_long"] = float(self.hardware_rnode_screen.ids.hardware_rnode_atl_long.text) - except: self.app.sideband.config["hw_rnode_atl_long"] = None - - if self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text == "": self.app.sideband.config["hw_rnode_beaconinterval"] = None - else: - try: self.app.sideband.config["hw_rnode_beaconinterval"] = int(self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text) - except: pass - - if self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text == "": self.app.sideband.config["hw_rnode_beacondata"] = None - else: self.app.sideband.config["hw_rnode_beacondata"] = self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text - - if self.hardware_rnode_screen.ids.hardware_rnode_bt_device.text == "": self.app.sideband.config["hw_rnode_bt_device"] = None - else: self.app.sideband.config["hw_rnode_bt_device"] = self.hardware_rnode_screen.ids.hardware_rnode_bt_device.text - - if self.hardware_rnode_screen.ids.hardware_rnode_tcp_host.text == "": self.app.sideband.config["hw_rnode_tcp_host"] = None - else: self.app.sideband.config["hw_rnode_tcp_host"] = self.hardware_rnode_screen.ids.hardware_rnode_tcp_host.text - - self.app.sideband.save_configuration() - - def hardware_rnode_scan_job(self): - time.sleep(1.25) - added_devices = [] - scan_timeout = time.time()+16 - def job(dt): - self.hardware_rnode_screen.ids.hardware_rnode_bt_scan_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_scan_button.text = "Scanning..." - Clock.schedule_once(job, 0.2) - while time.time() < scan_timeout: - RNS.log("Scanning...", RNS.LOG_DEBUG) - for device_addr in self.app.discovered_bt_devices: - if device_addr not in added_devices and not device_addr in self.app.bt_bonded_devices: - new_device = self.app.discovered_bt_devices[device_addr] - added_devices.append(device_addr) - RNS.log(f"Adding device: {new_device}") - def add_factory(add_device): - def add_job(dt): - pair_addr = add_device["address"] - btn_text = "Pair "+add_device["name"] - def run_pair(sender): - pair_result = self.hardware_rnode_pair_device_action(pair_addr) - if pair_result != "already_paired": - def job(): self.hardware_rnode_pair_check_job(pair_addr, add_device["name"]) - threading.Thread(target=job, daemon=True).start() - - device_button = MDRectangleFlatButton(text=btn_text, font_size=dp(16), padding=[dp(0), dp(14), dp(0), dp(14)], size_hint=[1.0, None]) - device_button.bind(on_release=run_pair) - self.hardware_rnode_screen.ids.rnode_scan_results.add_widget(device_button) - return add_job - - Clock.schedule_once(add_factory(new_device), 0.1) - - time.sleep(2) - - def job(dt): - self.hardware_rnode_screen.ids.hardware_rnode_bt_scan_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_scan_button.text = "Pair New Device" - Clock.schedule_once(job, 0.2) - - if len(added_devices) == 0: - def job(dt): toast("No unpaired RNodes discovered") - Clock.schedule_once(job, 0.2) - - def hardware_rnode_pair_check_job(self, pair_addr, device_name): - timeout = time.time()+45 - pairing_confirmed = False - while not pairing_confirmed and time.time() < timeout: - time.sleep(2) - self.app.bluetooth_update_bonded_devices() - if pair_addr in self.app.bt_bonded_devices: - pairing_confirmed = True - RNS.log(f"Pairing with {device_name} ({pair_addr}) successful", RNS.LOG_NOTICE) - def job(dt=None): toast(f"Paired with {device_name}") - Clock.schedule_once(job, 0.2) - - def hardware_rnode_pair_device_action(self, pair_addr): - RNS.log(f"Pair action for {pair_addr}", RNS.LOG_DEBUG) - self.app.stop_bluetooth_scan() - if pair_addr in self.app.bt_bonded_devices: - def job(dt): toast("Selected device already paired") - Clock.schedule_once(job, 0.1) - return "already_paired" - - else: - BluetoothSocket = autoclass('android.bluetooth.BluetoothSocket') - if self.app.bt_adapter == None: self.app.bt_adapter = BluetoothAdapter.getDefaultAdapter() - addr_bytes = bytes.fromhex(pair_addr.replace(":", "")) - remote_device = self.app.bt_adapter.getRemoteDevice(addr_bytes) - RNS.log(f"Remote device: {remote_device}", RNS.LOG_DEBUG) - remote_device.createBond() - - def hardware_rnode_bt_scan_action(self, sender=None): - self.app.discovered_bt_devices = {} - rw = [] - for child in self.hardware_rnode_screen.ids.rnode_scan_results.children: rw.append(child) - for w in rw: self.hardware_rnode_screen.ids.rnode_scan_results.remove_widget(w) - - Clock.schedule_once(self.app.bluetooth_scan_action, 0.5) - - def hardware_rnode_bt_on_action(self, sender=None): - self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = True - def re_enable(): - time.sleep(2) - while self.app.sideband.getstate("executing.bt_on"): time.sleep(1) - self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = False - threading.Thread(target=re_enable, daemon=True).start() - self.app.sideband.setstate("wants.bt_on", True) - - def hardware_rnode_bt_off_action(self, sender=None): - self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = True - def re_enable(): - time.sleep(2) - while self.app.sideband.getstate("executing.bt_off"): time.sleep(1) - self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = False - threading.Thread(target=re_enable, daemon=True).start() - self.app.sideband.setstate("wants.bt_off", True) - - def hardware_rnode_bt_pair_action(self, sender=None): - self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = True - self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = True - def re_enable(): - time.sleep(2) - while self.app.sideband.getstate("executing.bt_pair"): time.sleep(1) - self.hardware_rnode_screen.ids.hardware_rnode_bt_off_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_pair_button.disabled = False - self.hardware_rnode_screen.ids.hardware_rnode_bt_on_button.disabled = False - threading.Thread(target=re_enable, daemon=True).start() - self.app.sideband.setstate("wants.bt_pair", True) - - def hardware_rnode_bt_toggle_action(self, sender=None, event=None): - if sender.active: - self.app.sideband.config["hw_rnode_bluetooth"] = True - self.app.request_bluetooth_permissions() - else: - self.app.sideband.config["hw_rnode_bluetooth"] = False - - self.app.sideband.save_configuration() - - def hardware_rnode_ble_toggle_action(self, sender=None, event=None): - if sender.active: - self.app.sideband.config["hw_rnode_ble"] = True - self.app.request_bluetooth_permissions() - else: - self.app.sideband.config["hw_rnode_ble"] = False - - self.app.sideband.save_configuration() - - def hardware_rnode_framebuffer_toggle_action(self, sender=None, event=None): - if sender.active: - self.app.sideband.config["hw_rnode_enable_framebuffer"] = True - else: - self.app.sideband.config["hw_rnode_enable_framebuffer"] = False - - self.app.sideband.save_configuration() - - def hardware_rnode_tcp_toggle_action(self, sender=None, event=None): - if sender.active: self.app.sideband.config["hw_rnode_tcp"] = True - else: self.app.sideband.config["hw_rnode_tcp"] = False - - self.app.sideband.save_configuration() - - def hardware_rnode_init(self, sender=None): - if not self.hardware_rnode_ready: - if not self.app.root.ids.screen_manager.has_screen("hardware_rnode_screen"): - self.hardware_rnode_screen = Builder.load_string(layout_hardware_rnode_screen) - self.hardware_rnode_screen.app = self - self.app.root.ids.screen_manager.add_widget(self.hardware_rnode_screen) - - self.hardware_rnode_screen.ids.hardware_rnode_scrollview.effect_cls = ScrollEffect - def save_connectivity(sender=None, event=None): - if self.hardware_rnode_validate(): - self.hardware_rnode_save() - - def focus_save(sender=None, event=None): - if sender != None: - if not sender.focus: - save_connectivity(sender=sender) - - if self.app.sideband.config["hw_rnode_frequency"] != None: t_freq = str(self.app.sideband.config["hw_rnode_frequency"]/1000000.0) - else: t_freq = "" - - if self.app.sideband.config["hw_rnode_bandwidth"] != None: t_bw = str(self.app.sideband.config["hw_rnode_bandwidth"]/1000.0) - else: t_bw = str(62.5) - - if self.app.sideband.config["hw_rnode_tx_power"] != None: t_p = str(self.app.sideband.config["hw_rnode_tx_power"]) - else: t_p = str(0) - - if self.app.sideband.config["hw_rnode_spreading_factor"] != None: t_sf = str(self.app.sideband.config["hw_rnode_spreading_factor"]) - else: t_sf = str(8) - - if self.app.sideband.config["hw_rnode_coding_rate"] != None: t_cr = str(self.app.sideband.config["hw_rnode_coding_rate"]) - else: t_cr = str(6) - - if self.app.sideband.config["hw_rnode_beaconinterval"] != None: t_bi = str(self.app.sideband.config["hw_rnode_beaconinterval"]) - else: t_bi = "" - - if self.app.sideband.config["hw_rnode_beacondata"] != None: t_bd = str(self.app.sideband.config["hw_rnode_beacondata"]) - else: t_bd = "" - - if self.app.sideband.config["hw_rnode_bt_device"] != None: t_btd = str(self.app.sideband.config["hw_rnode_bt_device"]) - else: t_btd = "" - - if self.app.sideband.config["hw_rnode_tcp_host"] != None: t_th = str(self.app.sideband.config["hw_rnode_tcp_host"]) - else: t_th = "" - - if self.app.sideband.config["hw_rnode_atl_short"] != None: t_ats = str(self.app.sideband.config["hw_rnode_atl_short"]) - else: t_ats = "" - - if self.app.sideband.config["hw_rnode_atl_long"] != None: t_atl = str(self.app.sideband.config["hw_rnode_atl_long"]) - else: t_atl = "" - - self.hardware_rnode_screen.ids.hardware_rnode_bluetooth.active = self.app.sideband.config["hw_rnode_bluetooth"] - self.hardware_rnode_screen.ids.hardware_rnode_ble.active = self.app.sideband.config["hw_rnode_ble"] - self.hardware_rnode_screen.ids.hardware_rnode_tcp.active = self.app.sideband.config["hw_rnode_tcp"] - self.hardware_rnode_screen.ids.hardware_rnode_framebuffer.active = self.app.sideband.config["hw_rnode_enable_framebuffer"] - self.hardware_rnode_screen.ids.hardware_rnode_frequency.text = t_freq - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text = t_bw - self.hardware_rnode_screen.ids.hardware_rnode_txpower.text = t_p - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text = t_sf - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text = t_cr - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = t_bi - self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text = t_bd - self.hardware_rnode_screen.ids.hardware_rnode_bt_device.text = t_btd - self.hardware_rnode_screen.ids.hardware_rnode_tcp_host.text = t_th - self.hardware_rnode_screen.ids.hardware_rnode_atl_short.text = t_ats - self.hardware_rnode_screen.ids.hardware_rnode_atl_long.text = t_atl - self.hardware_rnode_screen.ids.hardware_rnode_frequency.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_txpower.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_beacondata.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_bt_device.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_tcp_host.bind(focus=focus_save) - self.hardware_rnode_screen.ids.hardware_rnode_frequency.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_txpower.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_beacondata.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_atl_short.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_atl_long.bind(on_text_validate=save_connectivity) - self.hardware_rnode_screen.ids.hardware_rnode_bluetooth.bind(active=self.hardware_rnode_bt_toggle_action) - self.hardware_rnode_screen.ids.hardware_rnode_ble.bind(active=self.hardware_rnode_ble_toggle_action) - self.hardware_rnode_screen.ids.hardware_rnode_framebuffer.bind(active=self.hardware_rnode_framebuffer_toggle_action) - self.hardware_rnode_screen.ids.hardware_rnode_tcp.bind(active=self.hardware_rnode_tcp_toggle_action) - - self.hardware_rnode_ready = True - - def hardware_rnode_validate(self, sender=None): - valid = True - try: - val = float(self.hardware_rnode_screen.ids.hardware_rnode_frequency.text) - if not val > 0: - raise ValueError("Invalid frequency") - self.hardware_rnode_screen.ids.hardware_rnode_frequency.error = False - self.hardware_rnode_screen.ids.hardware_rnode_frequency.text = str(val) - except: - self.hardware_rnode_screen.ids.hardware_rnode_frequency.error = True - valid = False - - try: - valid_vals = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500, 203.125, 406.25, 812.5, 1625] - val = float(self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text) - if not val in valid_vals: - raise ValueError("Invalid bandwidth") - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.error = False - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text = str(val) - except: - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.error = True - valid = False - - try: - val = int(self.hardware_rnode_screen.ids.hardware_rnode_txpower.text) - if not val >= 0: - raise ValueError("Invalid TX power") - self.hardware_rnode_screen.ids.hardware_rnode_txpower.error = False - self.hardware_rnode_screen.ids.hardware_rnode_txpower.text = str(val) - except: - self.hardware_rnode_screen.ids.hardware_rnode_txpower.error = True - valid = False - - try: - val = int(self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text) - if val < 7 or val > 12: - raise ValueError("Invalid sf") - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.error = False - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text = str(val) - except: - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.error = True - valid = False - - try: - val = int(self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text) - if val < 5 or val > 8: - raise ValueError("Invalid cr") - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.error = False - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text = str(val) - except: - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.error = True - valid = False - - try: - if self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text != "": - val = int(self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text) - if val < 10: - raise ValueError("Invalid beacon interval") - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = str(val) - - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.error = False - except: - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = "" - valid = False - - return valid - - def hardware_rnode_import(self, sender=None): - mote = None - try: - mote = Clipboard.paste() - except Exception as e: - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog( - title="Import Failed", - text="Could not read data from your clipboard, please check your system permissions.", - buttons=[ yes_button ], - # elevation=0, - ) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - dialog.open() - - try: - config = msgpack.unpackb(base64.b32decode(mote)) - self.hardware_rnode_screen.ids.hardware_rnode_frequency.text = str(config["f"]/1000000.0) - self.hardware_rnode_screen.ids.hardware_rnode_bandwidth.text = str(config["b"]/1000.0) - self.hardware_rnode_screen.ids.hardware_rnode_txpower.text = str(config["t"]) - self.hardware_rnode_screen.ids.hardware_rnode_spreadingfactor.text = str(config["s"]) - self.hardware_rnode_screen.ids.hardware_rnode_codingrate.text = str(config["c"]) - - if "n" in config and config["n"] != None: - ifn = str(config["n"]) - else: - ifn = "" - if "p" in config and config["p"] != None: - ifp = str(config["p"]) - else: - ifp = "" - - if self.app.connectivity_screen != None: - self.app.connectivity_screen.ids.connectivity_rnode_ifac_netname.text = ifn - self.app.connectivity_screen.ids.connectivity_rnode_ifac_passphrase.text = ifp - - self.app.sideband.config["connect_rnode_ifac_netname"] = ifn - self.app.sideband.config["connect_rnode_ifac_passphrase"] = ifp - - if config["i"] != None: ti = str(config["i"]) - else: ti = "" - self.hardware_rnode_screen.ids.hardware_rnode_beaconinterval.text = ti - - if config["d"] != None: td = str(config["d"]) - else: td = "" - self.hardware_rnode_screen.ids.hardware_rnode_beacondata.text = td - - if self.hardware_rnode_validate(): - self.hardware_rnode_save() - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog( - title="Configuration Imported", - text="The config mote was imported and saved as your active configuration.", - buttons=[ yes_button ], - # elevation=0, - ) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - dialog.open() - else: - raise ValueError("Invalid mote") - - except Exception as e: - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog( - title="Import Failed", - text="The read data did not contain a valid config mote. If any data was decoded, you may try to correct it by editing the relevant fields. The reported error was:\n\n"+str(e), - buttons=[ yes_button ], - # elevation=0, - ) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - dialog.open() - - def hardware_rnode_export(self, sender=None): - mote = None - try: - mote = base64.b32encode(msgpack.packb({ - "f": self.app.sideband.config["hw_rnode_frequency"], - "b": self.app.sideband.config["hw_rnode_bandwidth"], - "t": self.app.sideband.config["hw_rnode_tx_power"], - "s": self.app.sideband.config["hw_rnode_spreading_factor"], - "c": self.app.sideband.config["hw_rnode_coding_rate"], - "i": self.app.sideband.config["hw_rnode_beaconinterval"], - "d": self.app.sideband.config["hw_rnode_beacondata"], - "n": self.app.sideband.config["connect_rnode_ifac_netname"], - "p": self.app.sideband.config["connect_rnode_ifac_passphrase"], - })) - except Exception as e: - RNS.trace_exception(e) - - if mote != None: - Clipboard.copy(mote) - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog( - title="Configuration Exported", - text="The config mote was created and copied to your clipboard.", - buttons=[ yes_button ], - # elevation=0, - ) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - dialog.open() - else: - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog( - title="Export Failed", - text="The config mote could not be created, please check your settings.", - buttons=[ yes_button ], - # elevation=0, - ) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - dialog.open() - - ## Modem hardware screen - - def hardware_modem_action(self, sender=None, direction="left"): - if self.hardware_modem_ready: - self.hardware_modem_open(direction=direction) - else: - self.app.loader_action(direction=direction) - def final(dt): - self.hardware_modem_init() - def o(dt): - self.hardware_modem_open(no_transition=True) - Clock.schedule_once(o, ll_ot) - Clock.schedule_once(final, ll_ft) - - def hardware_modem_open(self, sender=None, direction="left", no_transition=False): - if no_transition: - self.app.root.ids.screen_manager.transition = self.no_transition - else: - self.app.root.ids.screen_manager.transition = self.slide_transition - self.app.root.ids.screen_manager.transition.direction = direction - - self.hardware_modem_init() - self.app.root.ids.screen_manager.transition.direction = "left" - self.app.root.ids.screen_manager.current = "hardware_modem_screen" - self.app.root.ids.nav_drawer.set_state("closed") - self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current) - - if no_transition: - self.app.root.ids.screen_manager.transition = self.slide_transition - - def hardware_modem_init(self, sender=None): - if not self.hardware_modem_ready: - if not self.app.root.ids.screen_manager.has_screen("hardware_modem_screen"): - self.hardware_modem_screen = Builder.load_string(layout_hardware_modem_screen) - self.hardware_modem_screen.app = self - self.app.root.ids.screen_manager.add_widget(self.hardware_modem_screen) - - self.hardware_modem_screen.ids.hardware_modem_scrollview.effect_cls = ScrollEffect - def save_connectivity(sender=None, event=None): - if self.hardware_modem_validate(): - self.hardware_modem_save() - - def focus_save(sender=None, event=None): - if sender != None: - if not sender.focus: - save_connectivity(sender=sender) - - if self.app.sideband.config["hw_modem_baudrate"] != None: - t_b = str(self.app.sideband.config["hw_modem_baudrate"]) - else: - t_b = "" - - if self.app.sideband.config["hw_modem_databits"] != None: - t_db = str(self.app.sideband.config["hw_modem_databits"]) - else: - t_db = "" - - if self.app.sideband.config["hw_modem_parity"] != None: - t_p = str(self.app.sideband.config["hw_modem_parity"]) - else: - t_p = "" - - if self.app.sideband.config["hw_modem_stopbits"] != None: - t_sb = str(self.app.sideband.config["hw_modem_stopbits"]) - else: - t_sb = "" - - if self.app.sideband.config["hw_modem_preamble"] != None: - t_pa = str(self.app.sideband.config["hw_modem_preamble"]) - else: - t_pa = "" - - if self.app.sideband.config["hw_modem_tail"] != None: - t_t = str(self.app.sideband.config["hw_modem_tail"]) - else: - t_t = "" - - if self.app.sideband.config["hw_modem_persistence"] != None: - t_ps = str(self.app.sideband.config["hw_modem_persistence"]) - else: - t_ps = "" - - if self.app.sideband.config["hw_modem_slottime"] != None: - t_st = str(self.app.sideband.config["hw_modem_slottime"]) - else: - t_st = "" - - if self.app.sideband.config["hw_modem_beaconinterval"] != None: - t_bi = str(self.app.sideband.config["hw_modem_beaconinterval"]) - else: - t_bi = "" - if self.app.sideband.config["hw_modem_beacondata"] != None: - t_bd = str(self.app.sideband.config["hw_modem_beacondata"]) - else: - t_bd = "" - - self.hardware_modem_screen.ids.hardware_modem_baudrate.text = t_b - self.hardware_modem_screen.ids.hardware_modem_databits.text = t_db - self.hardware_modem_screen.ids.hardware_modem_parity.text = t_p - self.hardware_modem_screen.ids.hardware_modem_stopbits.text = t_sb - self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text = t_bi - self.hardware_modem_screen.ids.hardware_modem_beacondata.text = t_bd - self.hardware_modem_screen.ids.hardware_modem_preamble.text = t_pa - self.hardware_modem_screen.ids.hardware_modem_tail.text = t_t - self.hardware_modem_screen.ids.hardware_modem_persistence.text = t_ps - self.hardware_modem_screen.ids.hardware_modem_slottime.text = t_st - self.hardware_modem_screen.ids.hardware_modem_baudrate.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_databits.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_parity.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_stopbits.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_beaconinterval.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_beacondata.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_preamble.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_tail.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_persistence.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_slottime.bind(focus=focus_save) - self.hardware_modem_screen.ids.hardware_modem_baudrate.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_databits.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_parity.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_stopbits.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_beaconinterval.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_beacondata.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_preamble.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_tail.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_persistence.bind(on_text_validate=save_connectivity) - self.hardware_modem_screen.ids.hardware_modem_slottime.bind(on_text_validate=save_connectivity) - - self.hardware_modem_ready = True - - def hardware_modem_save(self): - self.app.sideband.config["hw_modem_baudrate"] = int(self.hardware_modem_screen.ids.hardware_modem_baudrate.text) - self.app.sideband.config["hw_modem_databits"] = int(self.hardware_modem_screen.ids.hardware_modem_databits.text) - self.app.sideband.config["hw_modem_parity"] = self.hardware_modem_screen.ids.hardware_modem_parity.text - self.app.sideband.config["hw_modem_stopbits"] = int(self.hardware_modem_screen.ids.hardware_modem_stopbits.text) - self.app.sideband.config["hw_modem_preamble"] = int(self.hardware_modem_screen.ids.hardware_modem_preamble.text) - self.app.sideband.config["hw_modem_tail"] = int(self.hardware_modem_screen.ids.hardware_modem_tail.text) - self.app.sideband.config["hw_modem_persistence"] = int(self.hardware_modem_screen.ids.hardware_modem_persistence.text) - self.app.sideband.config["hw_modem_slottime"] = int(self.hardware_modem_screen.ids.hardware_modem_slottime.text) - - if self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text == "": - self.app.sideband.config["hw_modem_beaconinterval"] = None - else: - self.app.sideband.config["hw_modem_beaconinterval"] = int(self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text) - - if self.hardware_modem_screen.ids.hardware_modem_beacondata.text == "": - self.app.sideband.config["hw_modem_beacondata"] = None - else: - self.app.sideband.config["hw_modem_beacondata"] = self.hardware_modem_screen.ids.hardware_modem_beacondata.text - - self.app.sideband.save_configuration() - - def hardware_modem_validate(self, sender=None): - valid = True - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_baudrate.text) - if not val > 0: - raise ValueError("Invalid baudrate") - self.hardware_modem_screen.ids.hardware_modem_baudrate.error = False - self.hardware_modem_screen.ids.hardware_modem_baudrate.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_baudrate.error = True - valid = False - - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_databits.text) - if not val > 0: - raise ValueError("Invalid databits") - self.hardware_modem_screen.ids.hardware_modem_databits.error = False - self.hardware_modem_screen.ids.hardware_modem_databits.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_databits.error = True - valid = False - - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_stopbits.text) - if not val > 0: - raise ValueError("Invalid stopbits") - self.hardware_modem_screen.ids.hardware_modem_stopbits.error = False - self.hardware_modem_screen.ids.hardware_modem_stopbits.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_stopbits.error = True - valid = False - - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_preamble.text) - if not (val >= 0 and val <= 1000): - raise ValueError("Invalid preamble") - self.hardware_modem_screen.ids.hardware_modem_preamble.error = False - self.hardware_modem_screen.ids.hardware_modem_preamble.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_preamble.error = True - valid = False - - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_tail.text) - if not (val > 0 and val <= 500): - raise ValueError("Invalid tail") - self.hardware_modem_screen.ids.hardware_modem_tail.error = False - self.hardware_modem_screen.ids.hardware_modem_tail.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_tail.error = True - valid = False - - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_slottime.text) - if not (val > 0 and val <= 500): - raise ValueError("Invalid slottime") - self.hardware_modem_screen.ids.hardware_modem_slottime.error = False - self.hardware_modem_screen.ids.hardware_modem_slottime.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_slottime.error = True - valid = False - - try: - val = int(self.hardware_modem_screen.ids.hardware_modem_persistence.text) - if not (val > 0 and val <= 255): - raise ValueError("Invalid persistence") - self.hardware_modem_screen.ids.hardware_modem_persistence.error = False - self.hardware_modem_screen.ids.hardware_modem_persistence.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_persistence.error = True - valid = False - - try: - val = self.hardware_modem_screen.ids.hardware_modem_parity.text - nval = val.lower() - if nval in ["e", "ev", "eve", "even"]: - val = "even" - if nval in ["o", "od", "odd"]: - val = "odd" - if nval in ["n", "no", "non", "none", "not", "null", "off"]: - val = "none" - if not val in ["even", "odd", "none"]: - raise ValueError("Invalid parity") - self.hardware_modem_screen.ids.hardware_modem_parity.error = False - self.hardware_modem_screen.ids.hardware_modem_parity.text = str(val) - except: - self.hardware_modem_screen.ids.hardware_modem_parity.error = True - valid = False - - try: - if self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text != "": - val = int(self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text) - if val < 10: - raise ValueError("Invalid bi") - self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text = str(val) - - self.hardware_modem_screen.ids.hardware_modem_beaconinterval.error = False - except: - self.hardware_modem_screen.ids.hardware_modem_beaconinterval.text = "" - valid = False - - return valid - - ## Serial hardware screen - def hardware_serial_action(self, sender=None, direction="left"): - if self.hardware_serial_ready: - self.hardware_serial_open(direction=direction) - else: - self.app.loader_action(direction=direction) - def final(dt): - self.hardware_serial_init() - def o(dt): - self.hardware_serial_open(no_transition=True) - Clock.schedule_once(o, ll_ot) - Clock.schedule_once(final, ll_ft) - - def hardware_serial_open(self, sender=None, direction="left", no_transition=False): - if no_transition: - self.app.root.ids.screen_manager.transition = self.no_transition - else: - self.app.root.ids.screen_manager.transition = self.slide_transition - self.app.root.ids.screen_manager.transition.direction = direction - - self.app.root.ids.screen_manager.transition.direction = "left" - self.app.root.ids.screen_manager.current = "hardware_serial_screen" - self.app.root.ids.nav_drawer.set_state("closed") - self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current) - - if no_transition: - self.app.root.ids.screen_manager.transition = self.slide_transition - - def hardware_serial_init(self, sender=None): - if not self.hardware_serial_ready: - if not self.app.root.ids.screen_manager.has_screen("hardware_serial_screen"): - self.hardware_serial_screen = Builder.load_string(layout_hardware_serial_screen) - self.hardware_serial_screen.app = self - self.app.root.ids.screen_manager.add_widget(self.hardware_serial_screen) - - self.hardware_serial_screen.ids.hardware_serial_scrollview.effect_cls = ScrollEffect - def save_connectivity(sender=None, event=None): - if self.hardware_serial_validate(): - self.hardware_serial_save() - - def focus_save(sender=None, event=None): - if sender != None: - if not sender.focus: - save_connectivity(sender=sender) - - if self.app.sideband.config["hw_serial_baudrate"] != None: - t_b = str(self.app.sideband.config["hw_serial_baudrate"]) - else: - t_b = "" - - if self.app.sideband.config["hw_serial_databits"] != None: - t_db = str(self.app.sideband.config["hw_serial_databits"]) - else: - t_db = "" - - if self.app.sideband.config["hw_serial_parity"] != None: - t_p = str(self.app.sideband.config["hw_serial_parity"]) - else: - t_p = "" - - if self.app.sideband.config["hw_serial_stopbits"] != None: - t_sb = str(self.app.sideband.config["hw_serial_stopbits"]) - else: - t_sb = "" - - self.hardware_serial_screen.ids.hardware_serial_baudrate.text = t_b - self.hardware_serial_screen.ids.hardware_serial_databits.text = t_db - self.hardware_serial_screen.ids.hardware_serial_parity.text = t_p - self.hardware_serial_screen.ids.hardware_serial_stopbits.text = t_sb - self.hardware_serial_screen.ids.hardware_serial_baudrate.bind(focus=focus_save) - self.hardware_serial_screen.ids.hardware_serial_databits.bind(focus=focus_save) - self.hardware_serial_screen.ids.hardware_serial_parity.bind(focus=focus_save) - self.hardware_serial_screen.ids.hardware_serial_stopbits.bind(focus=focus_save) - self.hardware_serial_screen.ids.hardware_serial_baudrate.bind(on_text_validate=save_connectivity) - self.hardware_serial_screen.ids.hardware_serial_databits.bind(on_text_validate=save_connectivity) - self.hardware_serial_screen.ids.hardware_serial_parity.bind(on_text_validate=save_connectivity) - self.hardware_serial_screen.ids.hardware_serial_stopbits.bind(on_text_validate=save_connectivity) - - self.hardware_serial_ready = True - - def hardware_serial_validate(self, sender=None): - valid = True - try: - val = int(self.hardware_serial_screen.ids.hardware_serial_baudrate.text) - if not val > 0: - raise ValueError("Invalid baudrate") - self.hardware_serial_screen.ids.hardware_serial_baudrate.error = False - self.hardware_serial_screen.ids.hardware_serial_baudrate.text = str(val) - except: - self.hardware_serial_screen.ids.hardware_serial_baudrate.error = True - valid = False - - try: - val = int(self.hardware_serial_screen.ids.hardware_serial_databits.text) - if not val > 0: - raise ValueError("Invalid databits") - self.hardware_serial_screen.ids.hardware_serial_databits.error = False - self.hardware_serial_screen.ids.hardware_serial_databits.text = str(val) - except: - self.hardware_serial_screen.ids.hardware_serial_databits.error = True - valid = False - - try: - val = int(self.hardware_serial_screen.ids.hardware_serial_stopbits.text) - if not val > 0: - raise ValueError("Invalid stopbits") - self.hardware_serial_screen.ids.hardware_serial_stopbits.error = False - self.hardware_serial_screen.ids.hardware_serial_stopbits.text = str(val) - except: - self.hardware_serial_screen.ids.hardware_serial_stopbits.error = True - valid = False - - try: - val = self.hardware_serial_screen.ids.hardware_serial_parity.text - nval = val.lower() - if nval in ["e", "ev", "eve", "even"]: - val = "even" - if nval in ["o", "od", "odd"]: - val = "odd" - if nval in ["n", "no", "non", "none", "not", "null", "off"]: - val = "none" - if not val in ["even", "odd", "none"]: - raise ValueError("Invalid parity") - self.hardware_serial_screen.ids.hardware_serial_parity.error = False - self.hardware_serial_screen.ids.hardware_serial_parity.text = str(val) - except: - self.hardware_serial_screen.ids.hardware_serial_parity.error = True - valid = False - - return valid - - def hardware_serial_save(self): - self.app.sideband.config["hw_serial_baudrate"] = int(self.hardware_serial_screen.ids.hardware_serial_baudrate.text) - self.app.sideband.config["hw_serial_databits"] = int(self.hardware_serial_screen.ids.hardware_serial_databits.text) - self.app.sideband.config["hw_serial_parity"] = self.hardware_serial_screen.ids.hardware_serial_parity.text - self.app.sideband.config["hw_serial_stopbits"] = int(self.hardware_serial_screen.ids.hardware_serial_stopbits.text) - - self.app.sideband.save_configuration() - -layout_hardware_screen = """ -MDScreen: - name: "hardware_screen" - - BoxLayout: - orientation: "vertical" - - MDTopAppBar: - title: "Hardware" - anchor_title: "left" - elevation: 0 - left_action_items: - [['menu', lambda x: root.app.app.nav_drawer.set_state("open")]] - right_action_items: - [ - ['close', lambda x: root.app.app.close_hardware_action(self)], - ] - - ScrollView: - id: hardware_scrollview - - MDBoxLayout: - orientation: "vertical" - spacing: "8dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(28), dp(48), dp(28), dp(16)] - - MDLabel: - text: "Configure Hardware Parameters\\n" - font_style: "H6" - - MDLabel: - id: hardware_info - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(35), dp(0), dp(35)] - - MDRectangleFlatIconButton: - id: hardware_rnode_button - icon: "radio-handheld" - text: "RNode" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_action(self) - - MDRectangleFlatIconButton: - id: hardware_modem_button - icon: "router-wireless" - text: "Radio Modem" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_modem_action(self) - disabled: False - - MDRectangleFlatIconButton: - id: hardware_serial_button - icon: "cable-data" - text: "Serial Port" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_serial_action(self) - disabled: False -""" - -layout_hardware_modem_screen = """ -MDScreen: - name: "hardware_modem_screen" - - BoxLayout: - orientation: "vertical" - - MDTopAppBar: - title: "Radio Modem" - anchor_title: "left" - elevation: 0 - left_action_items: - [['menu', lambda x: root.app.app.nav_drawer.set_state("open")]] - right_action_items: - [ - ['close', lambda x: root.app.app.close_sub_hardware_action(self)], - ] - - ScrollView: - id: hardware_modem_scrollview - - MDBoxLayout: - orientation: "vertical" - spacing: "8dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(28), dp(48), dp(28), dp(16)] - - MDLabel: - text: "Modem Hardware Parameters\\n" - font_style: "H6" - - MDLabel: - id: hardware_modem_info - markup: True - text: "To communicate using a Radio Modem, you will need to specify the following parameters. Serial port parameters must be set to match those of the modem. CSMA parameters can be left at their default values in most cases.\\n" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - text: "Port Options" - font_style: "H6" - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_modem_baudrate - hint_text: "Baud Rate" - text: "" - font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(24)] - - MDTextField: - id: hardware_modem_databits - hint_text: "Data Bits" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_modem_parity - hint_text: "Parity" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_modem_stopbits - hint_text: "Stop Bits" - text: "" - font_size: dp(24) - - MDLabel: - text: "CSMA Parameters" - font_style: "H6" - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(0)] - - MDTextField: - id: hardware_modem_preamble - hint_text: "Preamble (ms)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_modem_tail - hint_text: "TX Tail (ms)" - text: "" - font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(24)] - - MDTextField: - id: hardware_modem_persistence - hint_text: "Persistence (1-255)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_modem_slottime - hint_text: "Slot Time (ms)" - text: "" - font_size: dp(24) - - MDLabel: - text: "Optional Settings" - font_style: "H6" - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_modem_beaconinterval - hint_text: "Beacon Interval (seconds)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_modem_beacondata - hint_text: "Beacon Data" - text: "" - font_size: dp(24) -""" - -layout_hardware_rnode_screen = """ -MDScreen: - name: "hardware_rnode_screen" - - BoxLayout: - orientation: "vertical" - - MDTopAppBar: - title: "RNode" - anchor_title: "left" - elevation: 0 - left_action_items: - [['menu', lambda x: root.app.app.nav_drawer.set_state("open")]] - right_action_items: - [ - ['close', lambda x: root.app.app.close_sub_hardware_action(self)], - ] - - ScrollView: - id: hardware_rnode_scrollview - - MDBoxLayout: - orientation: "vertical" - spacing: "8dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(28), dp(48), dp(28), dp(16)] - - MDLabel: - text: "RNode Hardware Parameters\\n" - font_style: "H6" - - MDLabel: - id: hardware_rnode_info - markup: True - text: "To communicate using an RNode, you will need to specify the following parameters. For two or more RNodes to be able to communicate, all parameters must match, except for the [i]Coding Rate[/i] and [i]TX Power[/i] parameter, which can vary between devices.\\n" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(48)] - - MDRectangleFlatIconButton: - id: rnode_mote_export - icon: "upload" - text: "Export" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_export(self) - - MDRectangleFlatIconButton: - id: rnode_mote_import - icon: "download" - text: "Import" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_import(self) - - MDLabel: - text: "Radio Options\\n" - font_style: "H6" - - # MDTextField: - # id: hardware_rnode_modulation - # hint_text: "Modulation" - # text: "LoRa" - # disabled: True - # font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_rnode_frequency - hint_text: "Frequency (MHz)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_rnode_bandwidth - hint_text: "Bandwidth (KHz)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_rnode_txpower - hint_text: "TX Power (dBm)" - text: "" - font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(24)] - - MDTextField: - id: hardware_rnode_spreadingfactor - hint_text: "Spreading Factor" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_rnode_codingrate - hint_text: "Coding Rate" - text: "" - font_size: dp(24) - - MDLabel: - text: "Optional Settings" - font_style: "H6" - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_rnode_beaconinterval - hint_text: "Beacon Interval (seconds)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_rnode_beacondata - hint_text: "Beacon Data" - text: "" - font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_rnode_atl_short - hint_text: "Airime Limit % (15s)" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_rnode_atl_long - hint_text: "Airime Limit % (1h)" - text: "" - font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - size_hint_y: None - padding: [0,dp(14),dp(24),dp(0)] - height: dp(48) - - MDLabel: - text: "Control RNode Display" - font_style: "H6" - - MDSwitch: - id: hardware_rnode_framebuffer - pos_hint: {"center_y": 0.3} - active: False - - MDLabel: - text: "•" - font_style: "H6" - text_size: self.size - halign: "center" - size_hint_y: None - height: self.texture_size[1] - padding: [0, dp(2+14), 0, dp(22+24)] - - MDLabel: - text: "WiFi & Ethernet Connection\\n" - font_style: "H6" - - MDLabel: - id: hardware_rnode_info_wifi - markup: True - text: "If your device is hosting or connected to a WiFi network, you can connect it by entering its IP address or hostname below." - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDBoxLayout: - orientation: "horizontal" - size_hint_y: None - padding: [0,dp(12),dp(24),dp(0)] - height: dp(36) - - MDLabel: - text: "Connect using WiFi" - font_style: "H6" - - MDSwitch: - id: hardware_rnode_tcp - pos_hint: {"center_y": 0.3} - active: False - - MDBoxLayout: - orientation: "vertical" - # spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_rnode_tcp_host - hint_text: "RNode IP address or hostname" - text: "" - font_size: dp(24) - - MDLabel: - text: "•" - font_style: "H6" - text_size: self.size - halign: "center" - size_hint_y: None - height: self.texture_size[1] - padding: [0, dp(2+14), 0, dp(22+24)] - - MDLabel: - text: "Bluetooth Connection\\n" - font_style: "H6" - - MDLabel: - id: hardware_rnode_info - markup: True - text: "If you enable connection via Bluetooth, Sideband will attempt to connect to any available and paired RNodes over Bluetooth.\\n\\nIf your RNode uses BLE (ESP32-S3 and nRF devices) instead of classic Bluetooth, enable the [i]Device requires BLE[/i] option as well." - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDBoxLayout: - orientation: "horizontal" - size_hint_y: None - padding: [0,0,dp(24),dp(0)] - height: dp(48) - - MDLabel: - text: "Connect using Bluetooth" - font_style: "H6" - - MDSwitch: - id: hardware_rnode_bluetooth - pos_hint: {"center_y": 0.3} - active: False - - MDBoxLayout: - orientation: "horizontal" - size_hint_y: None - padding: [0,0,dp(24),dp(48)] - height: dp(86) - - MDLabel: - text: "Device requires BLE" - font_style: "H6" - - MDSwitch: - id: hardware_rnode_ble - pos_hint: {"center_y": 0.3} - active: False - - MDLabel: - text: "Bluetooth Pairing\\n" - font_style: "H6" - - MDLabel: - id: hardware_rnode_info - markup: True - text: "To put an RNode into pairing mode, hold down the multi-function user button for more than 5 seconds, and release it. The display will indicate pairing mode. If the in-app pairing does not find any devices, use the Bluetooth settings of your device to scan and pair.\\n" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(12)] - - MDRectangleFlatIconButton: - id: hardware_rnode_bt_scan_button - icon: "bluetooth-connect" - text: "Pair New Device" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_bt_scan_action(self) - - MDBoxLayout: - id: rnode_scan_results - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(12+24)] - - MDLabel: - text: "Preffered Bluetooth Device\\n" - font_style: "H6" - - MDLabel: - id: hardware_rnode_info - markup: True - text: "Sideband will connect to the first available RNode that is paired. If you want to always use a specific RNode, you can enter its name here, for example \\"RNode A8EB\\"." - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_rnode_bt_device - hint_text: "Preferred RNode Device Name" - text: "" - font_size: dp(24) - - MDLabel: - text: "•" - font_style: "H6" - text_size: self.size - halign: "center" - size_hint_y: None - height: self.texture_size[1] - padding: [0, dp(2+14), 0, dp(22)] - - MDLabel: - text: "\\n\\nDevice Bluetooth Control\\n" - font_style: "H6" - - MDLabel: - id: hardware_rnode_info - markup: True - text: "\\n\\nIf your RNode does not have a physical pairing button, you can enable Bluetooth and put it into pairing mode by first connecting it via a USB cable, and using the buttons below. When plugging in the RNode over USB, you must grant Sideband permission to the USB device for this to work.\\n\\nYou can also change Bluetooth settings using the \\"rnodeconf\\" utility from a computer.\\n" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDRectangleFlatIconButton: - id: hardware_rnode_bt_on_button - icon: "bluetooth" - text: "Enable Bluetooth" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_bt_on_action(self) - - MDRectangleFlatIconButton: - id: hardware_rnode_bt_off_button - icon: "bluetooth-off" - text: "Disable Bluetooth" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_bt_off_action(self) - disabled: False - - MDRectangleFlatIconButton: - id: hardware_rnode_bt_pair_button - icon: "link-variant" - text: "Start Pairing Mode" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.hardware_rnode_bt_pair_action(self) - disabled: False -""" - -layout_hardware_serial_screen = """ -MDScreen: - name: "hardware_serial_screen" - - BoxLayout: - orientation: "vertical" - - MDTopAppBar: - title: "Serial Port" - anchor_title: "left" - elevation: 0 - left_action_items: - [['menu', lambda x: root.app.app.nav_drawer.set_state("open")]] - right_action_items: - [ - ['close', lambda x: root.app.app.close_sub_hardware_action(self)], - ] - - ScrollView: - id: hardware_serial_scrollview - - MDBoxLayout: - orientation: "vertical" - spacing: "8dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(28), dp(48), dp(28), dp(16)] - - MDLabel: - text: "Serial Hardware Parameters\\n" - font_style: "H6" - - MDLabel: - id: hardware_serial_info - markup: True - text: "To communicate using a serial port, you will need to specify the following parameters. If communicating directly to another Reticulum instance over serial, the parameters must match the other device.\\n\\nIf you are using a serial-connected device to pass on data to other Reticulum instances, it should be configured to pass data transparently to the desired endpoints.\\n" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDLabel: - text: "Port Options" - font_style: "H6" - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - # padding: [dp(0), dp(0), dp(0), dp(35)] - - MDTextField: - id: hardware_serial_baudrate - hint_text: "Baud Rate" - text: "" - font_size: dp(24) - - MDBoxLayout: - orientation: "horizontal" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(24)] - - MDTextField: - id: hardware_serial_databits - hint_text: "Data Bits" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_serial_parity - hint_text: "Parity" - text: "" - font_size: dp(24) - - MDTextField: - id: hardware_serial_stopbits - hint_text: "Stop Bits" - text: "" - font_size: dp(24) -""" diff --git a/sbapp/ui/helpers.py b/sbapp/ui/helpers.py index 3f2f3fc..5b43278 100644 --- a/sbapp/ui/helpers.py +++ b/sbapp/ui/helpers.py @@ -6,7 +6,6 @@ from kivymd.uix.list import OneLineIconListItem, MDList, IconLeftWidget, IconRig from kivy.properties import StringProperty import re -ts_format_date = "%Y-%m-%d" ts_format = "%Y-%m-%d %H:%M:%S" file_ts_format = "%Y_%m_%d_%H_%M_%S" @@ -43,8 +42,6 @@ color_failed_alt = "Red" color_unknown_alt = "Gray" color_cancelled_alt = "Red" -dark_theme_text_color = "ddd" - class ContentNavigationDrawer(Screen): pass diff --git a/sbapp/ui/keys.py b/sbapp/ui/keys.py deleted file mode 100644 index 5b5e8aa..0000000 --- a/sbapp/ui/keys.py +++ /dev/null @@ -1,270 +0,0 @@ -import time -import RNS - -import base64 -import threading -from kivy.metrics import dp,sp -from kivy.lang.builder import Builder -from kivy.core.clipboard import Clipboard -from kivymd.uix.button import MDRectangleFlatButton -from kivymd.uix.dialog import MDDialog -from kivymd.toast import toast -from kivy.effects.scroll import ScrollEffect - -if RNS.vendor.platformutils.get_platform() == "android": - from ui.helpers import dark_theme_text_color -else: - from .helpers import dark_theme_text_color - -class Keys(): - def __init__(self, app): - self.app = app - self.keys_screen = None - - if not self.app.root.ids.screen_manager.has_screen("keys_screen"): - self.keys_screen = Builder.load_string(layout_keys_screen) - self.keys_screen.app = self - self.app.root.ids.screen_manager.add_widget(self.keys_screen) - self.app.bind_clipboard_actions(self.keys_screen.ids) - - self.keys_screen.ids.keys_scrollview.effect_cls = ScrollEffect - info1 = "[size=18dp][b]Encryption Keys[/b][/size][size=5dp]\n \n[/size]Your primary encryption keys are stored in a Reticulum Identity within the Sideband app. If you want to backup this Identity for later use on this or another device, you can export it as a plain text blob, with the key data encoded in Base32 format. This will allow you to restore your address in Sideband or other LXMF clients at a later point.\n\n[b]Warning![/b] Anyone that gets access to the key data will be able to control your LXMF address, impersonate you, and read your messages. It is [b]extremely important[/b] that you keep the Identity data secure if you export it.\n\nBefore displaying or exporting your Identity data, make sure that no machine or person in your vicinity is able to see, copy or record your device screen or similar." - info2 = "[size=18dp][b]Backup & Restore[/b][/size][size=5dp]\n \n[/size]You can backup your entire Sideband profile for import on a computer or other device. The exported backup archive will be saved in the downloads folder of your device. Please note that the exported archive contains all your messages, data and encryption keys. Take extreme care to keep this archive secure." - - if not RNS.vendor.platformutils.get_platform() == "android": - self.app.widget_hide(self.keys_screen.ids.keys_share) - - self.keys_screen.ids.keys_info.text = info1 - self.keys_screen.ids.backup_info.text = info2 - - - def _profile_backup_job(self): - import tarfile - import plyer - from .helpers import file_ts_format - from kivy.clock import Clock - output_path = plyer.storagepath.get_downloads_dir() - time_str = time.strftime(file_ts_format, time.localtime(time.time())) - target_file = f"{output_path}/sideband_backup_{time_str}.tar.gz" - tar = tarfile.open(target_file, "w:gz") - tar.add(f"{self.app.sideband.app_dir}/app_storage", arcname="Sideband Backup") - tar.close() - - def job(dt): - self.keys_screen.ids.keys_backup.disabled = False - toast("Backup done") - ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog(text=f"Backup has been saved to {target_file}", - buttons=[ok_button]) - def dl_yes(s): dialog.dismiss() - - ok_button.bind(on_release=dl_yes) - dialog.open() - - Clock.schedule_once(job, 0.3) - - def profile_backup_action(self, sender=None): - self.keys_screen.ids.keys_backup.disabled = True - toast("Creating backup...") - threading.Thread(target=self._profile_backup_job, daemon=True).start() - - def identity_display_action(self, sender=None): - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - - dialog = MDDialog( - text="Your Identity key, in base32 format is as follows:\n\n[b]"+str(base64.b32encode(self.app.sideband.identity.get_private_key()).decode("utf-8"))+"[/b]", - buttons=[ yes_button ], - # elevation=0, - ) - def dl_yes(s): - dialog.dismiss() - - yes_button.bind(on_release=dl_yes) - dialog.open() - - def identity_copy_action(self, sender=None): - c_yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) - c_no_button = MDRectangleFlatButton(text="No, go back",font_size=dp(18)) - c_dialog = MDDialog(text="[b]Caution![/b]\n\nYour Identity key will be copied to the system clipboard. Take extreme care that no untrusted app steals your key by reading the clipboard data. Clear the system clipboard immediately after pasting your key where you need it.\n\nAre you sure that you wish to proceed?", buttons=[ c_no_button, c_yes_button ]) - def c_dl_no(s): - c_dialog.dismiss() - def c_dl_yes(s): - c_dialog.dismiss() - yes_button = MDRectangleFlatButton(text="OK") - dialog = MDDialog(text="Your Identity key was copied to the system clipboard", buttons=[ yes_button ]) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - - Clipboard.copy(str(base64.b32encode(self.app.sideband.identity.get_private_key()).decode("utf-8"))) - dialog.open() - - c_yes_button.bind(on_release=c_dl_yes) - c_no_button.bind(on_release=c_dl_no) - - c_dialog.open() - - def identity_share_action(self, sender=None): - if RNS.vendor.platformutils.get_platform() == "android": - self.share_text(str(base64.b32encode(self.app.sideband.identity.get_private_key()).decode("utf-8"))) - - def identity_restore_action(self, sender=None): - c_yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject) - c_no_button = MDRectangleFlatButton(text="No, go back",font_size=dp(18)) - c_dialog = MDDialog(text="[b]Caution![/b]\n\nYou are about to import a new Identity key into Sideband. The currently active key will be irreversibly destroyed, and you will loose your LXMF address if you have not already backed up your current Identity key.\n\nAre you sure that you wish to import the key?", buttons=[ c_no_button, c_yes_button ]) - def c_dl_no(s): - c_dialog.dismiss() - def c_dl_yes(s): - c_dialog.dismiss() - b32_text = self.keys_screen.ids.key_restore_text.text - - try: - key_bytes = base64.b32decode(b32_text) - new_id = RNS.Identity.from_bytes(key_bytes) - - if new_id != None: - new_id.to_file(self.app.sideband.identity_path) - - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog(text="[b]The provided Identity key data was imported[/b]\n\nThe app will now exit. Please restart Sideband to use the new Identity.", buttons=[ yes_button ]) - def dl_yes(s): - dialog.dismiss() - self.app.quit_action(sender=self) - yes_button.bind(on_release=dl_yes) - dialog.open() - - except Exception as e: - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog(text="[b]The provided Identity key data was not valid[/b]\n\nThe error reported by Reticulum was:\n\n[i]"+str(e)+"[/i]\n\nNo Identity was imported into Sideband.", buttons=[ yes_button ]) - def dl_yes(s): - dialog.dismiss() - yes_button.bind(on_release=dl_yes) - dialog.open() - - - c_yes_button.bind(on_release=c_dl_yes) - c_no_button.bind(on_release=c_dl_no) - - c_dialog.open() - - -layout_keys_screen = """ -MDScreen: - name: "keys_screen" - - BoxLayout: - orientation: "vertical" - - MDTopAppBar: - title: "Backup & Keys" - anchor_title: "left" - elevation: 0 - left_action_items: - [['menu', lambda x: root.app.nav_drawer.set_state("open")]] - right_action_items: - [ - ['close', lambda x: root.app.close_keys_action(self)], - ] - - ScrollView: - id:keys_scrollview - - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(35), dp(35), dp(35), dp(35)] - - - MDLabel: - id: backup_info - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDRectangleFlatIconButton: - id: keys_backup - icon: "home-export-outline" - text: "Backup Sideband Profile" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.profile_backup_action(self) - - MDRectangleFlatIconButton: - id: keys_restore - icon: "home-import-outline" - text: "Restore Sideband Profile" - disabled: True - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.profile_backup_action(self) - - MDLabel: - id: keys_info - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] - - MDRectangleFlatIconButton: - id: keys_display - icon: "eye" - text: "Display Identity Key" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.identity_display_action(self) - - MDRectangleFlatIconButton: - id: keys_copy - icon: "file-key" - text: "Copy Key To Clipboard" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.identity_copy_action(self) - - MDRectangleFlatIconButton: - id: keys_share - icon: "upload-lock" - text: "Send Key To Other App" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.identity_share_action(self) - - MDBoxLayout: - orientation: "vertical" - # spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(12), dp(0), dp(0)] - - MDTextField: - id: key_restore_text - hint_text: "Enter base32 key for import" - mode: "rectangle" - # size_hint: [1.0, None] - pos_hint: {"center_x": .5} - - MDRectangleFlatIconButton: - id: keys_restore - icon: "download-lock" - text: "Restore Identity From Key" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.identity_restore_action(self) -""" \ No newline at end of file diff --git a/sbapp/ui/layouts.py b/sbapp/ui/layouts.py index 7faf7a4..e520c34 100644 --- a/sbapp/ui/layouts.py +++ b/sbapp/ui/layouts.py @@ -163,9 +163,9 @@ MDNavigationLayout: OneLineIconListItem: - text: "Backup & Keys" + text: "Encryption Keys" on_release: root.ids.screen_manager.app.keys_action(self) - + IconLeftWidget: icon: "key-chain" on_release: root.ids.screen_manager.app.keys_action(self) @@ -360,23 +360,6 @@ MDScreen: text_size: self.width, None height: self.texture_size[1] - MDBoxLayout: - id: connectivity_service_restart_fields - orientation: "vertical" - size_hint_y: None - height: self.minimum_height - padding: [0, 0, 0, dp(32)] - - MDRectangleFlatIconButton: - id: button_service_restart - icon: "restart" - text: "Restart RNS Service" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.app.restart_service_action(self) - MDBoxLayout: orientation: "horizontal" padding: [0,0,dp(24),0] @@ -626,44 +609,6 @@ MDScreen: font_size: dp(24) - MDBoxLayout: - orientation: "horizontal" - padding: [0,0,dp(24),0] - size_hint_y: None - height: dp(24) - - MDLabel: - id: connectivity_weave_label - text: "Connect via Weave" - font_style: "H6" - disabled: False - - MDSwitch: - id: connectivity_use_weave - active: False - pos_hint: {"center_y": 0.3} - disabled: False - - MDBoxLayout: - id: connectivity_weave_fields - orientation: "vertical" - size_hint_y: None - height: self.minimum_height - padding: [0, 0, 0, dp(32)] - - MDTextField: - id: connectivity_weave_ifac_netname - hint_text: "Optional IFAC network name" - text: "" - font_size: dp(24) - - MDTextField: - id: connectivity_weave_ifac_passphrase - hint_text: "Optional IFAC passphrase" - text: "" - font_size: dp(24) - - # MDBoxLayout: # orientation: "horizontal" # padding: [0,0,dp(24),0] @@ -696,33 +641,15 @@ MDScreen: # font_size: dp(24) # # disabled: True - # MDLabel: - # text: "Shared Instance Access\\n" - # id: connectivity_shared_access_label - # font_style: "H5" - - MDBoxLayout: - orientation: "horizontal" - padding: [0,0,dp(24),0] - size_hint_y: None - height: dp(24) - - MDLabel: - id: connectivity_shared_access_label - text: "Share Reticulum Instance" - font_style: "H6" - # disabled: True - - MDSwitch: - id: connectivity_share_instance - active: False - pos_hint: {"center_y": 0.3} - # disabled: True + MDLabel: + text: "Shared Instance Access\\n" + id: connectivity_shared_access_label + font_style: "H5" MDLabel: id: connectivity_shared_access markup: True - text: "You can make the Reticulum instance launched by Sideband available for other programs on this system. By default, this grants connectivity to other local Reticulum-based programs, but no access to management, interface status and path information.\\n\\nIf you want to allow full functionality and ability to manage the running instance, you will need to configure other programs to use the correct RPC key for this instance.\\n\\nThis can be very useful for using other tools related to Reticulum, for example via command-line programs running in Termux. To do this, use the button below to copy the RPC key configuration line, and paste it into the Reticulum configuration file within the Termux environment, or other program.\\n\\nPlease note! [b]It is not necessary[/b] to enable Reticulum Transport for this to work!\\n\\n" + text: "The Reticulum instance launched by Sideband will be available for other programs on this system. By default, this grants connectivity to other local Reticulum-based programs, but no access to management, interface status and path information.\\n\\nIf you want to allow full functionality and ability to manage the running instance, you will need to configure other programs to use the correct RPC key for this instance.\\n\\nThis can be very useful for using other tools related to Reticulum, for example via command-line programs running in Termux. To do this, use the button below to copy the RPC key configuration line, and paste it into the Reticulum configuration file within the Termux environment, or other program.\\n\\nPlease note! [b]It is not necessary[/b] to enable Reticulum Transport for this to work!\\n\\n" size_hint_y: None text_size: self.width, None height: self.texture_size[1] @@ -849,6 +776,114 @@ MDScreen: # font_size: dp(24) """ +layout_guide_screen = """ +MDScreen: + name: "guide_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + title: "Guide" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.app.close_guide_action(self)], + ] + + ScrollView: + id:guide_scrollview + + MDBoxLayout: + orientation: "vertical" + size_hint_y: None + height: self.minimum_height + padding: [dp(35), dp(16), dp(35), dp(16)] + + MDLabel: + id: guide_info1 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info2 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info3 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info4 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info10 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info5 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info6 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info7 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info8 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + id: guide_info9 + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] +""" + layout_information_screen = """ MDScreen: name: "information_screen" @@ -1053,6 +1088,98 @@ MDScreen: id: map_layout """ +layout_keys_screen = """ +MDScreen: + name: "keys_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + title: "Encryption Keys" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.app.close_keys_action(self)], + ] + + ScrollView: + id:keys_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(35), dp(35), dp(35), dp(35)] + + + MDLabel: + id: keys_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDRectangleFlatIconButton: + id: keys_display + icon: "eye" + text: "Display Identity Key" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.identity_display_action(self) + + MDRectangleFlatIconButton: + id: keys_copy + icon: "file-key" + text: "Copy Key To Clipboard" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.identity_copy_action(self) + + MDRectangleFlatIconButton: + id: keys_share + icon: "upload-lock" + text: "Send Key To Other App" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.identity_share_action(self) + + MDBoxLayout: + orientation: "vertical" + # spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(12), dp(0), dp(0)] + + MDTextField: + id: key_restore_text + hint_text: "Enter base32 key for import" + mode: "rectangle" + # size_hint: [1.0, None] + pos_hint: {"center_x": .5} + + MDRectangleFlatIconButton: + id: keys_restore + icon: "download-lock" + text: "Restore Identity From Key" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.identity_restore_action(self) +""" + layout_plugins_screen = """ MDScreen: name: "plugins_screen" @@ -1437,21 +1564,6 @@ MDScreen: pos_hint: {"center_y": 0.3} active: False - MDBoxLayout: - orientation: "horizontal" - size_hint_y: None - padding: [0,0,dp(24),dp(0)] - height: dp(48) - - MDLabel: - text: "Start at boot" - font_style: "H6" - - MDSwitch: - id: settings_start_at_boot - pos_hint: {"center_y": 0.3} - active: False - MDLabel: text: "•" font_style: "H6" @@ -1978,3 +2090,578 @@ MDScreen: text_size: self.width, None height: self.texture_size[1] """ + +layout_hardware_screen = """ +MDScreen: + name: "hardware_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + title: "Hardware" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.app.close_hardware_action(self)], + ] + + ScrollView: + id: hardware_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "8dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(28), dp(48), dp(28), dp(16)] + + MDLabel: + text: "Configure Hardware Parameters\\n" + font_style: "H6" + + MDLabel: + id: hardware_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(35), dp(0), dp(35)] + + MDRectangleFlatIconButton: + id: hardware_rnode_button + icon: "radio-handheld" + text: "RNode" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_rnode_action(self) + + MDRectangleFlatIconButton: + id: hardware_modem_button + icon: "router-wireless" + text: "Radio Modem" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_modem_action(self) + disabled: False + + MDRectangleFlatIconButton: + id: hardware_serial_button + icon: "cable-data" + text: "Serial Port" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_serial_action(self) + disabled: False +""" + +layout_hardware_modem_screen = """ +MDScreen: + name: "hardware_modem_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + title: "Radio Modem" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.app.close_sub_hardware_action(self)], + ] + + ScrollView: + id: hardware_modem_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "8dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(28), dp(48), dp(28), dp(16)] + + MDLabel: + text: "Modem Hardware Parameters\\n" + font_style: "H6" + + MDLabel: + id: hardware_modem_info + markup: True + text: "To communicate using a Radio Modem, you will need to specify the following parameters. Serial port parameters must be set to match those of the modem. CSMA parameters can be left at their default values in most cases.\\n" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + text: "Port Options" + font_style: "H6" + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDTextField: + id: hardware_modem_baudrate + hint_text: "Baud Rate" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(0), dp(0), dp(24)] + + MDTextField: + id: hardware_modem_databits + hint_text: "Data Bits" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_modem_parity + hint_text: "Parity" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_modem_stopbits + hint_text: "Stop Bits" + text: "" + font_size: dp(24) + + MDLabel: + text: "CSMA Parameters" + font_style: "H6" + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(0), dp(0), dp(0)] + + MDTextField: + id: hardware_modem_preamble + hint_text: "Preamble (ms)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_modem_tail + hint_text: "TX Tail (ms)" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(0), dp(0), dp(24)] + + MDTextField: + id: hardware_modem_persistence + hint_text: "Persistence (1-255)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_modem_slottime + hint_text: "Slot Time (ms)" + text: "" + font_size: dp(24) + + MDLabel: + text: "Optional Settings" + font_style: "H6" + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDTextField: + id: hardware_modem_beaconinterval + hint_text: "Beacon Interval (seconds)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_modem_beacondata + hint_text: "Beacon Data" + text: "" + font_size: dp(24) +""" + +layout_hardware_rnode_screen = """ +MDScreen: + name: "hardware_rnode_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + title: "RNode" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.app.close_sub_hardware_action(self)], + ] + + ScrollView: + id: hardware_rnode_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "8dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(28), dp(48), dp(28), dp(16)] + + MDLabel: + text: "RNode Hardware Parameters\\n" + font_style: "H6" + + MDLabel: + id: hardware_rnode_info + markup: True + text: "To communicate using an RNode, you will need to specify the following parameters. For two or more RNodes to be able to communicate, all parameters must match, except for the [i]Coding Rate[/i] and [i]TX Power[/i] parameter, which can vary between devices.\\n" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(0), dp(0), dp(35)] + + MDRectangleFlatIconButton: + id: rnode_mote_export + icon: "upload" + text: "Export" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_rnode_export(self) + + MDRectangleFlatIconButton: + id: rnode_mote_import + icon: "download" + text: "Import" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_rnode_import(self) + + MDLabel: + text: "Radio Options" + font_style: "H6" + + # MDTextField: + # id: hardware_rnode_modulation + # hint_text: "Modulation" + # text: "LoRa" + # disabled: True + # font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDTextField: + id: hardware_rnode_frequency + hint_text: "Frequency (MHz)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_rnode_bandwidth + hint_text: "Bandwidth (KHz)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_rnode_txpower + hint_text: "TX Power (dBm)" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(0), dp(0), dp(24)] + + MDTextField: + id: hardware_rnode_spreadingfactor + hint_text: "Spreading Factor" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_rnode_codingrate + hint_text: "Coding Rate" + text: "" + font_size: dp(24) + + MDLabel: + text: "Optional Settings" + font_style: "H6" + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDTextField: + id: hardware_rnode_beaconinterval + hint_text: "Beacon Interval (seconds)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_rnode_beacondata + hint_text: "Beacon Data" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDTextField: + id: hardware_rnode_atl_short + hint_text: "Airime Limit % (15s)" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_rnode_atl_long + hint_text: "Airime Limit % (1h)" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + size_hint_y: None + padding: [0,0,dp(24),dp(0)] + height: dp(48) + + MDLabel: + text: "Control RNode Display" + font_style: "H6" + + MDSwitch: + id: hardware_rnode_framebuffer + pos_hint: {"center_y": 0.3} + active: False + + MDBoxLayout: + orientation: "horizontal" + size_hint_y: None + padding: [0,0,dp(24),dp(0)] + height: dp(48) + + MDLabel: + text: "Connect using Bluetooth" + font_style: "H6" + + MDSwitch: + id: hardware_rnode_bluetooth + pos_hint: {"center_y": 0.3} + active: False + + MDBoxLayout: + orientation: "horizontal" + size_hint_y: None + padding: [0,0,dp(24),dp(0)] + height: dp(48) + + MDLabel: + text: "Device requires BLE" + font_style: "H6" + + MDSwitch: + id: hardware_rnode_ble + pos_hint: {"center_y": 0.3} + active: False + + MDLabel: + id: hardware_rnode_info + markup: True + text: "If you enable connection via Bluetooth, Sideband will attempt to connect to any available and paired RNodes over Bluetooth.\\n\\nYou must first pair the RNode with your device for this to work. If your RNode does not have a physical pairing button, you can enable Bluetooth and put it into pairing mode by first connecting it via a USB cable, and using the buttons below. When plugging in the RNode over USB, you must grant Sideband permission to the USB device for this to work.\\n\\nYou can also change Bluetooth settings using the \\"rnodeconf\\" utility from a computer.\\n\\nBy default, Sideband will connect to the first available RNode that is paired. If you want to always use a specific RNode, you can enter its name in the Preferred RNode Device Name field below, for example \\"RNode A8EB\\".\\n" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDRectangleFlatIconButton: + id: hardware_rnode_bt_on_button + icon: "bluetooth" + text: "Enable Bluetooth" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_rnode_bt_on_action(self) + + MDRectangleFlatIconButton: + id: hardware_rnode_bt_off_button + icon: "bluetooth-off" + text: "Disable Bluetooth" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_rnode_bt_off_action(self) + disabled: False + + MDRectangleFlatIconButton: + id: hardware_rnode_bt_pair_button + icon: "link-variant" + text: "Start Pairing Mode" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.app.hardware_rnode_bt_pair_action(self) + disabled: False + + MDTextField: + id: hardware_rnode_bt_device + hint_text: "Preferred RNode Device Name" + text: "" + font_size: dp(24) +""" + +layout_hardware_serial_screen = """ +MDScreen: + name: "hardware_serial_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + title: "Serial Port" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.app.close_sub_hardware_action(self)], + ] + + ScrollView: + id: hardware_serial_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "8dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(28), dp(48), dp(28), dp(16)] + + MDLabel: + text: "Serial Hardware Parameters\\n" + font_style: "H6" + + MDLabel: + id: hardware_serial_info + markup: True + text: "To communicate using a serial port, you will need to specify the following parameters. If communicating directly to another Reticulum instance over serial, the parameters must match the other device.\\n\\nIf you are using a serial-connected device to pass on data to other Reticulum instances, it should be configured to pass data transparently to the desired endpoints.\\n" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + MDLabel: + text: "Port Options" + font_style: "H6" + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + # padding: [dp(0), dp(0), dp(0), dp(35)] + + MDTextField: + id: hardware_serial_baudrate + hint_text: "Baud Rate" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(0), dp(0), dp(24)] + + MDTextField: + id: hardware_serial_databits + hint_text: "Data Bits" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_serial_parity + hint_text: "Parity" + text: "" + font_size: dp(24) + + MDTextField: + id: hardware_serial_stopbits + hint_text: "Stop Bits" + text: "" + font_size: dp(24) +""" diff --git a/sbapp/ui/messages.py b/sbapp/ui/messages.py index af26f2d..3a0722c 100644 --- a/sbapp/ui/messages.py +++ b/sbapp/ui/messages.py @@ -49,8 +49,6 @@ if RNS.vendor.platformutils.is_darwin(): from kivy.lang.builder import Builder from kivymd.uix.list import OneLineIconListItem, IconLeftWidget -MSG_RENDER_LIMIT = 11000 - class DialogItem(OneLineIconListItem): divider = None icon = StringProperty() @@ -246,9 +244,14 @@ class Messages(): layout = GridLayout(cols=1, spacing=dp(16), padding=dp(16), size_hint_y=None) layout.bind(minimum_height=layout.setter('height')) self.list = layout - - if self.ptt_enabled: self.hide_widget(self.ids.message_ptt, False) - else: self.hide_widget(self.ids.message_ptt, True) + + if RNS.vendor.platformutils.is_darwin() or RNS.vendor.platformutils.is_windows(): + self.hide_widget(self.ids.message_ptt, True) + else: + if self.ptt_enabled: + self.hide_widget(self.ids.message_ptt, False) + else: + self.hide_widget(self.ids.message_ptt, True) c_ts = time.time() if len(self.new_messages) > 0: @@ -316,33 +319,13 @@ class Messages(): prgstr = "" sphrase = "Sending" prg = self.app.sideband.get_lxm_progress(msg["hash"]) - if not hasattr(w, "last_prg_update"): - w.last_prg_update = time.time() - w.last_prg = prg - speed = None - else: - now = time.time() - size = msg["lxm"].packed_size - td = now - w.last_prg_update - if td == 0 or prg == None or w.last_prg == None: speed = None - else: - bd = prg*size - w.last_prg*size - speed = (bd/td)*8 - if prg != None: prgstr = ", "+str(round(prg*100, 1))+"% done" if prg <= 0.00: stamp_cost = self.app.sideband.get_lxm_stamp_cost(msg["hash"]) - prop_cost = self.app.sideband.get_lxm_propagation_cost(msg["hash"]) - if stamp_cost and prop_cost: - sphrase = f"Generating stamps with cost {stamp_cost} and {prop_cost}" - prgstr = "" - elif stamp_cost: + if stamp_cost: sphrase = f"Generating stamp with cost {stamp_cost}" prgstr = "" - elif prop_cost: - sphrase = f"Generating PN stamp with cost {prop_cost}" - prgstr = "" else: sphrase = "Waiting for path" elif prg <= 0.01: @@ -353,7 +336,6 @@ class Messages(): sphrase = "Link established" elif prg >= 0.05: sphrase = "Sending" - if speed != None: prgstr += f", {RNS.prettyspeed(speed)}" if msg["title"]: titlestr = "[b]Title[/b] "+msg["title"].decode("utf-8")+"\n" @@ -732,11 +714,8 @@ class Messages(): alstr = RNS.prettysize(len(audio_field[1])) heading_str += f"\n[b]Audio Message[/b] ({alstr})" - final_content = pre_content+message_markup.decode("utf-8")+extra_content - if len(final_content) > MSG_RENDER_LIMIT: - final_content = pre_content+"[i]The content of this message is too large to display in the message stream. You can copy the message content into another program by using the context menu of this message, and selecting [b]Copy[/b].[/i]"+extra_content item = ListLXMessageCard( - text=final_content, + text=pre_content+message_markup.decode("utf-8")+extra_content, heading=heading_str, md_bg_color=msg_color, ) @@ -1471,10 +1450,7 @@ Builder.load_string(""" id: heading_text markup: True text: root.heading - size_hint_y: None - height: self.texture_size[1] - # adaptive_size: True - + adaptive_size: True # theme_text_color: 'Custom' # text_color: rgba(255,255,255,100) pos: 0, root.height - (self.height + root.padding[0] + dp(8)) diff --git a/sbapp/ui/objectdetails.py b/sbapp/ui/objectdetails.py index e61b36e..0de4f3d 100644 --- a/sbapp/ui/objectdetails.py +++ b/sbapp/ui/objectdetails.py @@ -830,19 +830,12 @@ class RVDetails(MDRecycleView): ler = self.delegate.app.sideband.get_destination_establishment_rate(self.delegate.object_hash) mtu = self.delegate.app.sideband.get_destination_mtu(self.delegate.object_hash) or RNS.Reticulum.MTU edr = self.delegate.app.sideband.get_destination_edr(self.delegate.object_hash) - lmd = self.delegate.app.sideband.get_destination_lmd(self.delegate.object_hash) if ler: lers = RNS.prettyspeed(ler, "b") mtus = RNS.prettysize(mtu) edrs = f"{RNS.prettyspeed(edr)}" if edr != None else "" self.entries.append({"icon": "lock-check-outline", "text": f"Link established, LER is [b]{lers}[/b], MTU is [b]{mtus}[/b]", "on_release": pass_job}) if edr: self.entries.append({"icon": "approximately-equal", "text": f"Expected data rate is [b]{edrs}[/b]", "on_release": pass_job}) - if lmd != None: - if lmd in RNS.Link.MODE_DESCRIPTIONS: lmds = RNS.Link.MODE_DESCRIPTIONS[lmd] - else: lmds = "unknown" - if lmds == "AES_128_CBC": lmds = "X25519/AES128" - elif lmds == "AES_256_CBC": lmds = "X25519/AES256" - self.entries.append({"icon": "link-lock", "text": f"Link mode is [b]{lmds}[/b]", "on_release": pass_job}) except Exception as e: RNS.trace_exception(e) diff --git a/sbapp/ui/utilities.py b/sbapp/ui/utilities.py index aa8da1b..d3adcc7 100644 --- a/sbapp/ui/utilities.py +++ b/sbapp/ui/utilities.py @@ -81,14 +81,13 @@ class Utilities(): ### rnstatus screen ###################################### - def rnstatus_action(self, sender=None, from_conversations=False): + def rnstatus_action(self, sender=None): if not self.app.root.ids.screen_manager.has_screen("rnstatus_screen"): self.rnstatus_screen = Builder.load_string(layout_rnstatus_screen) self.rnstatus_screen.app = self.app self.rnstatus_screen.delegate = self self.app.root.ids.screen_manager.add_widget(self.rnstatus_screen) - self.rnstatus_screen.from_conversations = from_conversations self.app.root.ids.screen_manager.transition.direction = "left" self.app.root.ids.screen_manager.current = "rnstatus_screen" self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current) @@ -98,10 +97,6 @@ class Utilities(): def update_rnstatus(self, sender=None): threading.Thread(target=self.update_rnstatus_job, daemon=True).start() - def close_rnstatus_action(self, sender=None): - if not self.rnstatus_screen.from_conversations: self.app.close_sub_utilities_action() - else: self.app.close_any_action() - def update_rnstatus_job(self, sender=None): if self.rnstatus_instance == None: import RNS.Utilities.rnstatus as rnstatus @@ -187,23 +182,14 @@ class Utilities(): threading.Thread(target=self.update_logviewer_job, daemon=True).start() def update_logviewer_job(self, sender=None): - try: output = self.app.sideband.get_log() - except Exception as e: output = f"An error occurred while retrieving log entries:\n{e}" - - if not RNS.vendor.platformutils.is_android(): service_output = None - else: - try: service_output = self.app.sideband.get_service_log() - except Exception as e: service_output = f"An error occurred while retrieving log entries:\n{e}" - + try: + output = self.app.sideband.get_log() + except Exception as e: + output = f"An error occurred while retrieving log entries:\n{e}" self.logviewer_screen.log_contents = output def cb(dt): - if not RNS.vendor.platformutils.is_android(): - self.logviewer_screen.ids.logviewer_output.text = f"[font=RobotoMono-Regular][size={int(dp(12))}]{output}[/size][/font]" - else: - self.logviewer_screen.ids.logviewer_output.text = f"[size=18dp][b]Frontend Log[/b][/size][size=5dp]\n \n[font=RobotoMono-Regular][size={int(dp(12))}]{output}[/size][/font]" - self.logviewer_screen.ids.slogviewer_output.text = f"\n[size=18dp][b]Service Log[/b][/size][size=5dp]\n \n[font=RobotoMono-Regular][size={int(dp(12))}]{service_output}[/size][/font]" - self.logviewer_screen.log_contents += f"\n\n{service_output}" + self.logviewer_screen.ids.logviewer_output.text = f"[font=RobotoMono-Regular][size={int(dp(12))}]{output}[/size][/font]" Clock.schedule_once(cb, 0.2) if self.app.root.ids.screen_manager.current == "logviewer_screen": @@ -324,7 +310,7 @@ MDScreen: right_action_items: [ # ['refresh', lambda x: root.delegate.update_rnstatus()], - ['close', lambda x: root.delegate.close_rnstatus_action(self)], + ['close', lambda x: root.app.close_sub_utilities_action(self)], ] MDScrollView: @@ -391,14 +377,6 @@ MDScreen: size_hint_y: None text_size: self.width, None height: self.texture_size[1] - - MDLabel: - id: slogviewer_output - markup: True - text: "" - size_hint_y: None - text_size: self.width, None - height: self.texture_size[1] """ layout_advanced_screen = """ diff --git a/sbapp/ui/voice.py b/sbapp/ui/voice.py index b54fb10..60d19fd 100644 --- a/sbapp/ui/voice.py +++ b/sbapp/ui/voice.py @@ -12,8 +12,6 @@ from kivymd.uix.pickers import MDColorPicker from kivymd.uix.button import MDRectangleFlatButton from kivymd.uix.button import MDRectangleFlatIconButton from kivymd.uix.dialog import MDDialog -from kivy.properties import StringProperty, BooleanProperty, OptionProperty, ColorProperty, Property -from kivymd.uix.list import MDList, IconLeftWidget, IconRightWidget, OneLineAvatarIconListItem from kivymd.icon_definitions import md_icons from kivymd.toast import toast from kivy.properties import StringProperty, BooleanProperty @@ -24,18 +22,10 @@ import threading from datetime import datetime if RNS.vendor.platformutils.get_platform() == "android": - from ui.helpers import ts_format_date + from ui.helpers import ts_format from android.permissions import request_permissions, check_permission else: - from .helpers import ts_format_date - -from kivy.utils import escape_markup -if RNS.vendor.platformutils.get_platform() == "android": - from ui.helpers import multilingual_markup -else: - from .helpers import multilingual_markup - -from LXST.Primitives.Telephony import Telephone, Profiles + from .helpers import ts_format class Voice(): def __init__(self, app): @@ -47,21 +37,18 @@ class Voice(): self.path_requesting = None self.output_devices = [] self.input_devices = [] - self.log_list = None - self.last_log_update = 0 - self.log_name_cache = {} self.listed_output_devices = [] self.listed_input_devices = [] self.listed_ringer_devices = [] - self.call_profile = Profiles.DEFAULT_PROFILE if not self.app.root.ids.screen_manager.has_screen("voice_screen"): self.screen = Builder.load_string(layout_voice_screen) self.screen.app = self.app self.screen.delegate = self self.app.root.ids.screen_manager.add_widget(self.screen) - self.update_call_log() + self.screen.ids.voice_scrollview.effect_cls = ScrollEffect + def update_call_status(self, dt=None): if self.app.root.ids.screen_manager.current == "voice_screen": if self.ui_updater == None: self.ui_updater = Clock.schedule_interval(self.update_call_status, 0.5) @@ -71,60 +58,36 @@ class Voice(): self.ui_updater = None db = self.screen.ids.dial_button - rb = self.screen.ids.reject_button ih = self.screen.ids.identity_hash - pb = self.screen.ids.call_profile_button if self.app.sideband.voice_running: telephone = self.app.sideband.telephone if self.path_requesting: db.disabled = True - rb.disabled = True ih.disabled = True else: if telephone.is_available: ih.disabled = False - rb.disabled = True - pb.disabled = False self.target_input_action(ih) else: ih.disabled = True - rb.disabled = True - pb.disabled = True if telephone.is_in_call or telephone.call_is_connecting: ih.disabled = True - rb.disabled = True db.disabled = False - if telephone.call_is_connecting: pb.disabled = True - if telephone.is_in_call: pb.disabled = False db.text = "Hang up" db.icon = "phone-hangup" - if telephone.active_profile: self.call_profile = telephone.active_profile elif telephone.is_ringing: ih.disabled = True - rb.disabled = False db.disabled = False - pb.disabled = True db.text = "Answer" db.icon = "phone-ring" if telephone.caller: ih.text = RNS.hexrep(telephone.caller.hash, delimit=False) - if telephone.active_profile: self.call_profile = telephone.active_profile - - if self.app.sideband.getstate("voice.connection_failure"): - self.app.sideband.setstate("voice.connection_failure", False) - toast("Could not connect call", duration=5) else: db.disabled = True; db.text = "Voice calls disabled" ih.disabled = True - rb.disabled = True - pb.disabled = True - - pb.text = Profiles.profile_abbrevation(self.call_profile) - - if time.time() > self.last_log_update+3: self.update_call_log() def target_valid(self): if self.app.sideband.voice_running: @@ -170,7 +133,7 @@ class Voice(): self.app.sideband.telephone.set_busy(False) if RNS.Transport.has_path(self.path_requesting): RNS.log(f"Calling {RNS.prettyhexrep(self.dial_target)}...", RNS.LOG_DEBUG) - self.app.sideband.telephone.dial(self.dial_target, profile=self.call_profile) + self.app.sideband.telephone.dial(self.dial_target) Clock.schedule_once(self.update_call_status, 0.1) else: @@ -183,60 +146,17 @@ class Voice(): def _path_request_failed(self, dt): toast("Path request timed out") - def call_profile_action(self, sender=None): - if self.app.sideband.telephone.is_in_call: self.switch_profile_action() - else: - pb = self.screen.ids.call_profile_button - self.call_profile = Profiles.next_profile(self.call_profile) - pb.text = Profiles.profile_abbrevation(self.call_profile) - toast(f"Call Profile: {Profiles.profile_name(self.call_profile)}") - - def switch_profile_action(self, sender=None): - if self.initial_call_profile == None: self.initial_call_profile = self.call_profile - if self.initial_call_profile < Profiles.QUALITY_MEDIUM: alt_profile = Profiles.QUALITY_MEDIUM - else: alt_profile = Profiles.BANDWIDTH_LOW - switch_profiles = [alt_profile, self.initial_call_profile] - if self.call_profile == switch_profiles[0]: - RNS.log(f"Switching to {Profiles.profile_name(switch_profiles[1])}", RNS.LOG_DEBUG) - self.app.sideband.telephone.switch_profile(switch_profiles[1]) - else: - RNS.log(f"Switching to {Profiles.profile_name(switch_profiles[0])}", RNS.LOG_DEBUG) - self.app.sideband.telephone.switch_profile(switch_profiles[0]) - - def clear_log_action(self, sender=None): - self.app.sideband.telephone.clear_call_log() - self.update_call_log() - - def log_dial_action(self, sender=None): - def job(dt=None): - if not self.app.root.ids.screen_manager.current == "voice_screen": return - if sender and self.app.sideband.voice_running and self.app.sideband.telephone != None: - telephone = self.app.sideband.telephone - if not telephone.is_ringing and not telephone.is_in_call and not telephone.call_is_connecting: - call_dialog_text = f"[b]Initiate Voice Call?[/b]\n\nDestination Identity:\n{RNS.prettyhexrep(sender.identity)}" - if hasattr(self.app, "confirm_call_dialog"): self.app.confirm_call_dialog.text = call_dialog_text - else: self.app.init_confirm_call_dialog(call_dialog_text) - self.app.confirm_call_dialog.dest_identity_hash = sender.identity - self.app.confirm_call_dialog.open() - - Clock.schedule_once(job, 0.1) - - def reject_action(self, sender=None): - if self.app.sideband.voice_running: - if self.app.sideband.telephone.is_ringing: - self.app.sideband.telephone.hangup() - self.initial_call_profile = None - def dial_action(self, sender=None): if self.app.sideband.voice_running: if self.app.sideband.telephone.is_available: destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", self.dial_target) - if not RNS.Transport.has_path(destination_hash): self.request_path(destination_hash) + if not RNS.Transport.has_path(destination_hash): + self.request_path(destination_hash) + else: RNS.log(f"Calling {RNS.prettyhexrep(self.dial_target)}...", RNS.LOG_DEBUG) - if self.app.sideband.telephone.dial(self.dial_target, profile=self.call_profile) == "no_path": - self.request_path(destination_hash) + self.app.sideband.telephone.dial(self.dial_target) self.update_call_status() elif self.app.sideband.telephone.is_in_call or self.app.sideband.telephone.call_is_connecting: @@ -249,10 +169,8 @@ class Voice(): self.app.sideband.telephone.answer() self.update_call_status() - self.initial_call_profile = None - - ### Settings screen + ### settings screen ###################################### def settings_action(self, sender=None): @@ -284,12 +202,8 @@ class Voice(): def update_settings_screen(self, sender=None): self.voice_settings_screen.ids.voice_trusted_only.active = self.app.sideband.config["voice_trusted_only"] self.voice_settings_screen.ids.voice_trusted_only.bind(active=self.settings_save_action) - self.voice_settings_screen.ids.voice_low_latency.active = self.app.sideband.config["voice_low_latency"] - self.voice_settings_screen.ids.voice_low_latency.bind(active=self.settings_save_action) - if not RNS.vendor.platformutils.is_android(): self.voice_settings_screen.ids.voice_low_latency.disabled = True - - bp = 6; ml = 38; fs = 16; ics = 14 + bp = 6; ml = 45; fs = 16; ics = 14 self.update_devices() # Output devices @@ -302,8 +216,7 @@ class Voice(): for device in self.output_devices: if not device in self.listed_output_devices: - device_str = device.replace("[", "").replace("]", "") - label = device_str if len(device_str) < ml else device_str[:ml-3]+"..." + label = device if len(device) < ml else device[:ml-3]+"..." device_button = MDRectangleFlatIconButton(text=label, font_size=dp(fs), icon_size=dp(ics), on_release=self.output_device_action) device_button.padding = [dp(bp), dp(bp), dp(bp), dp(bp)]; device_button.size_hint = [1.0, None] if self.app.sideband.config["voice_output"] == device: device_button.icon = "check" @@ -321,8 +234,7 @@ class Voice(): for device in self.input_devices: if not device in self.listed_input_devices: - device_str = device.replace("[", "").replace("]", "") - label = device_str if len(device_str) < ml else device_str[:ml-3]+"..." + label = device if len(device) < ml else device[:ml-3]+"..." device_button = MDRectangleFlatIconButton(text=label, font_size=dp(fs), icon_size=dp(ics), on_release=self.input_device_action) device_button.padding = [dp(bp), dp(bp), dp(bp), dp(bp)]; device_button.size_hint = [1.0, None] if self.app.sideband.config["voice_input"] == device: device_button.icon = "check" @@ -340,8 +252,7 @@ class Voice(): for device in self.output_devices: if not device in self.listed_ringer_devices: - device_str = device.replace("[", "").replace("]", "") - label = device_str if len(device_str) < ml else device_str[:ml-3]+"..." + label = device if len(device) < ml else device[:ml-3]+"..." device_button = MDRectangleFlatIconButton(text=label, font_size=dp(fs), icon_size=dp(ics), on_release=self.ringer_device_action) device_button.padding = [dp(bp), dp(bp), dp(bp), dp(bp)]; device_button.size_hint = [1.0, None] if self.app.sideband.config["voice_ringer"] == device: device_button.icon = "check" @@ -351,10 +262,7 @@ class Voice(): def settings_save_action(self, sender=None, event=None): self.app.sideband.config["voice_trusted_only"] = self.voice_settings_screen.ids.voice_trusted_only.active - self.app.sideband.config["voice_low_latency"] = self.voice_settings_screen.ids.voice_low_latency.active self.app.sideband.save_configuration() - if self.app.sideband.telephone: - self.app.sideband.telephone.set_low_latency_output(self.app.sideband.config["voice_low_latency"]) def output_device_action(self, sender=None): self.app.sideband.config["voice_output"] = sender.device @@ -381,116 +289,6 @@ class Voice(): self.app.sideband.telephone.set_ringer(self.app.sideband.config["voice_ringer"]) - ### Call log - ###################################### - - def update_call_log(self): - if self.log_list == None: - self.log_list = CallList() - self.screen.ids.log_list_container.add_widget(self.log_list) - - self.update_log_list() - self.last_log_update = time.time() - - def update_log_list(self): - if not self.app.sideband.telephone: self.log_list.data = [] - else: - LogEntry.owner = self - call_log = self.app.sideband.telephone.get_call_log() - call_log.sort(key=lambda e: e["time"], reverse=True) - data = [] - for entry in call_log: - try: - at = entry["time"] - td = int(time.time())-int(at) - evt = entry["event"] - idnt = entry["identity"] - - if not idnt in self.log_name_cache: self.log_name_cache[idnt] = self.app.sideband.voice_display_name(idnt) - name = multilingual_markup(escape_markup(str(self.log_name_cache[idnt])).encode("utf-8")).decode("utf-8") - - icon = None - if evt == "incoming-missed": icon = "phone-missed" - elif evt == "outgoing-failure": icon = "phone-remove" - elif evt == "incoming-success": icon = "phone-incoming" - elif evt == "outgoing-success": icon = "phone-outgoing" - - time_str = None - if td < 60: time_str = "Just now" - elif td < 60*60: td = int((td//60)*60) - elif td < 60*60*24: td = int((td//60)*60) - elif td < 60*60*24*7: td = int((td//(60*60*24))*(60*60*24)) - else: time_str = time.strftime(ts_format_date, time.localtime(at)) - - if time_str == None: time_str = f"{RNS.prettytime(td)} ago" - - if icon: - info = f"{name} • [i]{time_str}[/i]" - entry = {"icon": icon, "text": f"{info}", "identity": idnt} - data.append(entry) - - except Exception as e: - RNS.log(f"An error occurred while updating the call log list: {e}", RNS.LOG_ERROR) - RNS.trace_exception(e) - - self.log_list.data = data - -class LogEntry(OneLineAvatarIconListItem): - owner = None - - icon = StringProperty() - # ti_color = OptionProperty(None, options=theme_text_color_options) - # icon_fg = Property(None, allownone=True) - # icon_bg = Property(None, allownone=True) - - def __init__(self): - super().__init__() - self.bind(on_release=self.dial_action) - # self.ids.left_icon.bind(on_release=self.left_icon_action) - # self.ids.right_icon.bind(on_release=self.right_icon_action) - - def dial_action(self, sender=None): - self.owner.log_dial_action(self) - - def left_icon_action(self, sender): - pass - - def right_icon_action(self, sender): - pass - -class CallList(MDRecycleView): - def __init__(self): - super().__init__() - self.data = [] - -Builder.load_string(""" - - IconLeftWidget: - id: left_icon - # theme_icon_color: root.ti_color - # icon_color: root.icon_fg - # md_bg_color: root.icon_bg - icon: root.icon - _default_icon_pad: dp(14) - icon_size: dp(24) - - # IconRightWidget: - # id: right_icon - # icon: "dots-vertical" - -: - id: calls_scrollview - viewclass: "LogEntry" - effect_cls: "ScrollEffect" - - RecycleBoxLayout: - default_size: None, dp(57) - default_size_hint: 1, None - size_hint_y: None - height: self.minimum_height - orientation: "vertical" -""") - layout_voice_screen = """ MDScreen: name: "voice_screen" @@ -510,52 +308,37 @@ MDScreen: ['close', lambda x: root.app.close_any_action(self)], ] - MDBoxLayout: - orientation: "vertical" - size_hint_y: None - height: self.minimum_height - padding: [dp(28), dp(12), dp(28), dp(16)] + ScrollView: + id: voice_scrollview MDBoxLayout: orientation: "vertical" - # spacing: "24dp" size_hint_y: None height: self.minimum_height - padding: [dp(0), dp(12), dp(0), dp(0)] - - MDTextField: - id: identity_hash - hint_text: "Identity hash" - mode: "rectangle" - # size_hint: [1.0, None] - pos_hint: {"center_x": .5} - max_text_length: 32 - on_text: root.delegate.target_input_action(self) - - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(35), dp(0), dp(14)] + padding: [dp(28), dp(32), dp(28), dp(16)] MDBoxLayout: - orientation: "horizontal" + orientation: "vertical" + # spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(12), dp(0), dp(0)] + + MDTextField: + id: identity_hash + hint_text: "Identity hash" + mode: "rectangle" + # size_hint: [1.0, None] + pos_hint: {"center_x": .5} + max_text_length: 32 + on_text: root.delegate.target_input_action(self) + + MDBoxLayout: + orientation: "vertical" spacing: "24dp" size_hint_y: None height: self.minimum_height - padding: [dp(0), dp(0), dp(0), dp(0)] - - MDRectangleFlatIconButton: - id: call_profile_button - icon: "account-voice" - text: "HQ" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.delegate.call_profile_action(self) - disabled: False + padding: [dp(0), dp(35), dp(0), dp(35)] MDRectangleFlatIconButton: id: dial_button @@ -564,48 +347,9 @@ MDScreen: padding: [dp(0), dp(14), dp(0), dp(14)] icon_size: dp(24) font_size: dp(16) - size_hint: [2.0, None] + size_hint: [1.0, None] on_release: root.delegate.dial_action(self) disabled: True - - MDRectangleFlatIconButton: - id: reject_button - icon: "phone-cancel" - text: "Reject" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.delegate.reject_action(self) - disabled: True - - MDSeparator: - orientation: "horizontal" - height: dp(1) - - MDBoxLayout: - orientation: "vertical" - id: log_list_container - - MDSeparator: - orientation: "horizontal" - height: dp(1) - - MDBoxLayout: - orientation: "vertical" - size_hint_y: None - height: self.minimum_height - padding: [dp(28), dp(24), dp(28), dp(24)] - - MDRectangleFlatIconButton: - id: clear_log_button - icon: "playlist-remove" - text: "Clear Log" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.delegate.clear_log_action(self) """ layout_voice_settings_screen = """ @@ -671,21 +415,6 @@ MDScreen: pos_hint: {"center_y": 0.3} active: False - MDBoxLayout: - orientation: "horizontal" - padding: [0,0,dp(24),0] - size_hint_y: None - height: dp(48) - - MDLabel: - text: "Low-latency output" - font_style: "H6" - - MDSwitch: - id: voice_low_latency - pos_hint: {"center_y": 0.3} - active: False - MDLabel: text: "Audio Devices" font_style: "H6" diff --git a/setup.py b/setup.py index 8fffab3..604c0c1 100644 --- a/setup.py +++ b/setup.py @@ -114,17 +114,20 @@ setuptools.setup( ] }, install_requires=[ - "rns>=1.1.2", - "lxmf>=0.9.4", - "lxst>=0.4.6", + "rns>=0.9.3", + "lxmf>=0.6.2", "kivy>=2.3.0", - "numpy>=2.0.0", "pillow>=10.2.0", - "mistune>=3.0.2", "qrcode", "materialyoucolor>=2.0.7", + "ffpyplayer", + "sh", + "numpy<=1.26.4", + "lxst>=0.2.7", + "mistune>=3.0.2", "beautifulsoup4", - "pycodec2>=4.1.0", + "pycodec2;sys.platform!='Windows' and sys.platform!='win32' and sys.platform!='darwin'", + "pyaudio;sys.platform=='linux'", "pyobjus;sys.platform=='darwin'", "pyogg;sys.platform=='Windows' and sys.platform!='win32'", "audioop-lts>=0.2.1;python_version>='3.13'" diff --git a/winbuild.bat b/winbuild.bat deleted file mode 100644 index eaf61d4..0000000 --- a/winbuild.bat +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -cd sideband_sources\sbapp -for /f "delims=" %%v in ('powershell.exe -Command "python gv.py"') do set version=%%v -cd ..\.. -echo Compiling Sideband %version% - -cd sideband_sources -python -m PyInstaller sideband.spec --noconfirm -cd .. - -set "source_dir=Sideband_%version%" -set "zip_file=Sideband_%version%.zip" -move sideband_sources\dist\main %source_dir% -powershell.exe -Command "Compress-Archive -Path '%source_dir%' -DestinationPath '%zip_file%' -Force" - -echo Build completed \ No newline at end of file