pax_global_header00006660000000000000000000000064152341255550014521gustar00rootroot0000000000000052 comment=ce3e52ef30e93cd8126beec1f4dd40a0b019036d meshy/000077500000000000000000000000001523412555500122125ustar00rootroot00000000000000meshy/.gitattributes000066400000000000000000000004121523412555500151020ustar00rootroot00000000000000web/ export-ignore scripts/ export-ignore .forgejo/ export-ignore .pre-commit-config.yaml export-ignore .pre-commit-hooks.md export-ignore .woodpecker.yml export-ignore flake.nix export-ignore flake.lock export-ignore page.codeberg.sesivany.Meshy.json export-ignore meshy/.gitignore000066400000000000000000000002211523412555500141750ustar00rootroot00000000000000result .flatpak-builder repo *.flatpak *.mo *.po~ builddir-appimage dist/ dist-homebrew/ .cache/appimage/ __pycache__/ .DS_Store builddir-macos/ meshy/.python-version000066400000000000000000000000051523412555500152120ustar00rootroot000000000000003.14 meshy/LICENSE000066400000000000000000001034301523412555500132200ustar00rootroot00000000000000GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. “This License” refers to version 3 of the GNU General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. A “covered work” means either the unmodified Program or a work based on the Program. To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. “Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. meshy Copyright (C) 2026 sesivany This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: meshy Copyright (C) 2026 sesivany This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . meshy/README.md000066400000000000000000000227051523412555500134770ustar00rootroot00000000000000# Meshy [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) Meshy is a GTK4/libadwaita client for [MeshCore](https://meshcore.io/). The goal is to provide the best Linux experience. The app is already fairly feature complete and stable, but note it is still in active development, so things may still significantly change. Feedback with missing features, ideas and bugs is welcome. Meshy screenshot Meshy screenshot ## Installation ### Flathub ``` flatpak install flathub page.codeberg.sesivany.Meshy ``` ### Meshy Flatpak repo 1. Add the repo: ``` flatpak remote-add --if-not-exists meshy https://meshy-app.org/meshy.flatpakrepo ``` 2. Install Meshy: ``` flatpak install meshy page.codeberg.sesivany.Meshy ``` To install the development version (latest from main branch) instead: ``` flatpak install meshy page.codeberg.sesivany.Meshy//master ``` ### AppImage > The AppImage format is not officially supported. It is provided 'as is' and with no support. Download [the latest AppImage from](http://meshy-app.org/appimage/Meshy.AppImage), make it executable and run: ``` chmod +x Meshy.AppImage ./Meshy.AppImage ``` ### Building from source #### Flatpak Install `flatpak-builder` and the GNOME SDK: ``` flatpak install flathub org.gnome.Sdk//50 org.gnome.Platform//50 ``` Build and install: ``` flatpak-builder --force-clean --user --install builddir page.codeberg.sesivany.Meshy.json ``` Or to create a `.flatpak` bundle: ``` flatpak-builder --force-clean --repo=repo builddir page.codeberg.sesivany.Meshy.json flatpak build-bundle repo meshy.flatpak page.codeberg.sesivany.Meshy master ``` All dependencies (including third-party Python packages and native libraries) are handled by the Flatpak manifest automatically. #### AppImage Meshy also includes a local AppImage build path intended for packaging tests and release automation. Build dependencies are the same as the native build, plus: * `linuxdeploy` (required for AppImage bundling; the script can download it automatically) * `curl` or `wget` if you want the script to download missing AppImage tools automatically Build a local AppImage: ``` uv run ./build-aux/appimage/build-appimage.sh --download-tools ``` This is the normal full build path. It writes a staged AppDir and the final AppImage to `dist/`. If `linuxdeploy` is already installed on your machine, you can skip the download step: ``` uv run ./build-aux/appimage/build-appimage.sh ``` When the script downloads `linuxdeploy` as an AppImage, it runs it with `APPIMAGE_EXTRACT_AND_RUN=1` so CI and other FUSE-less environments do not need `/dev/fuse`. The final artifact is `dist/Meshy.AppImage`. Useful variants: ``` # Stop after producing dist/AppDir for inspection uv run ./build-aux/appimage/build-appimage.sh --appdir-only --download-tools # Build an AppImage without QR scanner support uv run ./build-aux/appimage/build-appimage.sh --download-tools --no-qr ``` The generated AppImage uses the same packaging flow that CI uses, so local runs exercise the release path directly. #### Native build ##### Dependencies Build dependencies: * meson (>= 0.62.0) * ninja * python3 * glib2 (gio-2.0, glib-compile-resources, glib-compile-schemas) * gtk4 (gtk4-update-icon-cache) * desktop-file-utils (desktop-file-validate) * appstream (appstreamcli) * gettext (msgfmt) Runtime dependencies: * gtk4 * libadwaita * gstreamer * libshumate * geoclue2 * zbar (optional, for QR code scanning) * python3-gobject (PyGObject) * python3-pycryptodome (pip: `pycryptodome`) * python3-pyzbar (pip: `pyzbar`) (optional, for QR code scanning) * python3-pyserial * python3-segno (pip: `segno`) On Fedora: ``` sudo dnf install meson ninja-build python3 glib2-devel gtk4-devel libadwaita-devel \ gstreamer1-devel libshumate-devel geoclue2-devel zbar-devel \ python3-gobject python3-pyserial desktop-file-utils appstream gettext pip install pycryptodome pyzbar segno ``` On Debian/Ubuntu: ``` sudo apt install meson ninja-build python3 libglib2.0-dev libgtk-4-dev libadwaita-1-dev \ libgstreamer1.0-dev libshumate-dev libzbar-dev \ python3-gi python3-pycryptodome python3-pyzbar python3-serial python3-segno \ desktop-file-utils appstream gettext ``` ##### Build options | Option | Default | Description | |--------|---------|-------------| | `qr_scanner` | `true` | Enable QR code scanning support (requires zbar and pyzbar) | | `shortcuts_dialog` | `true` | Enable keyboard shortcuts dialog (requires libadwaita >= 1.8) | To build without QR code scanning (e.g. if zbar or pyzbar are not available on your distribution): ``` meson setup builddir --prefix=/usr -Dqr_scanner=false ``` The "Scan QR Code" option will be hidden from the UI when built without this feature. To build without the keyboard shortcuts dialog (e.g. on systems with libadwaita < 1.8 such as Debian Stable): ``` meson setup builddir --prefix=/usr -Dshortcuts_dialog=false ``` The "Keyboard Shortcuts" menu item will be hidden from the UI when built without this feature. ##### Build and install ``` meson setup builddir --prefix=/usr meson compile -C builddir sudo meson install -C builddir ``` ### macOS Meshy supports macOS 13+ with full native integration including CoreBluetooth (BLE), CoreLocation (GPS), USB serial, and camera support. #### Option 1: Homebrew Cask (Recommended) The easiest installation method for developers: ```bash brew tap jreznik/meshy https://codeberg.org/jreznik/homebrew-meshy brew install meshy ``` This installs a 15 MB bundle that uses GTK4 and libadwaita from Homebrew. All dependencies are installed automatically. To update: ```bash brew upgrade meshy ``` To completely uninstall (including app data): ```bash brew uninstall --zap meshy ``` #### Option 2: Standalone DMG For users who don't want to install Homebrew, a fully self-contained DMG is available: **Download:** [Meshy-Standalone-20260610.dmg](dist/Meshy-Standalone-20260610.dmg) (151 MB) Installation: 1. Download the DMG 2. Open the DMG file 3. Drag Meshy.app to your Applications folder 4. Launch Meshy from Applications The standalone bundle includes everything: Python 3.14, GTK4, libadwaita, and all dependencies. No additional software needs to be installed. #### Building from Source on macOS Prerequisites: ```bash # Install Homebrew dependencies brew install python@3.14 gtk4 libadwaita libshumate meson ninja # Install Python packages (required for BLE, GPS, camera, notifications) pip3.14 install pycryptodome pyserial segno pygobject \ pyobjc-framework-Cocoa \ pyobjc-framework-CoreBluetooth \ pyobjc-framework-CoreLocation \ pyobjc-framework-AVFoundation \ pyobjc-framework-Vision \ pyobjc-framework-UserNotifications \ pyobjc-framework-libdispatch ``` Build and run: ```bash meson setup builddir --prefix=/usr/local meson compile -C builddir PANGOCAIRO_BACKEND=fc ./builddir/src/meshy ``` **Note:** When using the Homebrew Cask or creating app bundles with the build scripts, all Python dependencies are bundled automatically. Or create a Homebrew-style app bundle: ```bash ./build-aux/macos/build-homebrew-bundle.sh open dist-homebrew/Meshy.app ``` Or create a fully self-contained bundle: ```bash ./build-aux/macos/create-standalone-bundle.sh open dist/Meshy.app ``` See [STANDALONE_BUNDLE_SUCCESS.md](STANDALONE_BUNDLE_SUCCESS.md) for technical details about the standalone bundle. ## Development ### Code Quality Tools The project uses automated code quality checks via [pre-commit](https://pre-commit.com/) hooks: **Setup** (one time): ```bash # Install pre-commit (Fedora) sudo dnf install pre-commit ruff # Or with pip pip install --user pre-commit ruff # Install git hooks pre-commit install ``` **What the hooks do**: - **Ruff linter** - Fast Python linter checking code style, imports, and common issues - **Ruff formatter** - Automatic code formatting for consistent style - **i18n check** - Validates that UI strings are marked translatable - **Standard checks** - Trailing whitespace, EOF fixes, YAML validation, merge conflict detection **Usage**: Hooks run automatically on every commit: ```bash git commit -m "Fix: something" # Hooks run automatically and may modify files ``` Manual run on all files: ```bash pre-commit run --all-files ``` Run just ruff: ```bash ruff check src/ ruff format src/ ``` See [.pre-commit-hooks.md](.pre-commit-hooks.md) for detailed documentation. ### Running Tests Currently the project uses manual testing via the GUI. Automated unit tests for core modules (protocol, crypto, storage) are planned for future development. ### Project Structure - `src/` - Python source code - `views/` - UI view components (chat, channels, contacts, map, device, settings) - `protocol.py` - MeshCore protocol implementation - `mesh_crypto.py` - Cryptographic operations - `storage.py` - SQLite database management - `models.py` - Data models - `data/` - GTK resources, UI files, icons, schemas - `po/` - Translation files - `build-aux/` - Build scripts (AppImage) ## Translations Meshy uses [Codeberg Translate](https://translate.codeberg.org/) (Weblate) for translations. If you'd like to help with translations, go check [existing ones](https://translate.codeberg.org/projects/meshy/application/). If your language is in the list, you can start translating as a registered user. If it isn't among existing translations, [file a ticket](https://codeberg.org/sesivany/meshy/issues/new) and request it. meshy/build-aux/000077500000000000000000000000001523412555500141045ustar00rootroot00000000000000meshy/build-aux/appimage/000077500000000000000000000000001523412555500156675ustar00rootroot00000000000000meshy/build-aux/appimage/AppRun000077500000000000000000000027321523412555500170260ustar00rootroot00000000000000#!/bin/sh set -eu APPDIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" export APPDIR export PATH="$APPDIR/usr/bin:$PATH" export PYTHONHOME="$APPDIR/usr" PYTHONPATH_ENTRIES="" for LIBDIR in "$APPDIR/usr/lib" "$APPDIR/usr/lib64"; do for PYTHON_DIR in $(find "$LIBDIR" -maxdepth 1 -type d -name 'python3.*' 2>/dev/null | sort -V -r); do for PYTHON_SUBDIR in site-packages dist-packages; do PYTHON_PATH="$PYTHON_DIR/$PYTHON_SUBDIR" if [ -d "$PYTHON_PATH" ]; then PYTHONPATH_ENTRIES="${PYTHONPATH_ENTRIES:+$PYTHONPATH_ENTRIES:}$PYTHON_PATH" fi done done done export PYTHONPATH="$PYTHONPATH_ENTRIES${PYTHONPATH:+:$PYTHONPATH}" export LD_LIBRARY_PATH="$APPDIR/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export GI_TYPELIB_PATH="$APPDIR/usr/lib/girepository-1.0${GI_TYPELIB_PATH:+:$GI_TYPELIB_PATH}" export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas" export XDG_DATA_DIRS="$APPDIR/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" if [ -d "$APPDIR/usr/lib/gstreamer-1.0" ]; then export GST_PLUGIN_SYSTEM_PATH="$APPDIR/usr/lib/gstreamer-1.0${GST_PLUGIN_SYSTEM_PATH:+:$GST_PLUGIN_SYSTEM_PATH}" fi if [ -f "$APPDIR/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" ]; then export GDK_PIXBUF_MODULEDIR="$APPDIR/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders" export GDK_PIXBUF_MODULE_FILE="$APPDIR/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" fi exec "$APPDIR/usr/bin/python3" "$APPDIR/usr/bin/meshy" "$@" meshy/build-aux/appimage/build-appimage.sh000077500000000000000000000246321523412555500211150ustar00rootroot00000000000000#!/usr/bin/env bash # Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later set -euo pipefail APP_ID="page.codeberg.sesivany.Meshy" APP_NAME="Meshy" SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" BUILD_DIR="$ROOT_DIR/builddir-appimage" DIST_DIR="$ROOT_DIR/dist" APPDIR="$DIST_DIR/AppDir" TOOLS_DIR="$ROOT_DIR/.cache/appimage" ARCH="${ARCH:-$(uname -m)}" WITH_QR=1 DOWNLOAD_TOOLS=0 APPDIR_ONLY=0 log_step() { printf '\n==> %s\n' "$1" } usage() { cat <<'EOF' Usage: ./build-aux/appimage/build-appimage.sh [options] Options: --appdir-only Build the staged AppDir but skip the final AppImage step --build-dir PATH Meson build directory to use --dist-dir PATH Output directory for AppDir and final AppImage --download-tools Download linuxdeploy and appimagetool into .cache/appimage --no-qr Build without QR scanner support -h, --help Show this help text EOF } while [ "$#" -gt 0 ]; do case "$1" in --appdir-only) APPDIR_ONLY=1 ;; --build-dir) BUILD_DIR="$2" shift ;; --dist-dir) DIST_DIR="$2" APPDIR="$DIST_DIR/AppDir" shift ;; --download-tools) DOWNLOAD_TOOLS=1 ;; --no-qr) WITH_QR=0 ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 1 ;; esac shift done if [ "$ARCH" != "x86_64" ]; then echo "Only x86_64 AppImage builds are supported by this script right now." >&2 exit 1 fi mkdir -p "$DIST_DIR" "$TOOLS_DIR" require_tool() { if ! command -v "$1" >/dev/null 2>&1; then echo "Missing required tool: $1" >&2 exit 1 fi } download_tool() { local target="$1" local url="$2" if [ ! -x "$target" ]; then if command -v curl >/dev/null 2>&1; then curl -L "$url" -o "$target" elif command -v wget >/dev/null 2>&1; then wget -O "$target" "$url" else echo "Missing curl or wget; cannot download $url" >&2 exit 1 fi chmod +x "$target" fi } resolve_appimage_tool() { local binary_name="$1" local appimage_name="$2" local url="$3" local target="$TOOLS_DIR/$appimage_name" if command -v "$binary_name" >/dev/null 2>&1; then command -v "$binary_name" return fi if command -v "$appimage_name" >/dev/null 2>&1; then command -v "$appimage_name" return fi if [ -x "$target" ]; then echo "$target" return fi if [ "$DOWNLOAD_TOOLS" -eq 1 ]; then download_tool "$target" "$url" echo "$target" return fi echo "" } run_appimage_tool() { local tool="$1" shift if [[ "$tool" == *.AppImage ]]; then APPIMAGE_EXTRACT_AND_RUN=1 "$tool" "$@" else "$tool" "$@" fi } LINUXDEPLOY_BIN="$(resolve_appimage_tool "linuxdeploy" "linuxdeploy-x86_64.AppImage" "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage")" APPIMAGETOOL_BIN="$(resolve_appimage_tool "appimagetool" "appimagetool-x86_64.AppImage" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage")" require_tool python3 require_tool meson require_tool pkg-config require_tool glib-compile-schemas BUILD_PYTHON="$(command -v python3)" SYSTEM_PYTHON="/usr/bin/python3" if [ ! -x "$SYSTEM_PYTHON" ]; then echo "Missing required system interpreter: $SYSTEM_PYTHON" >&2 exit 1 fi TYPELIB_DIR="$(pkg-config --variable=typelibdir gobject-introspection-1.0)" LDCONFIG_BIN="$(command -v ldconfig || true)" if [ -z "$LDCONFIG_BIN" ]; then for candidate in /sbin/ldconfig /usr/sbin/ldconfig; do if [ -x "$candidate" ]; then LDCONFIG_BIN="$candidate" break fi done fi if [ -z "$LDCONFIG_BIN" ]; then echo "Missing required tool: ldconfig" >&2 exit 1 fi if [ -z "$LINUXDEPLOY_BIN" ]; then echo "Missing linuxdeploy. Install it or rerun with --download-tools." >&2 exit 1 fi if [ -z "$APPIMAGETOOL_BIN" ]; then echo "Missing appimagetool. Install it or rerun with --download-tools." >&2 exit 1 fi QROPT="-Dqr_scanner=true" if [ "$WITH_QR" -ne 1 ]; then QROPT="-Dqr_scanner=false" fi log_step "Preparing build directories" rm -rf "$APPDIR" "$BUILD_DIR" log_step "Configuring Meson" meson setup "$BUILD_DIR" --prefix=/usr --buildtype=release "$QROPT" log_step "Compiling project" meson compile -C "$BUILD_DIR" log_step "Installing into AppDir" DESTDIR="$APPDIR" meson install -C "$BUILD_DIR" log_step "Installing AppImage launcher and metadata" cp "$SCRIPT_DIR/AppRun" "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun" ln -srf "$APPDIR/usr/share/applications/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" ln -srf "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -srf "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/.DirIcon" SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas" log_step "Compiling GSettings schemas" glib-compile-schemas "$SCHEMA_DIR" PYTHON_VERSION="$("$SYSTEM_PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" PYTHON_STDLIB="$("$SYSTEM_PYTHON" -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')" PYTHON_LIBDIR_NAME="$("$SYSTEM_PYTHON" -c 'import sysconfig; import os; print(os.path.basename(sysconfig.get_config_var("LIBDIR")))')" TARGET_PYTHON_DIR="$APPDIR/usr/lib/python$PYTHON_VERSION" TARGET_SITE_PACKAGES="$TARGET_PYTHON_DIR/site-packages" mkdir -p "$APPDIR/usr/bin" "$TARGET_SITE_PACKAGES" "$APPDIR/usr/lib/girepository-1.0" log_step "Copying Python runtime" cp -aL "$SYSTEM_PYTHON" "$APPDIR/usr/bin/python3" cp -a "$PYTHON_STDLIB/." "$TARGET_PYTHON_DIR/" if [ "$PYTHON_LIBDIR_NAME" != "lib" ] && [ ! -e "$APPDIR/usr/$PYTHON_LIBDIR_NAME" ]; then ln -s "lib" "$APPDIR/usr/$PYTHON_LIBDIR_NAME" fi copy_python_module() { local interpreter="$1" local module="$2" "$interpreter" - "$module" "$TARGET_SITE_PACKAGES" <<'PY' import importlib.util import pathlib import shutil import sys module = sys.argv[1] target_site = pathlib.Path(sys.argv[2]) spec = importlib.util.find_spec(module) if spec is None or spec.origin is None: print(f"Missing Python module {module}", file=sys.stderr) sys.exit(1) origin = pathlib.Path(spec.origin) if origin.name == "__init__.py": dest = target_site / module shutil.copytree(origin.parent, dest, dirs_exist_ok=True) else: dest = target_site / origin.name dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(origin, dest) PY } copy_python_module "$BUILD_PYTHON" "Crypto" copy_python_module "$BUILD_PYTHON" "serial" copy_python_module "$BUILD_PYTHON" "segno" copy_python_module "$SYSTEM_PYTHON" "gi" copy_python_module "$SYSTEM_PYTHON" "cairo" if [ "$WITH_QR" -eq 1 ]; then copy_python_module "$BUILD_PYTHON" "pyzbar" fi log_step "Collecting GObject introspection data" copy_typelib() { local typelib="$1" local src="$TYPELIB_DIR/$typelib.typelib" if [ -f "$src" ]; then cp -a "$src" "$APPDIR/usr/lib/girepository-1.0/" fi } copy_typelib "Gtk-4.0" copy_typelib "Adw-1" copy_typelib "Gio-2.0" copy_typelib "GLib-2.0" copy_typelib "GdkPixbuf-2.0" copy_typelib "Shumate-1.0" copy_typelib "Geoclue-2.0" copy_typelib "Gdk-4.0" copy_typelib "GObject-2.0" log_step "Resolving GTK stack libraries for bundling" resolve_system_lib() { "$LDCONFIG_BIN" -p | grep "$1" | head -1 | awk '{print $NF}' || true } BUNDLE_LIBS=() for lib_name in libgtk-4.so libadwaita-1.so libshumate-1.0.so; do lib_path="$(resolve_system_lib "$lib_name")" if [ -n "$lib_path" ] && [ -f "$lib_path" ]; then BUNDLE_LIBS+=(--library "$lib_path") echo " Will bundle: $lib_path" else echo " Warning: $lib_name not found on system" >&2 fi done log_step "Bundling shared libraries with linuxdeploy (patchelf disabled)" NOOP_PATCHELF_DIR="$(mktemp -d)" cat > "$NOOP_PATCHELF_DIR/patchelf" << 'NOOP' #!/bin/sh exit 0 NOOP chmod +x "$NOOP_PATCHELF_DIR/patchelf" if [[ "$LINUXDEPLOY_BIN" == *.AppImage ]]; then LINUXDEPLOY_EXTRACTED="$(mktemp -d)" cd "$LINUXDEPLOY_EXTRACTED" "$LINUXDEPLOY_BIN" --appimage-extract >/dev/null 2>&1 cp "$NOOP_PATCHELF_DIR/patchelf" "$LINUXDEPLOY_EXTRACTED/squashfs-root/usr/bin/patchelf" NO_STRIP=true ARCH="$ARCH" "$LINUXDEPLOY_EXTRACTED/squashfs-root/AppRun" \ --appdir "$APPDIR" \ --executable "$APPDIR/usr/bin/python3" \ "${BUNDLE_LIBS[@]}" \ --desktop-file "$APPDIR/usr/share/applications/$APP_ID.desktop" \ --icon-file "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" rm -rf "$LINUXDEPLOY_EXTRACTED" cd "$ROOT_DIR" else PATH="$NOOP_PATCHELF_DIR:$PATH" NO_STRIP=true ARCH="$ARCH" "$LINUXDEPLOY_BIN" \ --appdir "$APPDIR" \ --executable "$APPDIR/usr/bin/python3" \ "${BUNDLE_LIBS[@]}" \ --desktop-file "$APPDIR/usr/share/applications/$APP_ID.desktop" \ --icon-file "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" fi rm -rf "$NOOP_PATCHELF_DIR" log_step "Bundling gdk-pixbuf loaders" GDK_PIXBUF_MODULEDIR_SYS="$(pkg-config --variable=gdk_pixbuf_moduledir gdk-pixbuf-2.0 2>/dev/null || true)" if [ -n "$GDK_PIXBUF_MODULEDIR_SYS" ] && [ -d "$GDK_PIXBUF_MODULEDIR_SYS" ]; then shopt -s nullglob LOADER_FILES=("$GDK_PIXBUF_MODULEDIR_SYS"/*.so) shopt -u nullglob if [ "${#LOADER_FILES[@]}" -gt 0 ]; then APPDIR_LOADERS="$APPDIR/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders" mkdir -p "$APPDIR_LOADERS" cp -a "${LOADER_FILES[@]}" "$APPDIR_LOADERS/" if command -v gdk-pixbuf-query-loaders >/dev/null 2>&1; then gdk-pixbuf-query-loaders "$APPDIR_LOADERS/"*.so > "$APPDIR/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" 2>/dev/null || true fi else echo " No gdk-pixbuf loader modules found (likely built-in), skipping" fi else echo " gdk-pixbuf module dir not found, skipping" fi APPIMAGE_PATH="$DIST_DIR/${APP_NAME}.AppImage" if [ "$APPDIR_ONLY" -eq 1 ]; then exit 0 fi log_step "Building final AppImage" ARCH="$ARCH" run_appimage_tool "$APPIMAGETOOL_BIN" "$APPDIR" "$APPIMAGE_PATH" log_step "Done" echo "AppImage written to $APPIMAGE_PATH" meshy/build-aux/flatpak/000077500000000000000000000000001523412555500155265ustar00rootroot00000000000000meshy/build-aux/flatpak/page.codeberg.sesivany.Meshy-flathub.json000066400000000000000000000135161523412555500254630ustar00rootroot00000000000000{ "app-id": "page.codeberg.sesivany.Meshy", "runtime": "org.gnome.Platform", "runtime-version": "50", "sdk": "org.gnome.Sdk", "command": "meshy", "finish-args": [ "--share=ipc", "--socket=fallback-x11", "--socket=wayland", "--system-talk-name=org.bluez", "--share=network", "--device=all" ], "cleanup": [ "/include", "/lib/pkgconfig", "*.la", "*.a" ], "modules": [ { "name": "zbar", "buildsystem": "autotools", "config-opts": [ "--without-qt", "--without-gtk", "--without-java", "--without-imagemagick", "--without-dbus", "--without-npapi", "--without-xv", "--without-xshm", "--disable-video", "--disable-nls" ], "cleanup": [ "/share/doc" ], "sources": [ { "type": "archive", "url": "https://linuxtv.org/downloads/zbar/zbar-0.23.93.tar.bz2", "sha256": "83be8f85fc7c288fd91f98d52fc55db7eedbddcf10a83d9221d7034636683fa0" } ] }, { "name": "python-pyzbar", "buildsystem": "simple", "build-commands": [ "pip3 install --no-index --find-links=. --prefix=/app pyzbar" ], "cleanup": [ "/bin/read_zbar*", "/bin/__pycache__", "/lib/python*/site-packages/pyzbar/tests", "/lib/python*/site-packages/pyzbar*.dist-info" ], "sources": [ { "type": "file", "url": "https://files.pythonhosted.org/packages/6d/24/81ebe6a1c00760471a3028a23cbe0b94e5fa2926e5ba47adc895920887bc/pyzbar-0.1.9-py2.py3-none-any.whl", "sha256": "4559628b8192feb25766d954b36a3753baaf5c97c03135aec7e4a026036b475d" } ] }, { "name": "python-crypto-deps", "buildsystem": "simple", "build-commands": [ "pip3 install --no-index --no-build-isolation --prefix=/app ." ], "cleanup": [ "/lib/python*/site-packages/Crypto/SelfTest", "/lib/python*/site-packages/pycryptodome*.dist-info" ], "sources": [ { "type": "archive", "url": "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", "sha256": "447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } ] }, { "name": "python-segno", "buildsystem": "simple", "build-commands": [ "pip3 install --no-index --find-links=. --prefix=/app segno" ], "cleanup": [ "/bin/segno", "/share/man", "/lib/python*/site-packages/segno*.dist-info" ], "sources": [ { "type": "file", "url": "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", "sha256": "28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7" } ] }, { "name": "python-pyserial", "buildsystem": "simple", "build-commands": [ "pip3 install --no-index --find-links=. --prefix=/app pyserial" ], "cleanup": [ "/bin/pyserial-*", "/lib/python*/site-packages/pyserial*.dist-info" ], "sources": [ { "type": "file", "url": "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", "sha256": "c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0" } ] }, { "name": "protobuf-c", "buildsystem": "autotools", "config-opts": [ "--disable-protoc" ], "sources": [ { "type": "archive", "url": "https://github.com/protobuf-c/protobuf-c/releases/download/v1.5.2/protobuf-c-1.5.2.tar.gz", "sha256": "e2c86271873a79c92b58fef7ebf8de1aa0df4738347a8bd5d4e65a80a16d0d24" } ] }, { "name": "libshumate", "buildsystem": "meson", "config-opts": [ "-Dgtk_doc=false", "-Ddemos=false", "-Dvapi=false", "-Dsysprof=disabled" ], "cleanup": [ "/share/gir-1.0" ], "sources": [ { "type": "archive", "url": "https://download.gnome.org/sources/libshumate/1.6/libshumate-1.6.1.tar.xz", "sha256": "b36aad34500791785f546684d0f2ed644e4819ff4e85ae67a2245f159eccb2d4" } ] }, { "name": "meshy", "buildsystem": "meson", "sources": [ { "type": "git", "url": "https://codeberg.org/sesivany/meshy.git", "tag": "26.08", "commit": "c498ac4405f3690b4f6ea55f64cafa646803629f" } ] } ] } meshy/build-aux/macos/000077500000000000000000000000001523412555500152065ustar00rootroot00000000000000meshy/build-aux/macos/README.md000066400000000000000000000217511523412555500164730ustar00rootroot00000000000000# Building Meshy on macOS This directory contains tools for building Meshy on macOS. ## Prerequisites ### System Dependencies (Homebrew) ```bash # Install Homebrew if not already installed /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Install required dependencies brew install gtk4 libadwaita gstreamer meson ninja pkg-config brew install pygobject3 python@3.11 ``` ### Python Dependencies ```bash # Core dependencies pip install pycryptodome pyserial segno # macOS-specific BLE and system integration pip install pyobjc-framework-CoreBluetooth pip install pyobjc-framework-CoreLocation # Optional: Camera/QR scanning (Phase 3) pip install pyobjc-framework-AVFoundation pip install pyobjc-framework-Vision ``` Alternatively, use `uv` for faster installs: ```bash uv pip install pycryptodome pyserial segno uv pip install pyobjc-framework-CoreBluetooth pyobjc-framework-CoreLocation ``` ## Building ### Versioning Convention Meshy follows the same versioning strategy on macOS as on Linux: - **Stable releases:** Version from git tag (e.g., `26.06`) - Built when on a tagged commit - Matches meson.build `version: '26.06'` - DMG name: `Meshy-26.06.dmg` - **Unstable/Nightly:** Date-based version (e.g., `26.06.19`) - Built from `main` branch or untagged commits - Format: `YY.MM.DD` (matching Linux date_version) - DMG name: `Meshy-26.06.19.dmg` The version is automatically determined by the build scripts: - If on a git tag → use tag version - Otherwise → use current date in `YY.MM.DD` format ### Development Build For development, build with meson and run directly: ```bash meson setup builddir --prefix=/usr meson compile -C builddir sudo meson install -C builddir # Run meshy ``` ### Homebrew-Based App Bundle (Recommended for Development) Creates a small (~20 MB) bundle that uses GTK4/libadwaita from Homebrew: ```bash # Build unsigned app bundle ./build-aux/macos/build-homebrew-bundle.sh # Build unsigned and create DMG ./build-aux/macos/build-homebrew-bundle.sh --dmg # Build, sign, and create DMG for distribution ./build-aux/macos/build-homebrew-bundle.sh --sign "Developer ID Application: Your Name (TEAMID)" --dmg ``` Output: - `dist-homebrew/Meshy.app` - Application bundle - `dist-homebrew/Meshy-${VERSION}.dmg` - DMG installer (if --dmg flag used) **Requirements:** Users must have `brew install gtk4 libadwaita python@3.14` ### Standalone App Bundle (Recommended for Distribution) Creates a fully self-contained (~150 MB) bundle with all dependencies: ```bash # Build unsigned self-contained bundle ./build-aux/macos/create-standalone-bundle.sh # Build unsigned and create DMG ./build-aux/macos/create-standalone-bundle.sh --dmg # Build, sign, and create DMG for distribution ./build-aux/macos/create-standalone-bundle.sh --sign "Developer ID Application: Your Name (TEAMID)" --dmg ``` Output: - `dist/Meshy.app` - Self-contained application bundle - `dist/Meshy-${VERSION}.dmg` - DMG installer (if --dmg flag used) **No runtime dependencies required** - users can drag-and-drop to Applications. ### Code Signing Both build scripts support optional code signing for distribution: **Options:** - `--sign "IDENTITY"` - Sign with specified identity from Keychain - `--dmg` - Create DMG installer **Common identities:** - `"Developer ID Application: Your Name (TEAMID)"` - For distribution outside App Store - `"Apple Development: Your Name (TEAMID)"` - For development/testing - `"-"` - Ad-hoc signature (default when --sign not provided, not recommended) **Examples:** ```bash # Unsigned (default) ./build-aux/macos/build-homebrew-bundle.sh --dmg # Signed for distribution ./build-aux/macos/build-homebrew-bundle.sh \ --sign "Developer ID Application: John Doe (ABC123XYZ)" \ --dmg # List available signing identities security find-identity -v -p codesigning ``` **Notarization:** After signing for distribution, you should notarize the DMG: ```bash # Submit for notarization (requires Apple Developer account) xcrun notarytool submit dist/Meshy-26.06.dmg \ --apple-id "your@email.com" \ --team-id "TEAMID" \ --password "app-specific-password" \ --wait # Staple the notarization ticket xcrun stapler staple dist/Meshy-26.06.dmg ``` ## Current Implementation Status ### ✅ Platform Infrastructure - Platform detection module (`src/platforms/__init__.py`) - Factory pattern for platform-specific implementations - macOS-specific UI adaptations (traffic light buttons, window controls) ### ✅ USB Serial - USB serial device discovery for macOS (`/dev/cu.usbmodem-*`, `/dev/cu.usbserial-*`) - Automatic detection and connection ### ✅ BLE (CoreBluetooth) - Full CoreBluetooth implementation via PyObjC - Device scanning with NUS service UUID filter - Device pairing and connection - GATT service and characteristic discovery - Bidirectional data transfer (TX/RX characteristics) - Thread-safe callbacks to GTK main loop - Automatic reconnection handling ### ✅ Location Services (CoreLocation) - CoreLocation integration via PyObjC - Permission handling (Info.plist) - Location updates when app is in use - Thread-safe coordinate updates to GTK ### ✅ Camera & QR Scanner (AVFoundation + Vision) - Native camera access via AVFoundation - Real-time camera preview using custom GdkPaintable - QR code detection using Vision framework - Matches Linux UI/UX with native macOS backend ### ✅ Notifications - Native macOS notification center integration - UserNotifications framework via PyObjC - Permission handling - Notification delivery and display ### ✅ System Integration - Login items (auto-start on login) - App activation handling - Proper window lifecycle management - GResource bundling for icons and UI files ## Platform Differences ### Bluetooth - **Linux:** Uses BlueZ D-Bus API - **macOS:** Uses CoreBluetooth framework via PyObjC - Pairing handled automatically by system - No explicit passkey dialogs (system manages) ### USB Serial - **Linux:** `/dev/ttyACM*`, `/dev/ttyUSB*` - **macOS:** `/dev/cu.usbserial-*`, `/dev/cu.usbmodem-*` - No udev rules needed ### Location - **Linux:** GeoClue2 D-Bus API - **macOS:** CoreLocation framework (requires Info.plist permission) ### Camera - **Linux:** XDG Portal + PipeWire - **macOS:** AVFoundation + Vision framework ## Known Issues & Workarounds 1. **Emoji Rendering Crash (Bus Error 10)** - **Issue:** CoreText backend crashes when rendering certain emojis - **Workaround:** Using `PANGOCAIRO_BACKEND=fc` (fontconfig) instead of CoreText - **Status:** Temporary workaround in place, upstream Pango issue #871 2. **Map Widget Instability** - **Issue:** Shumate map widget can crash with GdkMacos backend - **Workaround:** Wrapped in try/except with graceful fallback - **Status:** Works but may fail on some macOS versions 3. **Window Maximize Behavior** - **Issue:** GTK4 maximize not native macOS full-screen - **Status:** Disabled maximize button, using macOS traffic lights ## Contributing When adding macOS-specific code: 1. Use the platform detection utilities in `src/platforms/__init__.py` 2. Place platform-specific implementations in `src/platforms/macos/` 3. Keep Linux implementation working (test on both platforms) 4. Use `GLib.idle_add()` for callbacks from Objective-C to GTK main thread ## Testing ### USB Serial 1. Connect MeshCore device via USB 2. Launch Meshy 3. Go to Connection view → USB tab 4. Verify device appears as `/dev/cu.usbmodem-*` or `/dev/cu.usbserial-*` 5. Test connection and data transfer ### BLE (CoreBluetooth) 1. Ensure MeshCore device is in BLE mode and advertising 2. Launch Meshy 3. Grant Bluetooth permissions when prompted 4. Go to Connection view → BLE tab 5. Click "Scan for devices" 6. Verify device appears with NUS service 7. Connect and test data transfer ### Location Services 1. Launch Meshy 2. Grant location permissions when prompted (Info.plist description shown) 3. Navigate to a view that uses location 4. Verify location updates work correctly ### Camera & QR Scanner 1. Launch Meshy 2. Grant camera permissions when prompted 3. Go to Contacts → + → Scan QR Code 4. Verify camera preview shows live video 5. Point at a QR code 6. Verify QR code is detected and scanned within 2-3 seconds ### Notifications 1. Launch Meshy 2. Grant notification permissions when prompted 3. Send a message to trigger a notification 4. Verify notification appears in macOS Notification Center ### Build Scripts ```bash # Test Homebrew bundle build ./build-aux/macos/build-homebrew-bundle.sh open dist-homebrew/Meshy.app # Test standalone bundle build ./build-aux/macos/create-standalone-bundle.sh open dist/Meshy.app # Test DMG creation ./build-aux/macos/build-homebrew-bundle.sh --dmg # Verify dist-homebrew/Meshy-${VERSION}.dmg exists # Test versioning # On tag: should use tag version (e.g., 26.06) git tag -a 26.06.test -m "Test tag" ./build-aux/macos/build-homebrew-bundle.sh --dmg # Should create Meshy-26.06.test.dmg # Off tag: should use date version (e.g., 26.06.19) git checkout main ./build-aux/macos/build-homebrew-bundle.sh --dmg # Should create Meshy-YY.MM.DD.dmg ``` meshy/build-aux/macos/SIGNING-GUIDE.md000066400000000000000000000130101523412555500174540ustar00rootroot00000000000000# Meshy macOS Code Signing and Notarization Guide ## Quick Start **Before first use:** ```bash # Store your Apple ID credentials for notarization (one-time setup) xcrun notarytool store-credentials "meshy-notary" \ --apple-id "your@email.com" \ --team-id "LG87HQ83LT" \ --password "your-app-specific-password" ``` **To build, sign, and notarize both versions:** ```bash ./build-aux/macos/build-and-notarize-all.sh ``` This will create: - `dist-homebrew/Meshy-YY.MM.DD.dmg` - Homebrew-optimized (~20 MB) - `dist/Meshy-YY.MM.DD.dmg` - Standalone (~150 MB) Both fully signed and notarized, ready for distribution! --- ## Available Scripts ### 1. `build-and-notarize-all.sh` (Recommended) **What it does:** Builds, signs, and notarizes BOTH Homebrew and Standalone versions **Use when:** You want to release both versions at once (typical release workflow) **Timeline:** - Homebrew build: ~2 min - Homebrew notarization: ~3-5 min - Standalone build: ~2 min - Standalone notarization: ~3-5 min - **Total: ~10-15 minutes** **Output:** ``` dist-homebrew/Meshy-26.06.20.dmg (~20 MB, requires Homebrew GTK4) dist/Meshy-26.06.20.dmg (~150 MB, fully self-contained) ``` ### 2. `build-and-notarize.sh` **What it does:** Builds, signs, and notarizes ONLY the standalone version **Use when:** You only want the self-contained bundle (most users) **Timeline:** ~5-7 minutes **Output:** ``` dist/Meshy-26.06.20.dmg (~150 MB, fully self-contained) ``` ### 3. Individual build scripts (without notarization) **For development/testing only** - not for distribution! ```bash # Homebrew-optimized (unsigned) ./build-aux/macos/build-homebrew-bundle.sh # Homebrew-optimized (signed, not notarized) ./build-aux/macos/build-homebrew-bundle.sh \ --sign "Developer ID Application: Jaroslav Reznik (LG87HQ83LT)" # Standalone (unsigned) ./build-aux/macos/create-standalone-bundle.sh # Standalone (signed, not notarized) ./build-aux/macos/create-standalone-bundle.sh \ --sign "Developer ID Application: Jaroslav Reznik (LG87HQ83LT)" ``` --- ## Which Version Should Users Download? ### Homebrew-optimized (~20 MB) **For:** Developers and power users who already use Homebrew **Requirements:** ```bash brew install gtk4 libadwaita python@3.14 ``` **Pros:** - ✅ Small download size - ✅ Uses system-wide GTK4/libadwaita (stays up-to-date) - ✅ Less disk space **Cons:** - ❌ Requires Homebrew installation - ❌ Users must install dependencies manually ### Standalone (~150 MB) **For:** General users, non-technical users **Requirements:** None - fully self-contained **Pros:** - ✅ Zero dependencies - ✅ Just drag-and-drop to Applications - ✅ Works immediately after download **Cons:** - ❌ Larger download size - ❌ Uses bundled GTK4/libadwaita (not auto-updated) **Recommendation:** Provide both! Users can choose based on their needs. --- ## Credentials Setup (One-Time) ### 1. Get App-Specific Password 1. Go to https://appleid.apple.com/account/manage 2. Under "Sign-In and Security" → "App-Specific Passwords" 3. Click **+** to generate new password 4. Label: "Meshy Notarization" 5. **Copy the password** (you can't see it again) ### 2. Store Credentials in Keychain ```bash xcrun notarytool store-credentials "meshy-notary" \ --apple-id "your@email.com" \ --team-id "LG87HQ83LT" \ --password "xxxx-xxxx-xxxx-xxxx" ``` Replace: - `your@email.com` - Your Apple Developer account email - `xxxx-xxxx-xxxx-xxxx` - The app-specific password from step 1 This stores credentials securely in macOS Keychain. You'll never need to type them again! --- ## Troubleshooting ### "0 valid identities found" **Problem:** Certificate not in login keychain or missing private key **Solution:** ```bash # Check if certificate exists security find-identity -v -p codesigning # Import intermediate certificate curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db rm DeveloperIDG2CA.cer ``` ### Notarization fails **Problem:** Invalid credentials or network issue **Solution:** ```bash # Get detailed error log (replace with actual submission ID from error) xcrun notarytool log --keychain-profile "meshy-notary" # Re-store credentials if invalid xcrun notarytool store-credentials "meshy-notary" \ --apple-id "your@email.com" \ --team-id "LG87HQ83LT" \ --password "xxxx-xxxx-xxxx-xxxx" ``` ### "The staple and validate action failed" **Problem:** DMG wasn't notarized or notarization failed **Solution:** - Check that notarization succeeded before stapling - Re-run the build script (it handles retries) ### Manual verification commands ```bash # Verify app signature codesign --verify --verbose dist/Meshy.app spctl --assess --verbose dist/Meshy.app # Verify DMG signature codesign --verify --verbose dist/Meshy-26.06.20.dmg spctl --assess --type open --context context:primary-signature --verbose dist/Meshy-26.06.20.dmg # Verify notarization ticket is stapled stapler validate dist/Meshy-26.06.20.dmg ``` --- ## Release Checklist - [ ] Set up notarization credentials (one-time): `xcrun notarytool store-credentials` - [ ] Run `./build-aux/macos/build-and-notarize-all.sh` - [ ] Wait ~10-15 minutes for both builds to complete - [ ] Verify both DMGs open without Gatekeeper warnings - [ ] Upload DMGs to release page - [ ] Update release notes with system requirements for each version --- ## Your Signing Identity ``` Developer ID Application: Jaroslav Reznik (LG87HQ83LT) ``` This is already configured in all scripts. No manual editing needed! meshy/build-aux/macos/build-and-notarize-all.sh000077500000000000000000000076071523412555500220150ustar00rootroot00000000000000#!/bin/bash # Build, sign, and notarize both Homebrew and Standalone versions of Meshy for macOS # # Prerequisites: # 1. Developer ID Application certificate installed in Keychain # 2. Notarization credentials stored with: xcrun notarytool store-credentials # # Usage: Run from project root directory: # ./build-aux/macos/build-and-notarize-all.sh set -e # Change to project root directory cd "$(dirname "$0")/../.." # Configuration - UPDATE THESE VALUES SIGNING_IDENTITY="Developer ID Application: Jaroslav Reznik (LG87HQ83LT)" KEYCHAIN_PROFILE="meshy-notary" # Name you used in 'notarytool store-credentials' # Get version from build VERSION=$(date +%y.%m.%d) echo "======================================================================" echo "Building, signing, and notarizing Meshy v${VERSION}" echo "======================================================================" echo "" # ============================================================ # HOMEBREW-OPTIMIZED BUNDLE # ============================================================ echo "===> HOMEBREW-OPTIMIZED BUNDLE" echo "===> Step 1/4: Building signed Homebrew bundle and DMG..." ./build-aux/macos/build-homebrew-bundle.sh \ --sign "${SIGNING_IDENTITY}" \ --dmg HOMEBREW_DMG="dist-homebrew/Meshy-${VERSION}.dmg" if [ ! -f "${HOMEBREW_DMG}" ]; then echo "Error: Homebrew DMG not found at ${HOMEBREW_DMG}" exit 1 fi echo "✓ Built and signed: ${HOMEBREW_DMG}" echo "" echo "===> Step 2/4: Verifying Homebrew bundle signature..." codesign --verify --verbose dist-homebrew/Meshy.app echo "✓ Code signature verified (not yet notarized - will verify after notarization)" echo "" echo "===> Step 3/4: Submitting Homebrew DMG for notarization..." xcrun notarytool submit "${HOMEBREW_DMG}" \ --keychain-profile "${KEYCHAIN_PROFILE}" \ --wait echo "✓ Notarization complete" echo "" echo "===> Step 4/4: Stapling Homebrew DMG notarization ticket..." xcrun stapler staple "${HOMEBREW_DMG}" echo "✓ Notarization ticket stapled" echo "" echo "===> Verifying Homebrew DMG..." spctl --assess --type open --context context:primary-signature --verbose "${HOMEBREW_DMG}" echo "" echo "✅ Homebrew bundle complete: ${HOMEBREW_DMG}" echo "" echo "" # ============================================================ # STANDALONE BUNDLE (Notarization disabled) # ============================================================ echo "===> STANDALONE BUNDLE" echo "===> Step 1/2: Building signed standalone bundle and DMG..." ./build-aux/macos/create-standalone-bundle.sh \ --sign "${SIGNING_IDENTITY}" \ --dmg STANDALONE_DMG="dist/Meshy-${VERSION}.dmg" if [ ! -f "${STANDALONE_DMG}" ]; then echo "Error: Standalone DMG not found at ${STANDALONE_DMG}" exit 1 fi echo "✓ Built and signed: ${STANDALONE_DMG}" echo "" echo "===> Step 2/2: Verifying standalone bundle signature..." codesign --verify --verbose dist/Meshy.app echo "✓ Code signature verified" echo "" echo "⚠️ Standalone notarization SKIPPED (Python.framework signing issues)" echo "✅ Standalone bundle built (signed but not notarized): ${STANDALONE_DMG}" echo "" echo "" # ============================================================ # SUMMARY # ============================================================ echo "======================================================================" echo "✅ SUCCESS! Build complete" echo "======================================================================" echo "" echo "Homebrew-optimized (NOTARIZED ✅):" echo " 📦 ${HOMEBREW_DMG}" echo " 📊 Size: $(du -h "${HOMEBREW_DMG}" | cut -f1)" echo " ✓ Ready for public distribution (no Gatekeeper warnings)" echo "" echo "Standalone (signed but NOT notarized ⚠️):" echo " 📦 ${STANDALONE_DMG}" echo " 📊 Size: $(du -h "${STANDALONE_DMG}" | cut -f1)" echo " ⚠️ Will show Gatekeeper warnings - for testing only" echo "" echo "Recommended for distribution: Use Homebrew bundle (notarized)" echo "" meshy/build-aux/macos/build-and-notarize.sh000077500000000000000000000035171523412555500212430ustar00rootroot00000000000000#!/bin/bash # Build and sign standalone Meshy bundle for macOS # # NOTE: Notarization is currently DISABLED for standalone bundles due to # Python.framework signing complexity. Use the Homebrew bundle for # notarized distribution. # # Prerequisites: # 1. Developer ID Application certificate installed in Keychain # # Usage: Run from project root directory: # ./build-aux/macos/build-and-notarize.sh set -e # Change to project root directory cd "$(dirname "$0")/../.." # Configuration - UPDATE THESE VALUES SIGNING_IDENTITY="Developer ID Application: Jaroslav Reznik (LG87HQ83LT)" KEYCHAIN_PROFILE="meshy-notary" # Name you used in 'notarytool store-credentials' # Get version from build VERSION=$(date +%y.%m.%d) echo "===> Building and signing Meshy v${VERSION}..." echo "" # Step 1: Build signed standalone bundle with DMG echo "===> Step 1/4: Building signed app bundle and DMG..." ./build-aux/macos/create-standalone-bundle.sh \ --sign "${SIGNING_IDENTITY}" \ --dmg DMG_FILE="dist/Meshy-${VERSION}.dmg" if [ ! -f "${DMG_FILE}" ]; then echo "Error: DMG not found at ${DMG_FILE}" exit 1 fi echo "✓ Built and signed: ${DMG_FILE}" echo "" # Step 2: Verify code signature echo "===> Step 2/2: Verifying code signature..." codesign --verify --verbose dist/Meshy.app echo "✓ Code signature verified" echo "" echo "===> ⚠️ NOTARIZATION SKIPPED" echo "" echo "Standalone bundle notarization is currently disabled due to" echo "Python.framework signing complexity issues." echo "" echo "For notarized distribution, use the Homebrew bundle:" echo " ./build-aux/macos/build-homebrew-bundle.sh --sign \"${SIGNING_IDENTITY}\" --dmg" echo "" echo "Signed (but not notarized) DMG created:" echo " ${DMG_FILE}" echo "" echo "Note: This DMG will show Gatekeeper warnings when downloaded." echo "Use for testing only, not for public distribution." meshy/build-aux/macos/build-app-bundle.sh000077500000000000000000000131021523412555500206660ustar00rootroot00000000000000#!/bin/bash # Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later # Build macOS .app bundle for Meshy # This script creates a relocatable macOS application bundle set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color info() { echo -e "${GREEN}==>${NC} $1" } warn() { echo -e "${YELLOW}Warning:${NC} $1" } error() { echo -e "${RED}Error:${NC} $1" exit 1 } # Check we're on macOS if [[ "$(uname)" != "Darwin" ]]; then error "This script must be run on macOS" fi # Check required tools command -v meson >/dev/null 2>&1 || error "meson not found. Install with: brew install meson" command -v ninja >/dev/null 2>&1 || error "ninja not found. Install with: brew install ninja" command -v pkg-config >/dev/null 2>&1 || error "pkg-config not found. Install with: brew install pkg-config" # Get version using same logic as meson.build # If on a tag, use tag; otherwise use date version (YY.MM.DD format) if git describe --tags --exact-match >/dev/null 2>&1; then # On a tag - use tag version (e.g., 26.06) VERSION=$(git describe --tags --exact-match 2>/dev/null) else # Not on a tag - use date version matching meson's date_version (YY.MM.DD) VERSION=$(python3 -c 'import time; print(time.strftime("%y.%m.%d", time.gmtime()))') fi info "Building Meshy ${VERSION} for macOS" # Build directory BUILDDIR="builddir-macos" APPDIR="dist/Meshy.app" # Clean previous build if [[ -d "$BUILDDIR" ]]; then info "Cleaning previous build directory" rm -rf "$BUILDDIR" fi if [[ -d "dist" ]]; then info "Cleaning previous dist directory" rm -rf dist fi # Configure with meson info "Configuring with meson" meson setup "$BUILDDIR" --prefix=/usr # Build info "Building with ninja" meson compile -C "$BUILDDIR" # Create app bundle structure info "Creating app bundle structure" mkdir -p "$APPDIR/Contents/MacOS" mkdir -p "$APPDIR/Contents/Resources" mkdir -p "$APPDIR/Contents/Frameworks" # Install to temporary location info "Installing to app bundle" DESTDIR="$(pwd)/dist/tmp" meson install -C "$BUILDDIR" # Copy installed files to app bundle info "Copying application files" # Copy the main executable cp "dist/tmp/usr/bin/meshy" "$APPDIR/Contents/MacOS/" # Patch the launcher script to use Homebrew Python and set up bundle environment info "Configuring launcher script" sed -i '' '1s|.*|#!/opt/homebrew/bin/python3|' "$APPDIR/Contents/MacOS/meshy" # Add bundle environment setup after the imports cat > /tmp/meshy_env_setup.txt << 'ENVEOF' # When running from app bundle, set up environment script_path = os.path.realpath(__file__) macos_dir = os.path.dirname(script_path) contents_dir = os.path.dirname(macos_dir) resources_dir = os.path.join(contents_dir, 'Resources') # Add bundle Python path bundle_python_path = os.path.join(resources_dir, 'lib', 'python') if os.path.exists(bundle_python_path): sys.path.insert(0, bundle_python_path) os.environ.setdefault('MESHY_PKGDATADIR', os.path.join(resources_dir, 'share', 'meshy')) # Set up GTK environment variables os.environ.setdefault('GI_TYPELIB_PATH', '/opt/homebrew/lib/girepository-1.0') os.environ.setdefault('XDG_DATA_DIRS', f"{resources_dir}/share:/opt/homebrew/share:/usr/local/share:/usr/share") ENVEOF # Insert the environment setup after the "import gettext" line sed -i '' '/^import gettext$/r /tmp/meshy_env_setup.txt' "$APPDIR/Contents/MacOS/meshy" rm /tmp/meshy_env_setup.txt # Copy Python sources mkdir -p "$APPDIR/Contents/Resources/lib/python" cp -r "dist/tmp/usr/lib/"*"/site-packages/meshy" "$APPDIR/Contents/Resources/lib/python/" 2>/dev/null || true # Fallback: copy from source if meson install didn't copy them if [[ ! -d "$APPDIR/Contents/Resources/lib/python/meshy" ]]; then cp -r src "$APPDIR/Contents/Resources/lib/python/meshy" fi # Copy data files mkdir -p "$APPDIR/Contents/Resources/share/meshy" cp -r "dist/tmp/usr/share/meshy/"* "$APPDIR/Contents/Resources/share/meshy/" 2>/dev/null || true # Copy GSettings schemas mkdir -p "$APPDIR/Contents/Resources/share/glib-2.0/schemas" cp -r "dist/tmp/usr/share/glib-2.0/schemas/"* "$APPDIR/Contents/Resources/share/glib-2.0/schemas/" 2>/dev/null || true # Copy icons mkdir -p "$APPDIR/Contents/Resources/share/icons" cp -r "dist/tmp/usr/share/icons/"* "$APPDIR/Contents/Resources/share/icons/" 2>/dev/null || true # Copy app icon to Resources (macOS looks for it here) info "Copying app icon" if [[ -f "data/icons/hicolor/512x512/apps/page.codeberg.sesivany.Meshy.png" ]]; then cp "data/icons/hicolor/512x512/apps/page.codeberg.sesivany.Meshy.png" "$APPDIR/Contents/Resources/Meshy.png" fi # Generate Info.plist info "Generating Info.plist" sed "s/@VERSION@/$VERSION/g" data/macos/Info.plist.in > "$APPDIR/Contents/Info.plist" # TODO: Copy GTK4 and libadwaita frameworks from Homebrew # This would require bundling the frameworks or using dylibbundler warn "GTK4/libadwaita frameworks not bundled - app requires Homebrew installation" warn "To run: Install dependencies with 'brew install gtk4 libadwaita'" # Compile GSettings schemas in bundle if command -v glib-compile-schemas >/dev/null 2>&1; then info "Compiling GSettings schemas" glib-compile-schemas "$APPDIR/Contents/Resources/share/glib-2.0/schemas" 2>/dev/null || true fi # Clean up temporary install directory rm -rf dist/tmp info "App bundle created: $APPDIR" info "" info "To test the app:" info " open $APPDIR" info "" info "Note: This is a development bundle that requires GTK4/libadwaita from Homebrew" info "For distribution, you would need to bundle the frameworks or use gtk-mac-bundler" meshy/build-aux/macos/build-homebrew-bundle.sh000077500000000000000000000251571523412555500217330ustar00rootroot00000000000000#!/bin/bash # Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later # Create Homebrew-optimized Meshy.app bundle # Uses dependencies from Homebrew instead of bundling them # Result: Small ~15-20 MB bundle that requires gtk4/libadwaita from brew set -e # Colors GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' info() { echo -e "${GREEN}==>${NC} $1"; } warn() { echo -e "${YELLOW}Warning:${NC} $1"; } error() { echo -e "${RED}Error:${NC} $1"; exit 1; } [[ "$(uname)" == "Darwin" ]] || error "Must run on macOS" # Parse command line options SIGN_IDENTITY="" CREATE_DMG=false NOTARIZE=false KEYCHAIN_PROFILE="" while [[ $# -gt 0 ]]; do case "$1" in --sign) SIGN_IDENTITY="$2" shift 2 ;; --dmg) CREATE_DMG=true shift ;; --notarize) NOTARIZE=true KEYCHAIN_PROFILE="$2" shift 2 ;; --help|-h) echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" echo " --sign IDENTITY Sign the bundle with specified identity" echo " (default: unsigned)" echo " --dmg Create DMG installer after building" echo " --notarize PROFILE Notarize DMG using keychain profile" echo " (requires --dmg and --sign)" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 # Build unsigned bundle" echo " $0 --dmg # Build and create unsigned DMG" echo " $0 --sign \"Developer ID\" # Build and sign" echo " $0 --sign \"Developer ID\" --dmg # Build, sign, and create DMG" echo " $0 --sign \"Developer ID\" --dmg --notarize prof # Build, sign, create DMG, and notarize" exit 0 ;; *) error "Unknown option: $1 (use --help for usage)" ;; esac done # Validate options if [[ "$NOTARIZE" == "true" ]]; then [[ "$CREATE_DMG" == "true" ]] || error "--notarize requires --dmg" [[ -n "$SIGN_IDENTITY" ]] || error "--notarize requires --sign" [[ -n "$KEYCHAIN_PROFILE" ]] || error "--notarize requires a keychain profile name" fi # Get version using same logic as meson.build (matching Linux versioning) # If on a tag, use tag; otherwise use date version (YY.MM.DD format) if git describe --tags --exact-match >/dev/null 2>&1; then # On a tag - use tag version (e.g., 26.06) VERSION=$(git describe --tags --exact-match 2>/dev/null) else # Not on a tag - use date version matching meson's date_version (YY.MM.DD) VERSION=$(python3 -c 'import time; print(time.strftime("%y.%m.%d", time.gmtime()))') fi # Verify Homebrew dependencies info "Checking Homebrew dependencies" brew list gtk4 >/dev/null 2>&1 || error "gtk4 not installed. Run: brew install gtk4" brew list libadwaita >/dev/null 2>&1 || error "libadwaita not installed. Run: brew install libadwaita" brew list python@3.14 >/dev/null 2>&1 || error "python@3.14 not installed. Run: brew install python@3.14" # Configuration APP_NAME="Meshy" DIST_DIR="dist-homebrew" APP_DIR="$DIST_DIR/${APP_NAME}.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" info "Building Homebrew-optimized ${APP_NAME}.app bundle (version ${VERSION})" # Step 1: Build base app info "Step 1/6: Building base application" bash build-aux/macos/build-app-bundle.sh # Move to homebrew dist directory rm -rf "$DIST_DIR" mkdir -p "$DIST_DIR" cp -R dist/Meshy.app "$APP_DIR" info "Step 2/6: Installing Python packages (minimal - no framework bundling)" mkdir -p "$RESOURCES_DIR/lib/python-packages" # Use Homebrew Python to install packages # Force ARM64 architecture to avoid Rosetta requirement export ARCHFLAGS="-arch arm64" export _PYTHON_HOST_PLATFORM="macosx-14.0-arm64" arch -arm64 /opt/homebrew/bin/python3.14 -m pip install \ --target="$RESOURCES_DIR/lib/python-packages" \ pycryptodome \ pyserial \ segno \ pyobjc-framework-Cocoa \ pyobjc-framework-CoreBluetooth \ pyobjc-framework-CoreLocation \ pyobjc-framework-AVFoundation \ pyobjc-framework-Vision \ pyobjc-framework-UserNotifications \ pyobjc-framework-libdispatch \ pygobject # Strip x86_64 architecture from universal binaries to avoid Rosetta requirement info "Stripping x86_64 from universal binaries..." find "$RESOURCES_DIR/lib/python-packages" -type f \( -name "*.so" -o -name "*.dylib" \) | while read -r binary; do if file "$binary" | grep -q "universal binary"; then lipo "$binary" -thin arm64 -output "$binary.arm64" 2>/dev/null && mv "$binary.arm64" "$binary" || true fi done info "Step 3/6: Compiling native launcher (so permissions show 'Meshy' not 'python 3.14')" # Compile C launcher - this makes macOS show "Meshy" instead of "python 3.14" in permission dialogs cc -O2 -arch arm64 -o "$MACOS_DIR/meshy" build-aux/macos/launcher.c info "Step 4/6: Creating Python entry point" cat > "$RESOURCES_DIR/lib/python/meshy/__main__.py" << 'PYTHON_EOF' #!/usr/bin/env python3 import os import sys import gettext # Set up package data directory pkgdatadir = os.environ.get('MESHY_PKGDATADIR', '/opt/homebrew/share/meshy') localedir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'share', 'locale') # Initialize gettext for translations gettext.install('meshy', localedir) # Initialize meshy package globals import meshy as _meshy_pkg _meshy_pkg.pkgdatadir = pkgdatadir _meshy_pkg.QR_SCANNER_ENABLED = True # Native AVFoundation + Vision implementation _meshy_pkg.SHORTCUTS_DIALOG_ENABLED = True # Verify GObject Introspection can load macOS backend import gi gi.require_version("Gtk", "4.0") try: gi.require_version("GdkMacos", "4.0") except ValueError as e: print(f"✗ CRITICAL: Cannot load GdkMacos backend: {e}", file=sys.stderr) print(f" Ensure GTK4 is installed: brew install gtk4", file=sys.stderr) sys.exit(1) # Import GTK and verify initialization from gi.repository import Gtk, Gio print(f"✓ GTK {Gtk.get_major_version()}.{Gtk.get_minor_version()}.{Gtk.get_micro_version()} (Homebrew)", file=sys.stderr) # Load GResource file gresource_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'share', 'meshy', 'meshy.gresource') if os.path.exists(gresource_path): resource = Gio.Resource.load(gresource_path) Gio.resources_register(resource) print(f"✓ Loaded GResource from {gresource_path}", file=sys.stderr) else: print(f"✗ WARNING: GResource not found at {gresource_path}", file=sys.stderr) from meshy.main import main if __name__ == '__main__': sys.exit(main('$VERSION')) PYTHON_EOF STEP_NUM=5 if [[ "$NOTARIZE" == "true" ]]; then TOTAL_STEPS=6 else TOTAL_STEPS=5 fi info "Step $STEP_NUM/$TOTAL_STEPS: Code signing bundle" if [[ -n "$SIGN_IDENTITY" ]]; then info "Signing with identity: $SIGN_IDENTITY" # Create entitlements file for hardened runtime ENTITLEMENTS_FILE="$DIST_DIR/entitlements.plist" cat > "$ENTITLEMENTS_FILE" << 'EOF' com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation EOF # Sign all .so files and dylibs in Python packages (required for notarization) info "Signing Python native extensions..." find "$RESOURCES_DIR/lib/python-packages" -type f \( -name "*.so" -o -name "*.dylib" \) 2>/dev/null | while read -r lib; do codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime \ --entitlements "$ENTITLEMENTS_FILE" "$lib" 2>/dev/null || true done # Sign the launcher codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime \ --entitlements "$ENTITLEMENTS_FILE" "$MACOS_DIR/meshy" || error "Failed to sign launcher" # Sign the app bundle codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime \ --entitlements "$ENTITLEMENTS_FILE" "$APP_DIR" || error "Failed to sign app bundle" info "✓ Bundle signed successfully" else info "Skipping code signing (use --sign to enable)" fi # Calculate bundle size BUNDLE_SIZE=$(du -sh "$APP_DIR" | awk '{print $1}') info "" info "✅ Homebrew-optimized bundle created successfully!" info "" info "Bundle size: $BUNDLE_SIZE" info "Location: $APP_DIR" info "" info "Dependencies (from Homebrew):" info " • gtk4 (brew install gtk4)" info " • libadwaita (brew install libadwaita)" info " • python@3.14 (brew install python@3.14)" info "" info "To test:" info " open $APP_DIR" info "" # Create DMG if requested if [[ "$CREATE_DMG" == "true" ]]; then info "Creating DMG installer..." DMG_NAME="Meshy-${VERSION}.dmg" DMG_PATH="$DIST_DIR/$DMG_NAME" hdiutil create -volname "Meshy ${VERSION}" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" # Sign DMG if identity provided if [[ -n "$SIGN_IDENTITY" ]]; then info "Signing DMG..." codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_PATH" || warn "Failed to sign DMG" fi DMG_SIZE=$(du -sh "$DMG_PATH" | awk '{print $1}') info "" info "✅ DMG created: $DMG_PATH ($DMG_SIZE)" info "" # Notarize DMG if requested if [[ "$NOTARIZE" == "true" ]]; then info "Step 6/6: Notarizing DMG..." info "Submitting $DMG_NAME to Apple notary service..." SUBMIT_OUTPUT=$(xcrun notarytool submit "$DMG_PATH" --keychain-profile "$KEYCHAIN_PROFILE" --wait 2>&1) if echo "$SUBMIT_OUTPUT" | grep -q "status: Accepted"; then info "✓ Notarization accepted" info "Stapling notarization ticket..." xcrun stapler staple "$DMG_PATH" info "" info "✅ DMG notarized successfully!" info "" else error "Notarization failed. Output:\n$SUBMIT_OUTPUT" fi fi info "Distribution naming convention:" info " Stable release: Meshy-26.06.dmg (from tag)" info " Unstable/Nightly: Meshy-26.06.19.dmg (date version)" info "" else info "To create DMG for distribution:" info " $0 --dmg" info " Or manually: hdiutil create -volname 'Meshy ${VERSION}' -srcfolder $APP_DIR -ov -format UDZO $DIST_DIR/Meshy-${VERSION}.dmg" info "" fi meshy/build-aux/macos/create-standalone-bundle.sh000077500000000000000000000521121523412555500224060ustar00rootroot00000000000000#!/bin/bash # Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later # Create fully self-contained Meshy.app bundle # Bundles Python, GTK4, libadwaita, and all dependencies # Result: Users can drag-and-drop to Applications with no runtime dependencies # # NOTE: Apple notarization currently fails for standalone bundles due to # Python.framework signing complexity. The bundle can be signed but not notarized. # For notarized distribution, use build-homebrew-bundle.sh instead. set -e # Colors GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' info() { echo -e "${GREEN}==>${NC} $1"; } warn() { echo -e "${YELLOW}Warning:${NC} $1"; } error() { echo -e "${RED}Error:${NC} $1"; exit 1; } [[ "$(uname)" == "Darwin" ]] || error "Must run on macOS" # Parse command line options SIGN_IDENTITY="" CREATE_DMG=false while [[ $# -gt 0 ]]; do case "$1" in --sign) SIGN_IDENTITY="$2" shift 2 ;; --dmg) CREATE_DMG=true shift ;; --help|-h) echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" echo " --sign IDENTITY Sign the bundle with specified identity" echo " (default: unsigned)" echo " --dmg Create DMG installer after building" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 # Build unsigned bundle" echo " $0 --dmg # Build and create unsigned DMG" echo " $0 --sign \"Developer ID\" # Build and sign" echo " $0 --sign \"Developer ID\" --dmg # Build, sign, and create DMG" exit 0 ;; *) error "Unknown option: $1 (use --help for usage)" ;; esac done # Get version using same logic as meson.build (matching Linux versioning) # If on a tag, use tag; otherwise use date version (YY.MM.DD format) if git describe --tags --exact-match >/dev/null 2>&1; then # On a tag - use tag version (e.g., 26.06) VERSION=$(git describe --tags --exact-match 2>/dev/null) else # Not on a tag - use date version matching meson's date_version (YY.MM.DD) VERSION=$(python3 -c 'import time; print(time.strftime("%y.%m.%d", time.gmtime()))') fi # Configuration APP_NAME="Meshy" APP_DIR="dist/${APP_NAME}.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" info "Building self-contained ${APP_NAME}.app bundle (version ${VERSION})" # Step 1: Build base app info "Step 1/7: Building base application" bash build-aux/macos/build-app-bundle.sh # Step 2: Create Python virtual environment in bundle info "Step 2/7: Creating bundled Python environment" mkdir -p "$FRAMEWORKS_DIR" # Copy Python framework from Homebrew PYTHON_FW_SRC="/opt/homebrew/opt/python@3.14/Frameworks/Python.framework" PYTHON_FW_DEST="$FRAMEWORKS_DIR/Python.framework" if [[ ! -d "$PYTHON_FW_SRC" ]]; then error "Python framework not found at $PYTHON_FW_SRC" fi info "Copying Python.framework (this takes a moment...)" cp -RL "$PYTHON_FW_SRC" "$PYTHON_FW_DEST" # Make Python framework writable for modifications chmod -R u+w "$PYTHON_FW_DEST" # Step 3: Install Python dependencies into bundle info "Step 3/7: Installing Python packages" BUNDLE_PYTHON="$PYTHON_FW_DEST/Versions/3.14/bin/python3.14" # Force ARM64 architecture to avoid Rosetta requirement export ARCHFLAGS="-arch arm64" export _PYTHON_HOST_PLATFORM="macosx-14.0-arm64" arch -arm64 "$BUNDLE_PYTHON" -m pip install \ --target="$RESOURCES_DIR/lib/python-packages" \ pycryptodome \ pyserial \ segno \ pyobjc-framework-Cocoa \ pyobjc-framework-CoreBluetooth \ pyobjc-framework-CoreLocation \ pyobjc-framework-AVFoundation \ pyobjc-framework-Vision \ pyobjc-framework-UserNotifications \ pyobjc-framework-libdispatch \ pygobject # Strip x86_64 architecture from universal binaries to avoid Rosetta requirement info "Stripping x86_64 from universal binaries..." find "$RESOURCES_DIR/lib/python-packages" -type f \( -name "*.so" -o -name "*.dylib" \) | while read -r binary; do if file "$binary" | grep -q "universal binary"; then lipo "$binary" -thin arm64 -output "$binary.arm64" 2>/dev/null && mv "$binary.arm64" "$binary" || true fi done # Step 4: Bundle GTK4 and dependencies info "Step 4/7: Bundling GTK4 frameworks and libraries" # Function to copy library and its dependencies copy_lib_with_deps() { local lib=$1 local src="/opt/homebrew/lib/$lib" local dest="$FRAMEWORKS_DIR/$lib" if [[ -f "$src" && ! -f "$dest" ]]; then cp "$src" "$dest" chmod u+w "$dest" fi } # Core GTK4 libraries and ALL their dependencies LIBS=( # GTK/Adwaita "libgtk-4.1.dylib" "libadwaita-1.0.dylib" "libadwaita-1.1.dylib" "libadwaita-1.dylib" # GLib family "libglib-2.0.0.dylib" "libgobject-2.0.0.dylib" "libgio-2.0.0.dylib" "libgmodule-2.0.0.dylib" "libgthread-2.0.0.dylib" "libgirepository-2.0.0.dylib" # Graphics "libgdk_pixbuf-2.0.0.dylib" "libcairo.2.dylib" "libcairo-gobject.2.dylib" "libcairo-script-interpreter.2.dylib" "libpixman-1.0.dylib" # Text rendering "libpango-1.0.0.dylib" "libpangocairo-1.0.0.dylib" "libpangoft2-1.0.0.dylib" "libharfbuzz.0.dylib" "libharfbuzz-gobject.0.dylib" "libharfbuzz-subset.0.dylib" "libfribidi.0.dylib" "libthai.0.dylib" "libgraphite2.3.dylib" # Graphics libraries "libgraphene-1.0.0.dylib" "libepoxy.0.dylib" # Core libraries "libffi.8.dylib" "libintl.8.dylib" "libpcre2-8.0.dylib" "libpng16.16.dylib" "libfreetype.6.dylib" "libfontconfig.1.dylib" "libjpeg.8.dylib" "libtiff.6.dylib" # Map "libshumate-1.0.1.dylib" "libjson-glib-1.0.0.dylib" "libsoup-3.0.0.dylib" # XML/Data "libxml2.2.dylib" "libxmlb.2.dylib" "libfyaml.0.dylib" "libsqlite3.0.dylib" "libsqlite3.dylib" # Compression "liblzma.5.dylib" "libbz2.1.0.dylib" "libz.1.dylib" "libzstd.1.dylib" # Security/Crypto "libssl.3.dylib" "libcrypto.3.dylib" # Other "libiconv.2.dylib" "libgstreamer-1.0.0.dylib" "libgstbase-1.0.0.dylib" "libprotobuf-c.1.dylib" "libpsl.5.dylib" "libnghttp2.14.dylib" # X11 (needed by Cairo on some systems) "libX11.6.dylib" "libXext.6.dylib" "libXrender.1.dylib" "libxcb.1.dylib" "libxcb-render.0.dylib" "libxcb-shm.0.dylib" "libXau.6.dylib" "libXdmcp.6.dylib" # Additional dependencies "libappstream.5.dylib" "libdatrie.1.dylib" "libidn2.0.dylib" "libunistring.5.dylib" "liblzo2.2.dylib" ) for lib in "${LIBS[@]}"; do copy_lib_with_deps "$lib" done # Special case: SQLite (used by Python) # Copy the versioned library and create symlinks SQLITE_SRC="/opt/homebrew/opt/sqlite/lib/libsqlite3.3.53.1.dylib" SQLITE_DEST="$FRAMEWORKS_DIR/libsqlite3.3.53.1.dylib" if [[ -f "$SQLITE_SRC" && ! -f "$SQLITE_DEST" ]]; then cp "$SQLITE_SRC" "$SQLITE_DEST" chmod u+w "$SQLITE_DEST" # Create symlinks ln -sf libsqlite3.3.53.1.dylib "$FRAMEWORKS_DIR/libsqlite3.dylib" ln -sf libsqlite3.3.53.1.dylib "$FRAMEWORKS_DIR/libsqlite3.0.dylib" fi # Bundle GObject Introspection typelibs info "Bundling GObject typelibs" TYPELIB_DEST="$RESOURCES_DIR/lib/girepository-1.0" mkdir -p "$TYPELIB_DEST" # Follow symlinks to copy actual typelib files (not broken symlinks) find /opt/homebrew/lib/girepository-1.0 -name "*.typelib" -type l -exec sh -c 'cp -L "$1" "'$TYPELIB_DEST'/$(basename "$1")"' _ {} \; # Bundle GTK modules and data info "Bundling GTK modules and themes" mkdir -p "$RESOURCES_DIR/lib/gtk-4.0/4.0.0" cp -RL "/opt/homebrew/lib/gtk-4.0/4.0.0"/* "$RESOURCES_DIR/lib/gtk-4.0/4.0.0/" 2>/dev/null || true # Also copy any top-level gtk-4.0 files cp -RL "/opt/homebrew/lib/gtk-4.0"/*.* "$RESOURCES_DIR/lib/gtk-4.0/" 2>/dev/null || true # Bundle GDK-Pixbuf loaders (follow symlinks with -L) mkdir -p "$RESOURCES_DIR/lib/gdk-pixbuf-2.0" cp -RL "/opt/homebrew/lib/gdk-pixbuf-2.0" "$RESOURCES_DIR/lib/" 2>/dev/null || true # Fix loaders.cache to point to bundled loaders instead of Homebrew LOADERS_CACHE="$RESOURCES_DIR/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" if [[ -f "$LOADERS_CACHE" ]]; then sed -i '' 's|/opt/homebrew/lib/gdk-pixbuf-2.0/2.10.0/loaders|@executable_path/../Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders|g' "$LOADERS_CACHE" sed -i '' 's|/opt/homebrew/Cellar/[^/]*/lib/gdk-pixbuf-2.0/2.10.0/loaders|@executable_path/../Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders|g' "$LOADERS_CACHE" fi # Bundle themes and icons info "Bundling icon themes (Adwaita + hicolor)" mkdir -p "$RESOURCES_DIR/share" cp -RL "/opt/homebrew/share/icons" "$RESOURCES_DIR/share/" 2>/dev/null || true cp -RL "/opt/homebrew/share/themes" "$RESOURCES_DIR/share/" 2>/dev/null || true # Update icon caches for bundled themes if command -v gtk4-update-icon-cache >/dev/null 2>&1; then for icon_theme in "$RESOURCES_DIR/share/icons"/*; do if [[ -d "$icon_theme" && -f "$icon_theme/index.theme" ]]; then theme_name=$(basename "$icon_theme") gtk4-update-icon-cache -f -t "$icon_theme" 2>/dev/null || warn "Could not update icon cache for $theme_name" fi done fi # Copy GResource file (compiled UI resources) cp "builddir-macos/data/meshy.gresource" "$RESOURCES_DIR/share/" 2>/dev/null || \ cp "data/page.codeberg.sesivany.Meshy.gresource" "$RESOURCES_DIR/share/meshy.gresource" 2>/dev/null || \ warn "Could not find GResource file" # Step 5: Fix library paths with install_name_tool info "Step 5/7: Fixing library paths" fix_library_paths() { local target=$1 if [[ ! -f "$target" ]]; then return fi # Change ID if it's a dylib if [[ "$target" == *.dylib ]]; then local filename=$(basename "$target") install_name_tool -id "@executable_path/../Frameworks/$filename" "$target" 2>/dev/null || true fi # Get all dependencies (including Cellar paths) local deps=$(otool -L "$target" 2>/dev/null | grep -E '^\s+/(opt/homebrew|usr/local)' | awk '{print $1}') for dep in $deps; do local depname=$(basename "$dep") # Fix Python.framework references if [[ "$dep" == */Python.framework/Versions/*/Python ]]; then # Use @loader_path for files inside Python.framework, @executable_path for others if [[ "$target" == *"/Python.framework/"* ]]; then install_name_tool -change "$dep" "@loader_path/../Python" "$target" 2>/dev/null || true else install_name_tool -change "$dep" "@executable_path/../Frameworks/Python.framework/Versions/3.14/Python" "$target" 2>/dev/null || true fi # Fix regular dylib references elif [[ -f "$FRAMEWORKS_DIR/$depname" ]]; then install_name_tool -change "$dep" "@executable_path/../Frameworks/$depname" "$target" 2>/dev/null || true fi done # Re-sign with ad-hoc signature after modification codesign --force --sign - "$target" 2>/dev/null || true } # Fix Python framework info "Fixing Python framework paths" # Fix Python.framework library ID first PYTHON_LIB="$PYTHON_FW_DEST/Versions/3.14/Python" if [[ -f "$PYTHON_LIB" ]]; then install_name_tool -id "@rpath/Python.framework/Versions/3.14/Python" "$PYTHON_LIB" 2>/dev/null || true codesign --force --sign - "$PYTHON_LIB" 2>/dev/null || true fi # Add rpath to python3.14 executable PYTHON_EXE="$PYTHON_FW_DEST/Versions/3.14/bin/python3.14" if [[ -f "$PYTHON_EXE" ]]; then install_name_tool -add_rpath "@loader_path/../../.." "$PYTHON_EXE" 2>/dev/null || true fi # Fix all binaries in Python framework find "$PYTHON_FW_DEST" -type f \( -name "*.dylib" -o -name "*.so" -o -perm +111 \) -print0 2>/dev/null | \ while IFS= read -r -d '' file; do fix_library_paths "$file" done # Special fix for all Resources/Python.app/Contents/MacOS/Python instances find "$PYTHON_FW_DEST" -path "*/Resources/Python.app/Contents/MacOS/Python" -type f | while read -r python_app; do install_name_tool -change "@loader_path/../Python" "@loader_path/../../../../Python" "$python_app" 2>/dev/null || true codesign --force --sign - "$python_app" 2>/dev/null || true done # Fix all bundled libraries info "Fixing bundled library paths" for lib in "$FRAMEWORKS_DIR"/*.dylib; do [[ -f "$lib" ]] && fix_library_paths "$lib" done # Fix Python _sqlite3 module to use bundled sqlite SQLITE_MODULE="$PYTHON_FW_DEST/Versions/3.14/lib/python3.14/lib-dynload/_sqlite3.cpython-314-darwin.so" if [[ -f "$SQLITE_MODULE" ]]; then install_name_tool -change "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib" \ "@executable_path/../Frameworks/libsqlite3.dylib" \ "$SQLITE_MODULE" 2>/dev/null || true codesign --force --sign - "$SQLITE_MODULE" 2>/dev/null || true fi # Step 6: Create standalone launcher info "Step 6/7: Creating standalone launcher" cat > "$MACOS_DIR/meshy" << 'LAUNCHER_EOF' #!/bin/bash # Meshy standalone launcher - fully self-contained DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTENTS="$DIR/.." RESOURCES="$CONTENTS/Resources" FRAMEWORKS="$CONTENTS/Frameworks" # Python environment export PYTHONHOME="$FRAMEWORKS/Python.framework/Versions/3.14" export PYTHONPATH="$RESOURCES/lib/python:$RESOURCES/lib/python-packages:$PYTHONHOME/lib/python3.14:$PYTHONHOME/lib/python3.14/lib-dynload" # GTK environment export DYLD_LIBRARY_PATH="$FRAMEWORKS:$DYLD_LIBRARY_PATH" export GI_TYPELIB_PATH="$RESOURCES/lib/girepository-1.0" export XDG_DATA_DIRS="$RESOURCES/share:/usr/local/share:/usr/share" export GTK_DATA_PREFIX="$RESOURCES" export GTK_EXE_PREFIX="$RESOURCES" export GTK_PATH="$RESOURCES" # Force GTK to use macOS backend export GDK_BACKEND=macos # GDK Pixbuf export GDK_PIXBUF_MODULEDIR="$RESOURCES/lib/gdk-pixbuf-2.0/2.10.0/loaders" export GDK_PIXBUF_MODULE_FILE="$RESOURCES/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" # Use fontconfig backend instead of CoreText to prevent emoji crashes (Bus error 10) # Workaround for Pango issue #871 - CoreText emoji rendering crashes on macOS # See: https://gitlab.gnome.org/GNOME/pango/-/work_items/871 export PANGOCAIRO_BACKEND=fc # Meshy specific export MESHY_PKGDATADIR="$RESOURCES/share/meshy" # Use bundled Python PYTHON="$FRAMEWORKS/Python.framework/Versions/3.14/bin/python3.14" exec "$PYTHON" "$RESOURCES/lib/python/meshy/__main__.py" "$@" LAUNCHER_EOF chmod +x "$MACOS_DIR/meshy" # Create Python entry point cat > "$RESOURCES_DIR/lib/python/meshy/__main__.py" << 'PYTHON_EOF' #!/usr/bin/env python3 import os import sys import gettext print("Starting Meshy from bundled __main__.py", file=sys.stderr) # Set up package data directory pkgdatadir = os.environ.get('MESHY_PKGDATADIR', '/usr/share/meshy') localedir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'share', 'locale') # Initialize gettext for translations (sets up global _ function) gettext.install('meshy', localedir) # Initialize meshy package globals import meshy as _meshy_pkg _meshy_pkg.pkgdatadir = pkgdatadir _meshy_pkg.QR_SCANNER_ENABLED = False # Not available on macOS yet _meshy_pkg.SHORTCUTS_DIALOG_ENABLED = True # Enable minimal GTK debug output # os.environ['GTK_DEBUG'] = 'all' # Disabled - too verbose # os.environ['G_MESSAGES_DEBUG'] = 'all' # Disabled - too verbose # Verify GObject Introspection can load macOS backend import gi gi.require_version("Gtk", "4.0") try: gi.require_version("GdkMacos", "4.0") print("✓ GdkMacos backend loaded successfully", file=sys.stderr) except ValueError as e: print(f"✗ CRITICAL: Cannot load GdkMacos backend: {e}", file=sys.stderr) print(f" GI_TYPELIB_PATH={os.environ.get('GI_TYPELIB_PATH', 'NOT SET')}", file=sys.stderr) sys.exit(1) # Import GTK and verify initialization from gi.repository import Gtk, Gio print(f"✓ GTK imported, version: {Gtk.get_major_version()}.{Gtk.get_minor_version()}.{Gtk.get_micro_version()}", file=sys.stderr) # Load GResource file gresource_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'share', 'meshy', 'meshy.gresource') if os.path.exists(gresource_path): resource = Gio.Resource.load(gresource_path) Gio.resources_register(resource) print(f"✓ Loaded GResource from {gresource_path}", file=sys.stderr) else: print(f"✗ WARNING: GResource not found at {gresource_path}", file=sys.stderr) from meshy.main import main if __name__ == '__main__': print("Calling meshy.main.main()", file=sys.stderr) sys.exit(main('$VERSION')) PYTHON_EOF # Step 7: Code sign the entire bundle info "Step 7/7: Code signing bundle" # Remove Python.app bundles BEFORE signing (not needed for our use, cause signing issues) info "Removing Python.app bundles..." find "$PYTHON_FW_DEST" -name "Python.app" -type d -print -exec rm -rf {} + 2>/dev/null || true if [[ -n "$SIGN_IDENTITY" ]]; then info "Signing with identity: $SIGN_IDENTITY" # Remove all existing signatures from Python.framework (came from Homebrew) # This prevents "sealed resource modified" errors when we sign internal files info "Removing existing Python.framework signatures..." codesign --remove-signature "$PYTHON_FW_DEST/Python" 2>/dev/null || true codesign --remove-signature "$PYTHON_FW_DEST/Versions/3.14/Python" 2>/dev/null || true find "$PYTHON_FW_DEST" -name "*.dylib" -o -name "*.so" | while read -r lib; do codesign --remove-signature "$lib" 2>/dev/null || true done # Sign all .so files and dylibs in Python packages (required for notarization) info "Signing Python native extensions..." find "$RESOURCES_DIR/lib/python-packages" -type f \( -name "*.so" -o -name "*.dylib" \) 2>/dev/null | while read -r lib; do codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime "$lib" 2>/dev/null || true done # Sign GDK Pixbuf loaders (.so and .dylib files) info "Signing GDK Pixbuf loaders..." find "$RESOURCES_DIR/lib/gdk-pixbuf-2.0" -type f \( -name "*.so" -o -name "*.dylib" \) 2>/dev/null | while read -r lib; do codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime "$lib" 2>/dev/null || true done # Sign all binaries in Python framework (dylibs, .so files, .a files, executables) info "Signing Python framework binaries..." find "$PYTHON_FW_DEST" -type f \( -name "*.dylib" -o -name "*.so" -o -name "*.a" -o -perm +111 \) 2>/dev/null | while read -r binary; do # Skip if it's a text file or script if file "$binary" | grep -q "Mach-O"; then codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime "$binary" 2>/dev/null || true fi done # Sign all bundled libraries info "Signing bundled libraries..." for lib in "$FRAMEWORKS_DIR"/*.dylib; do [[ -f "$lib" ]] && codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime "$lib" || warn "Failed to sign $lib" done # Skip Python.framework bundle signing due to bundle format ambiguity # All binaries inside are already signed, which is sufficient for notarization info "Skipping Python.framework bundle signature (all contents already signed)" # Sign the entire app bundle (do NOT use --deep to avoid Python.framework ambiguity) info "Signing app bundle..." codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime "$APP_DIR" || error "Failed to sign app bundle" info "✓ Bundle signed successfully" else info "Skipping code signing (use --sign to enable)" fi # Clean up info "Cleaning up temporary files" find "$APP_DIR" -name "*.pyc" -delete find "$APP_DIR" -name "__pycache__" -type d -delete # Calculate bundle size BUNDLE_SIZE=$(du -sh "$APP_DIR" | awk '{print $1}') info "" info "✅ Self-contained bundle created successfully!" info "" info "Bundle size: $BUNDLE_SIZE" info "Location: $APP_DIR" info "" info "The app is now fully self-contained with:" info " • Python 3.14 interpreter" info " • GTK4 and libadwaita frameworks" info " • All Python dependencies" info " • All required libraries" info "" info "To test:" info " open $APP_DIR" info "" # Create DMG if requested if [[ "$CREATE_DMG" == "true" ]]; then info "Creating DMG installer..." DMG_NAME="Meshy-${VERSION}.dmg" DMG_PATH="dist/$DMG_NAME" hdiutil create -volname "Meshy ${VERSION}" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" # Sign DMG if identity provided if [[ -n "$SIGN_IDENTITY" ]]; then info "Signing DMG..." codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_PATH" || warn "Failed to sign DMG" fi DMG_SIZE=$(du -sh "$DMG_PATH" | awk '{print $1}') info "" info "✅ DMG created: $DMG_PATH ($DMG_SIZE)" info "" info "Distribution naming convention:" info " Stable release: Meshy-26.06.dmg (from tag)" info " Unstable/Nightly: Meshy-26.06.19.dmg (date version)" info "" else info "To create DMG for distribution:" info " $0 --dmg" info " Or manually: hdiutil create -volname 'Meshy ${VERSION}' -srcfolder $APP_DIR -ov -format UDZO dist/Meshy-${VERSION}.dmg" info "" fi meshy/build-aux/macos/launcher.c000066400000000000000000000072221523412555500171560ustar00rootroot00000000000000// Meshy launcher - native executable that spawns Python // This ensures macOS shows "Meshy" instead of "python 3.14" in permission dialogs // Uses posix_spawn instead of exec to keep launcher process active #include #include #include #include #include #include #include #include extern char **environ; int main(int argc, char *argv[]) { // Get path to this executable char exec_path[1024]; uint32_t size = sizeof(exec_path); if (_NSGetExecutablePath(exec_path, &size) != 0) { fprintf(stderr, "Error: executable path too long\n"); return 1; } // Get directory containing this executable (Contents/MacOS) char exec_path_copy[1024]; strncpy(exec_path_copy, exec_path, sizeof(exec_path_copy)); char *macos_dir = dirname(exec_path_copy); // Build paths char contents_dir[1024], resources_dir[1024], python_path[1024], script_path[1024]; snprintf(contents_dir, sizeof(contents_dir), "%s/..", macos_dir); snprintf(resources_dir, sizeof(resources_dir), "%s/Resources", contents_dir); snprintf(python_path, sizeof(python_path), "/opt/homebrew/bin/python3.14"); snprintf(script_path, sizeof(script_path), "%s/lib/python/meshy/__main__.py", resources_dir); // Set environment variables char pythonhome[1024], pythonpath[2048]; snprintf(pythonhome, sizeof(pythonhome), "/opt/homebrew/opt/python@3.14/Frameworks/Python.framework/Versions/3.14"); snprintf(pythonpath, sizeof(pythonpath), "%s/lib/python-packages:%s/lib/python", resources_dir, resources_dir); setenv("PYTHONHOME", pythonhome, 1); setenv("PYTHONPATH", pythonpath, 1); setenv("PYTHONNOUSERSITE", "1", 1); // GTK environment char dyld_lib[1024], gi_typelib[1024], xdg_data[2048]; snprintf(dyld_lib, sizeof(dyld_lib), "/opt/homebrew/lib"); snprintf(gi_typelib, sizeof(gi_typelib), "/opt/homebrew/lib/girepository-1.0"); snprintf(xdg_data, sizeof(xdg_data), "/opt/homebrew/share:%s/share:/usr/local/share:/usr/share", resources_dir); setenv("DYLD_LIBRARY_PATH", dyld_lib, 1); setenv("GI_TYPELIB_PATH", gi_typelib, 1); setenv("XDG_DATA_DIRS", xdg_data, 1); setenv("GTK_DATA_PREFIX", "/opt/homebrew", 1); setenv("GTK_EXE_PREFIX", "/opt/homebrew", 1); setenv("GDK_BACKEND", "macos", 1); setenv("GDK_PIXBUF_MODULEDIR", "/opt/homebrew/lib/gdk-pixbuf-2.0/2.10.0/loaders", 1); setenv("GDK_PIXBUF_MODULE_FILE", "/opt/homebrew/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache", 1); setenv("PANGOCAIRO_BACKEND", "fc", 1); char meshy_pkgdatadir[1024]; snprintf(meshy_pkgdatadir, sizeof(meshy_pkgdatadir), "%s/share/meshy", resources_dir); setenv("MESHY_PKGDATADIR", meshy_pkgdatadir, 1); // Build argument array for Python char **python_argv = malloc(sizeof(char*) * (argc + 3)); python_argv[0] = python_path; python_argv[1] = "-S"; // Skip site-packages python_argv[2] = script_path; // Copy remaining arguments for (int i = 1; i < argc; i++) { python_argv[i + 2] = argv[i]; } python_argv[argc + 2] = NULL; // Spawn Python process (keeps launcher active so macOS shows "Meshy" in permissions) pid_t pid; int status; if (posix_spawn(&pid, python_path, NULL, NULL, python_argv, environ) != 0) { perror("Failed to spawn Python"); return 1; } // Wait for Python to exit if (waitpid(pid, &status, 0) == -1) { perror("Failed to wait for Python"); return 1; } // Return Python's exit code if (WIFEXITED(status)) { return WEXITSTATUS(status); } return 1; } meshy/build-aux/nix/000077500000000000000000000000001523412555500147025ustar00rootroot00000000000000meshy/data/000077500000000000000000000000001523412555500131235ustar00rootroot00000000000000meshy/data/gschemas.compiled000066400000000000000000000013451523412555500164360ustar00rootroot00000000000000GVariantX(XLX\_M\Hxpage.codeberg.sesivany.Meshy(  L{ vdj vs vL$$v8=8ٴ=vPUI.qUvhp}~ pvxf%! vfvvcustom-thememeshcore(s)window-width (i)window-heightX(i) window-maximized(b)tcp-companions(as)last-transportble(s).path/page/codeberg/sesivany/Meshy/scolor-schemesystem(s)last-device-address(s)last-device-name(s)meshy/data/gtk/000077500000000000000000000000001523412555500137105ustar00rootroot00000000000000meshy/data/gtk/help-overlay.ui000066400000000000000000000106011523412555500166540ustar00rootroot00000000000000 General Disconnect <Primary>d Quit <Primary>q Help F1 Keyboard Shortcuts <Primary>question Navigation Device <Primary>1 Contacts <Primary>2 Channels <Primary>3 Map <Primary>4 Settings <Primary>5 Messaging Search Contacts <Primary>f Focus Message Input <Primary>n Previous Contact/Channel <Alt>Up Next Contact/Channel <Alt>Down Previous Unread <Alt><Shift>Up Next Unread <Alt><Shift>Down meshy/data/icons/000077500000000000000000000000001523412555500142365ustar00rootroot00000000000000meshy/data/icons/scalable/000077500000000000000000000000001523412555500160045ustar00rootroot00000000000000meshy/data/icons/scalable/actions/000077500000000000000000000000001523412555500174445ustar00rootroot00000000000000meshy/data/icons/scalable/actions/action-unavailable-symbolic.svg000066400000000000000000000011401523412555500255360ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/avatar-default-symbolic.svg000066400000000000000000000006501523412555500247050ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-100-symbolic.svg000066400000000000000000000014561523412555500250670ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-20-symbolic.svg000066400000000000000000000014561523412555500250100ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-30-symbolic.svg000066400000000000000000000015401523412555500250030ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-50-symbolic.svg000066400000000000000000000014561523412555500250130ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-60-symbolic.svg000066400000000000000000000014621523412555500250110ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-70-symbolic.svg000066400000000000000000000014621523412555500250120ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-80-symbolic.svg000066400000000000000000000014621523412555500250130ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-level-low-symbolic.svg000066400000000000000000000020011523412555500253530ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/battery-symbolic.svg000066400000000000000000000015341523412555500234610ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/bluetooth-active-symbolic.svg000066400000000000000000000020721523412555500252630ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/bluetooth-disabled-symbolic.svg000066400000000000000000000021101523412555500255500ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/bluetooth-symbolic.svg000066400000000000000000000020721523412555500240120ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/broadcast-flood-symbolic.svg000066400000000000000000000026311523412555500250510ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/broadcast-symbolic.svg000066400000000000000000000027121523412555500237500ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/charge-symbolic.svg000066400000000000000000000013351523412555500232370ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/chat-bubbles-text-symbolic.svg000066400000000000000000000013521523412555500253220ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/check-plain-symbolic.svg000066400000000000000000000013361523412555500241650ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/circle-crossed-symbolic.svg000066400000000000000000000011401523412555500247010ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/computer-symbolic.svg000066400000000000000000000010371523412555500236430ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/document-open-symbolic.svg000066400000000000000000000031221523412555500245570ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/document-save-symbolic.svg000066400000000000000000000011661523412555500245620ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/drive-harddisk-symbolic.svg000066400000000000000000000012141523412555500247020ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/drive-removable-media-symbolic.svg000066400000000000000000000011251523412555500261430ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/edit-clear-all-symbolic.svg000066400000000000000000000023131523412555500245620ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/edit-clear-symbolic.svg000066400000000000000000000025701523412555500240210ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/edit-copy-symbolic.svg000066400000000000000000000013751523412555500237070ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/edit-delete-symbolic.svg000066400000000000000000000020021523412555500241630ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/edit-find-symbolic.svg000066400000000000000000000011161523412555500236460ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/edit-undo-symbolic.svg000066400000000000000000000012751523412555500237010ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/emblem-system-symbolic.svg000066400000000000000000000112001523412555500245610ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/face-smile-symbolic.svg000066400000000000000000000006531523412555500240150ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/filter-symbolic.svg000066400000000000000000000011731523412555500232730ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/find-location-symbolic.svg000066400000000000000000000024501523412555500245330ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/go-down-symbolic.svg000066400000000000000000000011171523412555500233560ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/go-next-symbolic.svg000066400000000000000000000011761523412555500233720ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/hashtag-symbolic.svg000066400000000000000000000005331523412555500234240ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/history-undo-symbolic.svg000066400000000000000000000026641523412555500244600ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/info-symbolic.svg000066400000000000000000000014441523412555500227420ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/key5-symbolic.svg000066400000000000000000000010231523412555500226550ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/list-add-symbolic.svg000066400000000000000000000003441523412555500235060ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/logs-symbolic.svg000066400000000000000000000024511523412555500227520ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/mail-send-symbolic.svg000066400000000000000000000017401523412555500236570ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/map-symbolic.svg000066400000000000000000000003651523412555500225650ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/network-offline-symbolic.svg000066400000000000000000000033561523412555500251240ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/network-transmit-receive-symbolic.svg000066400000000000000000000021201523412555500267470ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/network-wireless-symbolic.svg000066400000000000000000000031211523412555500253250ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/network-workgroup-symbolic.svg000066400000000000000000000046351523412555500255420ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/object-select-symbolic.svg000066400000000000000000000013471523412555500245340ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/open-menu-symbolic.svg000066400000000000000000000004721523412555500237120ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/padlock2-symbolic.svg000066400000000000000000000007031523412555500235030ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/phone-old-symbolic.svg000066400000000000000000000011651523412555500236740ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/podcast-symbolic.svg000066400000000000000000000037761523412555500234560ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/preferences-other-symbolic.svg000066400000000000000000000057631523412555500254370ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/preferences-system-symbolic.svg000066400000000000000000000037451523412555500256400ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/qr-code-scanner-symbolic.svg000066400000000000000000000017441523412555500247730ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/radiowaves-dots-symbolic.svg000066400000000000000000000023601523412555500251200ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/region-symbolic.svg000066400000000000000000000015301523412555500232660ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/repeater-symbolic.svg000066400000000000000000000051151523412555500236150ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/share-alt-symbolic.svg000066400000000000000000000020101523412555500236550ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/sidebar-show-right-symbolic.svg000066400000000000000000000012001523412555500254770ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/sidebar-show-symbolic.svg000066400000000000000000000011761523412555500244000ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/snapshots-alt-symbolic.svg000066400000000000000000000017551523412555500246140ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/starred-symbolic.svg000066400000000000000000000013171523412555500234520ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/strength-bars-1-symbolic.svg000066400000000000000000000013471523412555500247320ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/strength-bars-2-symbolic.svg000066400000000000000000000014251523412555500247300ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/strength-bars-3-symbolic.svg000066400000000000000000000014251523412555500247310ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/strength-bars-4-symbolic.svg000066400000000000000000000014261523412555500247330ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/strength-bars-5-symbolic.svg000066400000000000000000000013761523412555500247400ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/strength-bars-none-symbolic.svg000066400000000000000000000033141523412555500255250ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-lock-screen-symbolic.svg000066400000000000000000000007111523412555500255320ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-log-out-symbolic.svg000066400000000000000000000023341523412555500247160ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-monitor-symbolic.svg000066400000000000000000000024461523412555500250230ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-reboot-symbolic.svg000066400000000000000000000016621523412555500246250ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-run-symbolic.svg000066400000000000000000000126421523412555500241370ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-search-symbolic.svg000066400000000000000000000011411523412555500245700ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/system-users-symbolic.svg000066400000000000000000000020571523412555500244730ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/user-available-symbolic.svg000066400000000000000000000005111523412555500246750ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/user-home-symbolic.svg000066400000000000000000000016421523412555500237130ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/user-trash-symbolic.svg000066400000000000000000000017611523412555500241060ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/utilities-terminal-symbolic.svg000066400000000000000000000027541523412555500256400ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/view-more-symbolic.svg000066400000000000000000000007631523412555500237240ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/view-refresh-symbolic.svg000066400000000000000000000021361523412555500244140ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/view-sort-descending-symbolic.svg000066400000000000000000000020051523412555500260410ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/weather-clear-symbolic.svg000066400000000000000000000045201523412555500245300ustar00rootroot00000000000000 meshy/data/icons/scalable/apps/000077500000000000000000000000001523412555500167475ustar00rootroot00000000000000meshy/data/icons/scalable/apps/page.codeberg.sesivany.Meshy-symbolic.svg000066400000000000000000000013431523412555500267210ustar00rootroot00000000000000 meshy/data/icons/scalable/apps/page.codeberg.sesivany.Meshy.svg000066400000000000000000000051021523412555500250770ustar00rootroot00000000000000 meshy/data/linux/000077500000000000000000000000001523412555500142625ustar00rootroot00000000000000meshy/data/linux/60-meshy-serial.rules000066400000000000000000000012671523412555500201710ustar00rootroot00000000000000# Meshy - MeshCore companion USB serial access # Install to /etc/udev/rules.d/ or /usr/lib/udev/rules.d/ # Then reload: sudo udevadm control --reload-rules && sudo udevadm trigger # # Grants access to USB CDC-ACM serial devices (ttyACM*) and USB-to-serial # adapters (ttyUSB*) commonly used by MeshCore companions (ESP32, NRF52, # RP2040, Seeed Wio, Heltec, etc.) # # Uses TAG+="uaccess" so only the logged-in console user gets access, # rather than making devices world-writable. # CDC-ACM devices (most MeshCore companions) SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", TAG+="uaccess" # FTDI/CP210x/CH340 USB-to-serial adapters (ttyUSB*) SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", TAG+="uaccess" meshy/data/macos/000077500000000000000000000000001523412555500142255ustar00rootroot00000000000000meshy/data/macos/Info.plist.in000066400000000000000000000025161523412555500166060ustar00rootroot00000000000000 CFBundleIdentifier page.codeberg.sesivany.Meshy CFBundleName Meshy CFBundleDisplayName Meshy CFBundleVersion @VERSION@ CFBundleShortVersionString @VERSION@ CFBundleExecutable meshy CFBundleIconFile Meshy.png CFBundlePackageType APPL NSHighResolutionCapable LSMinimumSystemVersion 12.0 LSArchitecturePriority arm64 NSBluetoothAlwaysUsageDescription Meshy needs Bluetooth to communicate with MeshCore mesh networking devices over BLE. NSLocationWhenInUseUsageDescription Meshy can share your current location with your mesh network. NSCameraUsageDescription Meshy uses the camera to scan QR codes for quickly adding contacts to your mesh network. LSApplicationCategoryType public.app-category.social-networking meshy/data/meson.build000066400000000000000000000046161523412555500152740ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later desktop_file = i18n.merge_file( input: 'page.codeberg.sesivany.Meshy.desktop.in', output: 'page.codeberg.sesivany.Meshy.desktop', type: 'desktop', po_dir: '../po', install: true, install_dir: get_option('datadir') / 'applications', ) desktop_utils = find_program('desktop-file-validate', required: false) if desktop_utils.found() test('Validate desktop file', desktop_utils, args: [desktop_file]) endif appstream_file = i18n.merge_file( input: 'page.codeberg.sesivany.Meshy.metainfo.xml.in', output: 'page.codeberg.sesivany.Meshy.metainfo.xml', po_dir: '../po', install: true, install_dir: get_option('datadir') / 'metainfo', ) appstreamcli = find_program('appstreamcli', required: false) if appstreamcli.found() test('Validate appstream file', appstreamcli, args: ['validate', '--no-net', '--explain', appstream_file]) endif install_data( 'page.codeberg.sesivany.Meshy.gschema.xml', install_dir: get_option('datadir') / 'glib-2.0' / 'schemas', ) compile_schemas = find_program('glib-compile-schemas', required: false) if compile_schemas.found() test('Validate schema file', compile_schemas, args: ['--strict', '--dry-run', meson.current_source_dir()]) endif gresource_conf = configuration_data() if get_option('shortcuts_dialog') gresource_conf.set('HELP_OVERLAY_ENTRY', 'gtk/help-overlay.ui') else gresource_conf.set('HELP_OVERLAY_ENTRY', '') endif gresource_xml = configure_file( input: 'page.codeberg.sesivany.Meshy.gresource.xml.in', output: 'page.codeberg.sesivany.Meshy.gresource.xml', configuration: gresource_conf, ) gnome.compile_resources('meshy', gresource_xml, source_dir: meson.current_source_dir(), gresource_bundle: true, install: true, install_dir: pkgdatadir, ) # Linux-specific udev rules if host_machine.system() == 'linux' install_data( 'linux/60-meshy-serial.rules', install_dir: get_option('datadir') / 'meshy', ) endif install_data( 'icons' / 'scalable' / 'apps' / 'page.codeberg.sesivany.Meshy.svg', install_dir: get_option('datadir') / 'icons' / 'hicolor' / 'scalable' / 'apps', ) install_data( 'icons' / 'scalable' / 'apps' / 'page.codeberg.sesivany.Meshy-symbolic.svg', install_dir: get_option('datadir') / 'icons' / 'hicolor' / 'scalable' / 'apps', ) meshy/data/page.codeberg.sesivany.Meshy.desktop.in000066400000000000000000000005501523412555500225340ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later [Desktop Entry] Name=Meshy Comment=MeshCore mesh network client Exec=meshy %u Icon=page.codeberg.sesivany.Meshy Terminal=false Type=Application Categories=Network;Chat; Keywords=mesh;lora;radio;chat; MimeType=x-scheme-handler/meshcore; meshy/data/page.codeberg.sesivany.Meshy.gresource000066400000000000000000003667551523412555500225020ustar00rootroot00000000000000GVariant(M  !##$$$%%&(,-//111334446777889:;=??@@AADEEFHIKKK)_v y v Nq ?NLX<9H<vX@r@LA$v\zvL!."L!vh!F/F/v`/11 "1v2[9"[9vp9 ?Ԯ% ?v(?@L@vADaDvEW,H"WvWdYddvdHgS?HgvhgInF<Inv`nxr1xrvr u, uv u x xv(x|)|v|óvbv c4ucvηv>Tvmr^"mvs sv\5[v(-+-L488vXITIvhe"e vpM*LL[vRvR"Rvh> 5q>v>0A$0AL4A8AN8AvXAG#G%v(GK_2"K vKsY2sYvYa\fa\vx\$rf{$rv@rt1tvtv&}9vLvvһvvvK}#IK}vh}~VC~v~̂-jP̂!vY9ovY&7Yvp^O(/^v2N;2vPr]&rvԖԵԖLؖܖ60ܖvvКAJhAv`3,3vPWDTWvp|i4"| Lt?C LoS"v8Wgv8"\ /" L,02Au0vHCv#´)#v@PfPvh v8{@}v^%vqhߞ"v'vcomputer-symbolic.svg (uuay)battery-symbolic.svgV (uuay)actions/68$(IA=>D 4&E5B7)0HJ# 2;+<:,-F1G .3find-location-symbolic.svg" (uuay)Meshy/'"utilities-terminal-symbolic.svg (uuay)document-open-symbolic.svgL (uuay)channel-chat-widget.ui (uuay)filter-symbolic.svg{ (uuay)connection-view.uiK (uuay)channels-view.ui Create a Private Channelchannel.create-privateJoin a Private Channelchannel.join-privateJoin the Public Channelchannel.join-publicJoin a Hashtag Channelchannel.join-hashtag (uuay)face-smile-symbolic.svg (uuay)user-trash-symbolic.svg (uuay)emblem-system-symbolic.svgz (uuay)contacts-view.ui Allcontacts.filter-allFavouritescontacts.filter-favoritesUserscontacts.filter-usersRepeaterscontacts.filter-repeatersRoom Serverscontacts.filter-roomsSensorscontacts.filter-sensors
A–Zcontacts.sort-azHeard Recentlycontacts.sort-heardLatest Messagescontacts.sort-messages
Favorites Firstcontacts.favorites-first
(uuay)document-save-symbolic.svgp (uuay)network-offline-symbolic.svg (uuay)share-alt-symbolic.svg (uuay)sidebar-show-right-symbolic.svgc (uuay)charge-symbolic.svg (uuay)view-refresh-symbolic.svgX (uuay)bluetooth-symbolic.svg4 (uuay)preferences-system-symbolic.svg (uuay)weather-clear-symbolic.svg3 (uuay)open-menu-symbolic.svg (uuay)broadcast-symbolic.svg (uuay)edit-clear-all-symbolic.svg (uuay)settings-view.ui (uuay)edit-clear-symbolic.svgr (uuay)map-symbolic.svg (uuay)icons/?object-select-symbolic.svg (uuay)preferences-other-symbolic.svg (uuay)window.ui
Disconnectwin.disconnectKeyboard Shortcutswin.show-help-overlayAbout Meshyapp.aboutQuitapp.quit
(uuay)ui/ * K%!podcast-symbolic.svg (uuay)bluetooth-disabled-symbolic.svgB (uuay)repeater-view.uiI (uuay)go-next-symbolic.svgx (uuay)gtk/Lbroadcast-flood-symbolic.svg (uuay)network-transmit-receive-symbolic.svgJ (uuay)chat-view.ui (uuay)starred-symbolic.svg (uuay)system-run-symbolic.svg (uuay)system-search-symbolic.svg[ (uuay)view-more-symbolic.svg (uuay)page/Cnetwork-wireless-symbolic.svgK (uuay)user-available-symbolic.svgC (uuay)qr-code-scanner-symbolic.svg (uuay)view-sort-descending-symbolic.svg (uuay)go-down-symbolic.svgI (uuay)list-add-symbolic.svg (uuay)avatar-default-symbolic.svg (uuay)network-server-symbolic.svg (uuay)bluetooth-active-symbolic.svg4 (uuay)//system-reboot-symbolic.svg (uuay)sidebar-show-symbolic.svga (uuay)system-lock-screen-symbolic.svg (uuay)edit-copy-symbolic.svg (uuay)edit-delete-symbolic.svg (uuay)scalable/sesivany/drive-removable-media-symbolic.svgO (uuay)mail-send-symbolic.svg (uuay)codeberg/@edit-find-symbolic.svgH (uuay)hashtag-symbolic.svg[ (uuay)system-users-symbolic.svg (uuay)user-home-symbolic.svg (uuay)network-workgroup-symbolic.svg (uuay)drive-harddisk-symbolic.svg (uuay)padlock2-symbolic.svg (uuay)device-view.ui (uuay)help-overlay.ui- GeneralQuit<Primary>qKeyboard Shortcuts<Primary>questionNavigationDevice<Primary>1Contacts<Primary>2Channels<Primary>3Map<Primary>4Settings<Primary>5MessagingSearch Contacts<Primary>fFocus Message Input<Primary>nPrevious Contact/Channel<Alt>UpNext Contact/Channel<Alt>DownPrevious Unread<Alt><Shift>UpNext Unread<Alt><Shift>Down (uuay)meshy/data/page.codeberg.sesivany.Meshy.gresource.xml.in000066400000000000000000000410271523412555500236640ustar00rootroot00000000000000 icons/scalable/actions/action-unavailable-symbolic.svg icons/scalable/actions/avatar-default-symbolic.svg icons/scalable/actions/battery-level-100-symbolic.svg icons/scalable/actions/battery-level-80-symbolic.svg icons/scalable/actions/battery-level-70-symbolic.svg icons/scalable/actions/battery-level-60-symbolic.svg icons/scalable/actions/battery-level-50-symbolic.svg icons/scalable/actions/battery-level-30-symbolic.svg icons/scalable/actions/battery-level-20-symbolic.svg icons/scalable/actions/battery-level-low-symbolic.svg icons/scalable/actions/battery-symbolic.svg icons/scalable/actions/bluetooth-active-symbolic.svg icons/scalable/actions/bluetooth-disabled-symbolic.svg icons/scalable/actions/bluetooth-symbolic.svg icons/scalable/actions/broadcast-flood-symbolic.svg icons/scalable/actions/broadcast-symbolic.svg icons/scalable/actions/charge-symbolic.svg icons/scalable/actions/chat-bubbles-text-symbolic.svg icons/scalable/actions/check-plain-symbolic.svg icons/scalable/actions/circle-crossed-symbolic.svg icons/scalable/actions/computer-symbolic.svg icons/scalable/actions/document-open-symbolic.svg icons/scalable/actions/document-save-symbolic.svg icons/scalable/actions/drive-harddisk-symbolic.svg icons/scalable/actions/drive-removable-media-symbolic.svg icons/scalable/actions/edit-clear-all-symbolic.svg icons/scalable/actions/edit-clear-symbolic.svg icons/scalable/actions/edit-copy-symbolic.svg icons/scalable/actions/edit-delete-symbolic.svg icons/scalable/actions/edit-undo-symbolic.svg icons/scalable/actions/edit-find-symbolic.svg icons/scalable/actions/emblem-system-symbolic.svg icons/scalable/actions/face-smile-symbolic.svg icons/scalable/actions/filter-symbolic.svg icons/scalable/actions/find-location-symbolic.svg icons/scalable/actions/go-down-symbolic.svg icons/scalable/actions/history-undo-symbolic.svg icons/scalable/actions/hashtag-symbolic.svg icons/scalable/actions/info-symbolic.svg icons/scalable/actions/key5-symbolic.svg icons/scalable/actions/go-next-symbolic.svg icons/scalable/actions/list-add-symbolic.svg icons/scalable/actions/logs-symbolic.svg icons/scalable/actions/mail-send-symbolic.svg icons/scalable/actions/map-symbolic.svg icons/scalable/actions/network-offline-symbolic.svg icons/scalable/actions/network-transmit-receive-symbolic.svg icons/scalable/actions/network-wireless-symbolic.svg icons/scalable/actions/network-workgroup-symbolic.svg icons/scalable/actions/object-select-symbolic.svg icons/scalable/actions/open-menu-symbolic.svg icons/scalable/actions/padlock2-symbolic.svg icons/scalable/actions/phone-old-symbolic.svg icons/scalable/actions/podcast-symbolic.svg icons/scalable/actions/preferences-other-symbolic.svg icons/scalable/actions/preferences-system-symbolic.svg icons/scalable/actions/qr-code-scanner-symbolic.svg icons/scalable/actions/region-symbolic.svg icons/scalable/actions/repeater-symbolic.svg icons/scalable/actions/radiowaves-dots-symbolic.svg icons/scalable/actions/share-alt-symbolic.svg icons/scalable/actions/sidebar-show-right-symbolic.svg icons/scalable/actions/sidebar-show-symbolic.svg icons/scalable/actions/snapshots-alt-symbolic.svg icons/scalable/actions/starred-symbolic.svg icons/scalable/actions/strength-bars-1-symbolic.svg icons/scalable/actions/strength-bars-2-symbolic.svg icons/scalable/actions/strength-bars-3-symbolic.svg icons/scalable/actions/strength-bars-4-symbolic.svg icons/scalable/actions/strength-bars-5-symbolic.svg icons/scalable/actions/strength-bars-none-symbolic.svg icons/scalable/actions/system-lock-screen-symbolic.svg icons/scalable/actions/system-log-out-symbolic.svg icons/scalable/actions/system-reboot-symbolic.svg icons/scalable/actions/system-monitor-symbolic.svg icons/scalable/actions/system-run-symbolic.svg icons/scalable/actions/system-search-symbolic.svg icons/scalable/actions/system-users-symbolic.svg icons/scalable/actions/user-available-symbolic.svg icons/scalable/actions/user-home-symbolic.svg icons/scalable/actions/user-trash-symbolic.svg icons/scalable/actions/utilities-terminal-symbolic.svg icons/scalable/actions/view-more-symbolic.svg icons/scalable/actions/view-refresh-symbolic.svg icons/scalable/actions/view-sort-descending-symbolic.svg icons/scalable/actions/weather-clear-symbolic.svg @HELP_OVERLAY_ENTRY@ style.css ui/device-view.ui ui/connection-view.ui ui/settings-view.ui ui/repeater-view.ui ui/sensor-view.ui ui/window.ui ui/contacts-view.ui ui/chat-view.ui ui/chat-message-item.ui ui/channel-chat-widget.ui ui/channel-message-item.ui ui/channels-view.ui ui/device-actions.ui ui/discover-dialog.ui ui/share-contact-dialog.ui ui/stats-chart-dialog.ui ui/rx-log-dialog.ui ui/trace-path-dialog.ui ui/settings-sidebar.ui ui/theme-chooser-dialog.ui ui/contact-detail-dialog.ui ui/manual-backup-dialog.ui ui/auto-backup-dialog.ui ui/import-backup-dialog.ui ui/passkey-dialog-content.ui ui/tcp-change-address-content.ui ui/tcp-add-content.ui ui/add-contact-content.ui ui/qr-contact-content.ui ui/create-private-channel-content.ui ui/join-private-channel-content.ui ui/join-hashtag-channel-content.ui ui/region-add-content.ui ui/scan-dialog-content.ui ui/pair-dialog.ui ui/qr-code-dialog.ui ui/location-dialog.ui ui/discover-nearby-dialog.ui ui/room-login-dialog.ui ui/telemetry-dialog.ui ui/repeater-telemetry-dialog.ui ui/smart-paths-dialog.ui ui/message-paths-dialog.ui ui/channel-detail-dialog.ui ui/channel-participants-dialog.ui ui/discover-regions-dialog.ui ui/sensor-login-dialog.ui ui/custom-tiles-dialog.ui ui/qr-scanner-dialog.ui ui/map-picker-dialog.ui ui/map-view.ui ui/map-dialog.ui ui/map-sidebar-filters.ui ui/trace-picker-bottom-bar.ui ui/section-nav.ui meshy/data/page.codeberg.sesivany.Meshy.gschema.xml000066400000000000000000000101371523412555500226660ustar00rootroot00000000000000 800 Window width 600 Window height false Window maximized '' Last connected device address '' Last connected device name 'ble' Last transport type (ble, usb or tcp) [] Saved TCP/WiFi companions (name|host:port) [] Paired BLE device UUIDs (macOS) List of peripheral UUIDs that have been successfully paired 'system' Color scheme preference Options: system, light, dark, custom 'meshcore' Custom theme name 0 Default channel notification level 0=All, 1=Mentions, 2=None true Run in background Keep running after closing the window to receive messages true Autostart Automatically start when you log in 'heard' Contact list sort order Options: heard, messages, az true Show favorite contacts first 'messages' Channel list sort order Options: messages, az '' Custom map tile URL Raster tile URL with {z}/{x}/{y} placeholders, or vector style JSON URL. Empty = use default. '' Custom tile source type Either 'raster' or 'vector'. Empty = use default OFM. true Automatic backup Create daily automatic backups of device data, kept for 7 days 'classic' Default routing mode Options: classic, smart ['device_settings', 'contacts', 'channels', 'messages', 'regions', 'discovered_contacts', 'path_history', 'local_settings'] Auto-backup sections Which data sections to include in automatic backups meshy/data/page.codeberg.sesivany.Meshy.metainfo.xml.in000066400000000000000000000203201523412555500234610ustar00rootroot00000000000000 page.codeberg.sesivany.Meshy CC0-1.0 GPL-3.0-or-later Meshy MeshCore mesh network client

Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your companion device over Bluetooth to send and receive encrypted messages, manage contacts, configure channels, and monitor your mesh network.

Features:

  • Bluetooth LE and USB serial connectivity
  • End-to-end encrypted messaging
  • Public, private, and hashtag channels
  • Contact management with location tracking
  • Interactive map view with route tracing
  • Device and repeater management
  • QR code scanning for quick contact exchange
Jiri Eischmann page.codeberg.sesivany.Meshy.desktop https://codeberg.org/sesivany/meshy https://codeberg.org/sesivany/meshy/issues https://codeberg.org/sesivany/meshy https://meshy-app.org/screenshot1.png Channel chats https://meshy-app.org/screenshot2.png Repeater management https://meshy-app.org/screenshot3-dark.png Settings - Dark Style https://meshy-app.org/screenshot4.png Map view showing contact locations intense intense 360 pointing keyboard touch

New features:

  • Room server remote management — login, ACL, settings, CLI, regions, and reboot
  • Channel moderation — participant list with blocking and message deletion
  • Per-channel message retention settings (1 day to forever)
  • Clear all messages in a channel
  • Live graphs for noise floor, RSSI, and SNR in device statistics
  • Remove individual paths from smart routing history
  • Global default routing strategy (Classic/Smart) in Settings with per-contact override
  • Persistent contact sort mode and favorites-first preference
  • TCP companion address editing via right-click context menu
  • Wrong PIN error feedback with automatic retry
  • Clickable channel name in header bar opens channel details

Improvements:

  • Repeat mode compatibility — correct frequency, writable radio parameters, Device Info badge
  • Reworked message compose panel with split button for send options
  • Refined Device Info panel layout
  • Improved app-side trip time calculation for accurate smart routing scoring
  • Localized number and time formatting across the application
  • All UI definitions moved to XML
  • Performance optimizations — SQL query batching, O(1) contact sync, dict-based message lookups

Fixes:

  • BLE write serialization preventing InProgress errors during channel sync
  • Radio settings not reflecting actual device values after Apply
  • Custom preset overwritten by incoming device info updates
  • Ghost messages appearing from unconfigured channel slots
  • Crash on auto-connect when no BLE transport is available
  • Race condition when the connection drops immediately after connecting
  • Missed messages counter reset during contact/channel sync

Improvements:

  • New icon

New features:

  • Smart routing — automatic path selection based on success history, reliability, and latency scoring
  • One-time routing override via long-press/right-click on the Send button
  • Region discovery — scan for nearby repeaters and query them for regions
  • Custom map tile sources (Ctrl+M)
  • Map view for routing paths in contact details
  • Hop count displayed on received channel messages
  • Arrival Time sorting option for discovered contacts
  • New message indicators in the roster
  • Receive Errors percentage in device stats
  • Disconnect shortcut and app menu item
  • Experimental macOS support with CoreBluetooth BLE transport

Improvements:

  • Reworked message deduplication for contact chats — more reliable duplicate detection with fewer false positives
  • More reliable BLE connection handling
  • Support for non-Linux/macOS unix systems (FreeBSD etc.)
  • Redesigned routing section in contact details with Classic/Smart strategy toggle
  • Improved search bar UX — search field hidden when not active
  • MeshCore 1.16 protocol support (channel data, default scope, login fields, unscoped flood override)
  • Updated radio presets — added Australia (Mid/Wide), Australia QLD, Brazil, Netherlands; revised existing presets
  • Significant memory consumption optimizations — map tile cleanup, view lifecycle management
  • Code refactoring and performance optimizations across the application
  • New user documentation (Zola-based)
  • New translations: French, Spanish, Dutch

Fixes:

  • Messages getting stuck on 'Sending' status
  • Contacts with invalid GPS coordinates breaking map views
  • 'Order by last message' not updating after received messages
  • Custom radio settings overwritten by preset detection after save
  • Spurious 'Device error: illegal argument' toast on connect
  • Chat view shifting up when switching focus
  • Map node layering order making nodes invisible
  • ACK rewards incorrectly assigned to retry attempts
  • Missing BLE device import in Linux BLE transport
  • Broken device connection caused by missing CONTACT_TYPE_ICONS export
  • Unsupported max-width and max-height CSS properties
  • Various issues found by static analysis

First stable release of Meshy.

#98E9FF #003C47
meshy/data/style.css000066400000000000000000000055121523412555500150000ustar00rootroot00000000000000/* Copyright 2026, Jiri Eischmann and the meshy contributors */ /* SPDX-License-Identifier: GPL-3.0-or-later */ .outgoing-message { background-color: alpha(@accent_bg_color, 0.15); border-color: alpha(@accent_bg_color, 0.3); } searchbar > revealer > box { background: none; box-shadow: none; border: none; padding-top: 2px; padding-bottom: 2px; } .navigation-sidebar row { margin-left: 4px; margin-right: 4px; padding-left: 4px; padding-right: 4px; } .nav-unread-dot { background-color: @accent_bg_color; border-radius: 50%; min-width: 6px; min-height: 6px; padding: 0; font-size: 0; } .unread-badge { background-color: @accent_bg_color; color: @accent_fg_color; border-radius: 12px; min-width: 12px; font-size: 0.8em; font-weight: bold; padding: 2px 5px 1px; } .emoji-avatar { background-color: alpha(@accent_bg_color, 0.15); border-radius: 50%; min-width: 32px; min-height: 32px; font-size: 18px; } .unread-divider { color: @accent_bg_color; font-weight: bold; font-size: 0.8em; } .date-divider-line { min-height: 1px; margin-top: 0; margin-bottom: 0; } .unread-divider-line { min-height: 1px; margin-top: 0; margin-bottom: 0; background-color: @accent_bg_color; } .map-marker-icon { color: white; } .map-marker-badge { background-color: rgba(60, 60, 60, 0.85); color: white; font-size: 8px; font-weight: bold; min-width: 14px; min-height: 14px; border-radius: 7px; padding: 0; } .map-overlay-box { background: alpha(@window_bg_color, 0.85); border-radius: 6px; } .map-overlay-label { background: alpha(@window_bg_color, 0.85); border-radius: 6px; padding: 4px 12px; } .nav-rail { background-color: @sidebar_bg_color; border-right: 1px solid alpha(@borders, 0.5); padding-top: 4px; padding-left: 4px; padding-right: 3px; } listview.background > row { min-height: 0; padding: 0; border-radius: 0; background: none; outline: none; } .pill { border-radius: 16px; padding-left: 16px; padding-right: 16px; } .status-badge { border-radius: 12px; padding: 2px 10px; font-size: 0.85em; background-color: alpha(@accent_bg_color, 0.15); color: @accent_color; border: 1px solid alpha(@accent_bg_color, 0.3); } .theme-tile { border-radius: 12px; min-width: 100px; min-height: 50px; box-shadow: 0 0 0 1px alpha(@borders, 0.5); } button.flat:checked .theme-tile { box-shadow: 0 0 0 2px @accent_bg_color; } .region-chip { border-radius: 100px; padding-left: 12px; padding-right: 6px; padding-top: 2px; padding-bottom: 2px; min-height: 28px; } .region-chip image { opacity: 0.6; } .region-chip:hover image { opacity: 1; } meshy/data/ui/000077500000000000000000000000001523412555500135405ustar00rootroot00000000000000meshy/data/ui/add-contact-content.ui000066400000000000000000000022721523412555500177330ustar00rootroot00000000000000 vertical 8 none Contact Type Name Public Key (64 hex characters) meshy/data/ui/auto-backup-dialog.ui000066400000000000000000000273221523412555500175550ustar00rootroot00000000000000 Automatic Backup 360 true true vertical 12 8 8 8 8 none Enable Automatic Backups Daily backups after sync, kept for 7 days none Device Identity check_device_identity key5-symbolic 5 false center Settings false true center emblem-system-symbolic Device Settings check_device_settings true center Local Settings check_local_settings true center Regions check_regions true center Contacts false true center avatar-default-symbolic Contact List check_contacts true center Discovered Contacts check_discovered_contacts true center Channels check_channels system-users-symbolic 5 true center Messages and Routing false true center chat-bubbles-text-symbolic Messages check_messages true center Smart Route History check_path_history true center meshy/data/ui/channel-chat-widget.ui000066400000000000000000000150601523412555500177070ustar00rootroot00000000000000 meshy/data/ui/channel-detail-dialog.ui000066400000000000000000000030241523412555500202030ustar00rootroot00000000000000 420 480 never true 420 vertical 12 12 12 12 12 meshy/data/ui/channel-message-item.ui000066400000000000000000000331021523412555500200640ustar00rootroot00000000000000 600 vertical horizontal 8 12 none false initials 32 true start emoji start vertical 2 12 2 2 0 false none false false text true word-char 0 40 12 12 8 8 contact vertical 6 12 12 8 8 0 true 6 avatar-default-symbolic 0 0 Add Contact start 4 location vertical 6 12 12 8 8 0 true 0 true hidden Open in Maps App 4 4 false false 8 12 12 8 8 false true center true center meshy/data/ui/channel-participants-dialog.ui000066400000000000000000000064311523412555500214470ustar00rootroot00000000000000 Participants 420 480 system-search-symbolic Search filter-symbolic Filter view-sort-descending-symbolic Sort by vertical Search... 8 8 6 false never true false 4 4 meshy/data/ui/channels-view.ui000066400000000000000000000053421523412555500166460ustar00rootroot00000000000000 Create a Private Channel channel.create-private Join a Private Channel channel.join-private Join the Public Channel channel.join-public Join a Hashtag Channel channel.join-hashtag meshy/data/ui/chat-message-item.ui000066400000000000000000000322641523412555500174030ustar00rootroot00000000000000 600 vertical horizontal 8 12 none false initials 32 true start emoji start vertical 2 12 2 2 0 40 false none false false text true word-char 0 true 40 12 12 8 8 contact vertical 6 12 12 8 8 0 true 6 avatar-default-symbolic 0 0 Add Contact start 4 location vertical 6 12 12 8 8 0 true 0 true hidden Open in Maps App 4 4 false 8 12 12 8 8 false true center true center meshy/data/ui/chat-view.ui000066400000000000000000000150721523412555500157730ustar00rootroot00000000000000 meshy/data/ui/connection-view.ui000066400000000000000000000070241523412555500172110ustar00rootroot00000000000000 meshy/data/ui/contact-detail-dialog.ui000066400000000000000000000260771523412555500202430ustar00rootroot00000000000000 meshy/data/ui/contacts-view.ui000066400000000000000000000037771523412555500167030ustar00rootroot00000000000000 meshy/data/ui/create-private-channel-content.ui000066400000000000000000000015011523412555500220650ustar00rootroot00000000000000 vertical 8 none Channel Name meshy/data/ui/custom-tiles-dialog.ui000066400000000000000000000055441523412555500177740ustar00rootroot00000000000000 Custom Map Tiles 450 300 vertical 12 16 16 12 12 Type URL Template true 0 8 end 4 Reset to Default Apply meshy/data/ui/device-actions.ui000066400000000000000000000127501523412555500170010ustar00rootroot00000000000000 Trace Path Trace a route and show signal quality per hop trace_btn share-alt-symbolic center Discover Nearby Nodes Scan the network for nearby nodes discover_btn edit-find-symbolic center Rx Log View received radio packets rx_log_btn logs-symbolic center Reboot Device reboot_btn system-reboot-symbolic center meshy/data/ui/device-view.ui000066400000000000000000000403011523412555500163040ustar00rootroot00000000000000 meshy/data/ui/discover-dialog.ui000066400000000000000000000067501523412555500171620ustar00rootroot00000000000000 Discover Contacts 420 520 system-search-symbolic Search filter-symbolic Filter by type view-sort-descending-symbolic Sort by vertical Search... 8 8 6 false true true No contacts 4 4 meshy/data/ui/discover-nearby-dialog.ui000066400000000000000000000056701523412555500204400ustar00rootroot00000000000000 Discover Nearby Nodes 420 480 vertical 8 8 8 8 8 center true true vertical 8 8 8 8 none edit-find-symbolic Listening... Waiting for nearby nodes to respond meshy/data/ui/discover-regions-dialog.ui000066400000000000000000000070531523412555500206230ustar00rootroot00000000000000 Discover Regions 420 480 vertical 8 8 8 8 8 center true true vertical 8 8 8 8 none edit-find-symbolic Scanning... Listening for nearby repeaters Add Selected center false meshy/data/ui/import-backup-dialog.ui000066400000000000000000000274301523412555500201170ustar00rootroot00000000000000 Import Backup 360 true true vertical 12 8 8 8 8 start none Device Identity check_device_identity key5-symbolic true center Settings false true center emblem-system-symbolic Device Settings check_device_settings true center Local Settings check_local_settings true center Regions check_regions true center Contacts false true center avatar-default-symbolic Contact List check_contacts true center Discovered Contacts check_discovered_contacts true center Channels check_channels system-users-symbolic true center Messages and Routing false true center chat-bubbles-text-symbolic Messages check_messages true center Smart Route History check_path_history true center Restore center meshy/data/ui/join-hashtag-channel-content.ui000066400000000000000000000015171523412555500215350ustar00rootroot00000000000000 vertical 8 none Hashtag (e.g. #general) meshy/data/ui/join-private-channel-content.ui000066400000000000000000000020131523412555500215600ustar00rootroot00000000000000 vertical 8 none Channel Name Private Key (32 hex characters) meshy/data/ui/location-dialog.ui000066400000000000000000000025601523412555500171470ustar00rootroot00000000000000 Device Location 400 450 vertical 8 12 12 12 meshy/data/ui/manual-backup-dialog.ui000066400000000000000000000266601523412555500200660ustar00rootroot00000000000000 Manual Backup 360 true true vertical 12 8 8 8 8 none Device Identity check_device_identity key5-symbolic 5 false center Settings false true center emblem-system-symbolic Device Settings check_device_settings true center Local Settings check_local_settings true center Regions check_regions true center Contacts false true center avatar-default-symbolic Contact List check_contacts true center Discovered Contacts check_discovered_contacts true center Channels check_channels system-users-symbolic 5 true center Messages and Routing false true center chat-bubbles-text-symbolic Messages check_messages true center Smart Route History check_path_history true center Export center meshy/data/ui/map-dialog.ui000066400000000000000000000010471523412555500161130ustar00rootroot00000000000000 meshy/data/ui/map-picker-dialog.ui000066400000000000000000000016151523412555500173670ustar00rootroot00000000000000 center center meshy/data/ui/map-sidebar-filters.ui000066400000000000000000000061341523412555500177350ustar00rootroot00000000000000 vertical 8 12 12 8 Users true avatar-default-symbolic Repeaters true repeater-symbolic Rooms true user-home-symbolic Sensors true weather-clear-symbolic Discovered Nodes edit-find-symbolic 4 2 meshy/data/ui/map-view.ui000066400000000000000000000057561523412555500156410ustar00rootroot00000000000000 meshy/data/ui/message-paths-dialog.ui000066400000000000000000000025711523412555500201020ustar00rootroot00000000000000 Message Paths 420 480 never true vertical 8 12 12 12 12 meshy/data/ui/pair-dialog.ui000066400000000000000000000062221523412555500162710ustar00rootroot00000000000000 bluetooth-active-symbolic Scanning... Looking for nearby MeshCore devices Pair New Companion 420 480 vertical 8 8 8 8 8 center true Scanning for MeshCore devices... true vertical 8 8 8 8 none meshy/data/ui/passkey-dialog-content.ui000066400000000000000000000015661523412555500204730ustar00rootroot00000000000000 vertical 8 none PIN digits meshy/data/ui/qr-code-dialog.ui000066400000000000000000000037021523412555500166700ustar00rootroot00000000000000 Your Contact QR Code 380 440 vertical 12 24 24 12 24 contain 280 280 Scan this QR code to add this contact meshy/data/ui/qr-contact-content.ui000066400000000000000000000017611523412555500176270ustar00rootroot00000000000000 vertical 8 none Contact Type Name meshy/data/ui/qr-scanner-dialog.ui000066400000000000000000000033541523412555500174120ustar00rootroot00000000000000 Scan QR Code 400 400 vertical 8 8 8 8 360 270 true contain Point the camera at a QR code... meshy/data/ui/region-add-content.ui000066400000000000000000000024011523412555500175550ustar00rootroot00000000000000 vertical 12 300 none Region Name none Parent meshy/data/ui/repeater-telemetry-dialog.ui000066400000000000000000000023141523412555500211530ustar00rootroot00000000000000 420 true vertical 12 12 12 12 12 meshy/data/ui/repeater-view.ui000066400000000000000000001272461523412555500166720ustar00rootroot00000000000000 meshy/data/ui/room-login-dialog.ui000066400000000000000000000055431523412555500174250ustar00rootroot00000000000000 360 280 vertical 12 16 16 12 12 none Password Save password 8 center 4 Login center meshy/data/ui/rx-log-dialog.ui000066400000000000000000000042171523412555500165500ustar00rootroot00000000000000 Rx Log 480 560 edit-clear-all-symbolic Clear log true vertical 8 8 8 8 none logs-symbolic No Packets Received radio packets will appear here meshy/data/ui/scan-dialog-content.ui000066400000000000000000000023001523412555500177230ustar00rootroot00000000000000 400 200 vertical 8 8 8 8 single meshy/data/ui/section-nav.ui000066400000000000000000000014371523412555500163320ustar00rootroot00000000000000 vertical true single meshy/data/ui/sensor-login-dialog.ui000066400000000000000000000042211523412555500177520ustar00rootroot00000000000000 360 240 vertical 12 12 12 12 12 none Password Login center meshy/data/ui/sensor-view.ui000066400000000000000000000072711523412555500163670ustar00rootroot00000000000000 meshy/data/ui/settings-sidebar.ui000066400000000000000000000114201523412555500173440ustar00rootroot00000000000000 12 12 8 Backup document-save-symbolic Manual Choose what to back up and save to a file manual_backup_btn document-save-symbolic center Automatic Daily backups, kept for 7 days auto_backup_btn snapshots-alt-symbolic center Restore document-open-symbolic From File Restore from a manually saved backup restore_file_btn document-open-symbolic center From Auto Backup Restore from an automatic snapshot restore_auto_btn history-undo-symbolic center true 12 12 12 Factory Reset Erase all data on the companion factory_reset_btn edit-clear-all-symbolic center meshy/data/ui/settings-view.ui000066400000000000000000000636501523412555500167210ustar00rootroot00000000000000 meshy/data/ui/share-contact-dialog.ui000066400000000000000000000050711523412555500200720ustar00rootroot00000000000000 Share Contact 420 520 system-search-symbolic Search vertical Search... 8 8 6 false true true No contacts 4 4 meshy/data/ui/smart-paths-dialog.ui000066400000000000000000000027441523412555500176060ustar00rootroot00000000000000 420 360 true 600 vertical 12 16 16 12 12 meshy/data/ui/stats-chart-dialog.ui000066400000000000000000000042701523412555500175740ustar00rootroot00000000000000 -- 420 600 vertical 12 16 16 12 12 -- dBm 160 true dBm over last 2 minutes meshy/data/ui/tcp-add-content.ui000066400000000000000000000022471523412555500170700ustar00rootroot00000000000000 vertical 8 none Hostname / IP Address url Port digits 5000 meshy/data/ui/tcp-change-address-content.ui000066400000000000000000000021651523412555500212070ustar00rootroot00000000000000 vertical 8 none Hostname / IP Address url Port digits meshy/data/ui/telemetry-dialog.ui000066400000000000000000000023141523412555500173460ustar00rootroot00000000000000 420 true vertical 12 12 12 12 12 meshy/data/ui/theme-chooser-dialog.ui000066400000000000000000000037131523412555500201020ustar00rootroot00000000000000 meshy/data/ui/trace-path-dialog.ui000066400000000000000000000172541523412555500173750ustar00rootroot00000000000000 Trace Path 460 520 never true vertical 8 8 8 8 8 Path Path Pick on Map Choose route on the map true map-symbolic go-next-symbolic Run Trace center 8 center 4 4 false Enter path hashes and press Run Trace true center none false false false 8 12 8 8 8 0 true true map-symbolic center false Show on Map false false vertical 0 4 8 meshy/data/ui/trace-picker-bottom-bar.ui000066400000000000000000000036051523412555500205200ustar00rootroot00000000000000 8 8 8 6 6 0 true true word-char false edit-undo-symbolic Undo edit-clear-all-symbolic Clear true Done false meshy/data/ui/window.ui000066400000000000000000000461561523412555500154220ustar00rootroot00000000000000
Disconnect win.disconnect Settings win.navigate-settings Keyboard Shortcuts win.show-help-overlay Help win.help About Meshy win.about Quit win.quit
Keyboard Shortcuts win.show-help-overlay Help win.help About Meshy win.about Quit win.quit
meshy/docs/000077500000000000000000000000001523412555500131425ustar00rootroot00000000000000meshy/docs/.gitignore000066400000000000000000000000101523412555500151210ustar00rootroot00000000000000public/ meshy/docs/config.toml000066400000000000000000000010001523412555500152730ustar00rootroot00000000000000base_url = "https://meshy-app.org/docs" title = "Meshy Documentation" description = "Documentation for Meshy — MeshCore client for Linux" compile_sass = true build_search_index = false minify_html = true theme = "easydocs" [markdown] github_alerts = true [markdown.highlighting] theme = "github-light" [extra] logo = "logo.svg" favicon = "logo.svg" easydocs_logo_always_clickable = true easydocs_heading_threshold = 1 version = "v26.07.1" version_url = "https://codeberg.org/sesivany/meshy/src/tag/26.07.1" meshy/docs/content/000077500000000000000000000000001523412555500146145ustar00rootroot00000000000000meshy/docs/content/_index.md000066400000000000000000000001401523412555500163770ustar00rootroot00000000000000+++ title = "Meshy Documentation" sort_by = "weight" redirect_to = "guide/getting-started/" +++ meshy/docs/content/guide/000077500000000000000000000000001523412555500157115ustar00rootroot00000000000000meshy/docs/content/guide/_index.md000066400000000000000000000005001523412555500174740ustar00rootroot00000000000000+++ title = "User Guide" sort_by = "weight" +++ Meshy is a Linux desktop client for [MeshCore](https://meshcore.co.uk/) LoRa mesh networking devices. Connect to your companion device over Bluetooth, USB, or TCP to send and receive encrypted messages, manage contacts, configure channels, and monitor your mesh network. meshy/docs/content/guide/advanced.md000066400000000000000000000033631523412555500200050ustar00rootroot00000000000000+++ title = "Advanced Features" weight = 12 +++ ## Smaz Decompression Meshy can decompress incoming messages that use Smaz text compression: - Some MeshCore clients (e.g. meshcore-open) compress outgoing messages using the Smaz algorithm and send them with an `s:` prefix. - Meshy automatically detects and decompresses these messages. - Meshy does not compress outgoing messages — for the typically short MeshCore messages, the encoding overhead negates the compression savings. ## Trip Time Tracking Every delivered message includes round-trip time (RTT) measurement: - Timer starts when message is queued for transmission. - Timer stops when ACK is received. - Displayed in milliseconds next to the ✓✓ status. - Useful for diagnosing network latency and hop delays. ## SNR Visualization Signal-to-Noise Ratio (SNR) indicates link quality: - **Green** (≥0 dB) -- Excellent signal. - **Yellow** (-5 to 0 dB) -- Marginal signal. - **Red** (<-5 dB) -- Poor signal. SNR is shown in: - Path traces (color-coded labels on map). - Neighbor lists (repeater management). - Rx Log entries. ## Cayenne LPP Telemetry Meshy fully supports the Cayenne Low Power Payload format for sensor data: - Digital/analog I/O. - Temperature, humidity, pressure. - Accelerometer, gyrometer. - GPS coordinates. - Battery voltage and current. - Luminosity, presence detection. - And 20+ other sensor types. Telemetry is decoded automatically and displayed in human-readable format. ## Packet Deduplication Meshy maintains a rolling buffer of the last 32 received packet hashes: - Prevents duplicate processing when packets arrive via multiple paths. - Hash is computed from packet contents. - Duplicates are silently dropped. - Useful in dense mesh networks with many redundant paths. meshy/docs/content/guide/backup-restore.md000066400000000000000000000062521523412555500211660ustar00rootroot00000000000000+++ title = "Backup and Restore" weight = 10 +++ Meshy can back up your device configuration, contacts, channels, messages, and more to a human-readable JSON file. Backups are accessible from the **Settings** screen in the sidebar under **Backup & Restore**. ## What Can Be Backed Up The backup dialog lets you choose which sections to include: - **Device Identity** -- the device's private key (opt-in, unchecked by default for security). Requires fetching the key from the companion device at export time. - **Settings** -- device settings (radio parameters, location, advert policy), local settings (default flood scope), and regions. - **Contacts** -- your contact list and discovered contacts from adverts. - **Channels** -- channel names and pre-shared keys (PSKs). - **Messages and Routing** -- direct messages, channel messages, room/repeater passwords, per-channel notification levels, and smart route history. ## Manual Backup Click **Backup → Manual** in the sidebar. A dialog lets you select which sections to include. Click **Export** to save a JSON file. The filename includes the device name and a timestamp, e.g. `MyNode_meshcore_config_2026-07-03-143000.json`. ## Automatic Backup Click **Backup → Automatic** to configure daily backups. When enabled, Meshy creates a backup after each sync with the device. Auto-backups are stored locally and the last 7 days are kept -- older backups are pruned automatically. Device identity is never included in automatic backups. Auto-backup files are stored in: ``` ~/.var/app/page.codeberg.sesivany.Meshy/data/meshy/backups/ ``` For native builds: `~/.local/share/meshy/backups/`. ## Restore from File Click **Restore → From File** in the sidebar. After selecting a JSON backup file, a preview dialog shows the source device name and lets you choose which sections to restore. Sections not present in the backup file are greyed out. ## Restore from Auto Backup Click **Restore → From Auto Backup** to see a list of automatic backups for the currently connected device, sorted by date. Select one and click **Restore** to open the same section picker as manual restore. ## How Restore Works - **Contacts** are sent to the device via ADD_UPDATE_CONTACT commands. Contacts already present on the device (matched by public key) are skipped. - **Channels** are pushed via SET_CHANNEL commands into the first available slots. Channels with a matching PSK are skipped. - **Device settings** (radio parameters, location, advert policy, auto-add configuration) are applied as individual commands to the companion. - **Device identity** imports the private key to the companion device. This effectively replaces the device's identity. - **Messages** are imported into the local database. Duplicate messages (matched by contact key, timestamp, and text) are skipped. - **Regions, discovered contacts, route history, and local settings** are restored into the local database. After restore, Meshy re-syncs with the device to refresh the contact list, channels, and device info. ## Database Location The SQLite database is stored at: ``` ~/.var/app/page.codeberg.sesivany.Meshy/data/meshy/ ``` For Flatpak installations. For native builds, check `~/.local/share/meshy/`. meshy/docs/content/guide/channels.md000066400000000000000000000061471523412555500200360ustar00rootroot00000000000000+++ title = "Channels" weight = 6 +++ The **Channels** view provides group messaging. The number of available channel slots is reported by the device (typically 8). ## Channel Types | Type | Description | |------|-------------| | **Public** | Uses a fixed, well-known pre-shared key. All MeshCore users on the same frequency can read it. | | **Hashtag** | The key is derived from the channel name (e.g., `#hiking` produces a deterministic key). Anyone who knows the name can join. | | **Private** | Uses a randomly generated or user-provided 16-byte key. Only users with the key can read messages. | ## Adding Channels Click the **+** button in the channels sidebar: - **Create Private Channel** -- generates a random key and creates a new private channel. - **Join Private Channel** -- enter the channel name and 32-character hex PSK (shared by the channel creator). - **Join Public Channel** -- joins the standard MeshCore public channel. - **Join Hashtag Channel** -- enter a channel name starting with `#`; the key is derived automatically. Channels can also be added by scanning a QR code from the contacts view. ## Channel Chat Channel messages show the sender's name. Your own messages appear on the right; others' on the left. ## Message Paths and Repeats MeshCore messages can arrive through multiple paths (via different repeaters). Meshy tracks these: - **Heard repeats** -- if your message is echoed back through repeaters, the repeat count and paths are shown. - **Message paths** -- right-click a message and select "Show Message Paths" to see all path variants, including SNR and hop details for each. ## Channel Settings Hover over a channel row and click the three-dot button, long-press the row, or click the channel name in the header bar above the chat to open channel settings. ### Channel Info - **Channel Name** -- editable for public channels (rename is sent to the device). Shown as read-only for private and hashtag channels. - **Private Key** -- shown for private channels (copyable for sharing). ### Chat - **Participants** -- opens a list of all senders seen in the channel. Each entry shows the sender's name, message count, and when they were last seen. You can filter by All or Blocked, and sort by Name, Last Seen, or Messages. Blocking a sender hides their future messages; you can also choose to delete their existing messages when blocking. Blocked senders can be unblocked at any time. **Note:** Channel messages don't include sender identity verification. Blocking matches by name only -- the sender could change their name to bypass it. - **Message Retention** -- how long messages are kept: 1 Day, 1 Week, 1 Month, 3 Months, or Forever (default). Older messages are automatically deleted on app startup. - **Notification Level** -- choose between Default, All Messages, Mentions Only (@yourname), or None. ### Region - **Region** -- limit flood messages to a specific region (requires regions configured in [Settings](@/guide/settings.md#regions)). ### Actions - **Clear Messages** -- deletes all messages in the channel (requires confirmation). - **Remove Channel** -- clears the channel slot on the device. meshy/docs/content/guide/chat.md000066400000000000000000000070101523412555500171500ustar00rootroot00000000000000+++ title = "Chat" weight = 5 +++ Selecting a contact opens the chat view with the message history. ## Sending Messages Type your message in the input field and press Enter or click the send button. An emoji picker is available via the smiley button. A character counter shows bytes used vs. the maximum (156 bytes for direct messages). The send button is disabled if the message exceeds the limit. Note that the limit is in bytes, not characters -- emoji and non-ASCII characters use more bytes. ## Message Status Outgoing messages show a status indicator: | Symbol | Meaning | |--------|---------| | `Sending` | **Pending/Sent** -- the message is being sent or waiting for acknowledgment from the recipient | | `✓` | **Delivered** -- the recipient has acknowledged receipt | | `✗` | **Failed** -- all delivery attempts were unsuccessful | While the message is in the sending/pending state, the status also shows the routing method (`path`, `direct`, or `flood`) and, on retries, the attempt number (e.g., `(2/5) -- path`). If the radio channel is busy, you may see `waiting Xs (radio busy)` before the message is transmitted. When delivered, the status shows the round-trip time (e.g., `✓ 1.2s`). If delivery is confirmed after the message was already marked as failed (a late acknowledgment within 30 seconds), it shows the trip time with a late marker (e.g., `✓ 1.2s (late)`) or just `✓ late` if the trip time is unknown. ## Message Retry and Routing Meshy automatically retries failed deliveries using the routing strategy configured for each contact -- either **Classic** (single path with flood fallback) or **Smart** (automatic selection from a pool of known paths). See the [Routing](@/guide/routing.md) page for full details on how each strategy works and how to configure it. ## Duplicate Message Filtering In mesh networks, a message can arrive more than once -- for example, when your contact's device reports the message as undelivered (no acknowledgment received) and the contact manually resends it, even though the original was actually received on your end. Meshy automatically filters these duplicates in **direct chats** (not in rooms or channels). An incoming message is considered a duplicate and silently dropped when: 1. It has the **exact same timestamp and text** as a message already received from the same contact (catches retransmissions that bypass firmware-level deduplication), or 2. It has the **same text** as the **last incoming message** from that contact, that message is **less than 30 minutes old**, and **you have not sent any reply** since. The second rule is based on the assumption that if your contact sees a message as undelivered, they will not send a new message -- they will resend the failed one. If you have replied in the meantime, the conversation has moved forward and an identical message is treated as a new response. **Limitations:** In rare cases, a legitimate message may be filtered -- for example, if a contact intentionally sends the exact same text twice within 30 minutes and you have not replied between the two. This filtering only applies to direct contact chats; rooms and channels are not affected. ## Room Messages Messages in room chats show the sender's name above the message bubble. The sender is resolved from your contact list when possible; otherwise, the sender's key prefix is shown. ## Message Actions Right-click or long-press an outgoing message for: - **Send Again** -- re-send the message with a fresh retry cycle. - **Delete** -- remove the message from the chat and storage. meshy/docs/content/guide/contacts.md000066400000000000000000000133631523412555500200570ustar00rootroot00000000000000+++ title = "Contacts" weight = 3 +++ The **Contacts** view shows all nodes in your mesh network. The sidebar lists contacts; the content area shows the chat or management view for the selected contact. ## Contact Types Every contact in MeshCore has a **type** that determines how Meshy interacts with it. The type is set when a contact is added, either automatically from advertisements or manually. | Type | Icon | Description | |------|------|-------------| | **Chat** | 👤 | A regular user node for direct messaging. Chat contacts support one-to-one encrypted conversations, path routing, and delivery confirmations. This is the most common contact type. | | **Repeater** | 🔁 | A node configured to relay messages across the mesh. Repeaters extend the network range by forwarding traffic between nodes that can't reach each other directly. If you have the admin password, you can [manage the repeater](@/guide/repeater-management.md) remotely -- configure settings, manage firmware, view statistics, and reboot. | | **Room** | 💬 | A room server that hosts group conversations. Room servers store and forward messages for all participants. You log in with a password to join the room. Admins can manage an access control list (ACL) to set participant permissions (Admin, Read-Write, or Read Only). Messages in rooms show the sender's name so you can see who wrote what. | | **Sensor** | 📡 | A node with attached sensors (e.g., temperature, humidity, voltage). Sensor contacts display live telemetry data instead of a chat view. You can log in with a password to access detailed readings and device statistics. | The contact type affects what you see when you select the contact in the sidebar: Chat and Room contacts open a messaging view, Repeater contacts open the management interface, and Sensor contacts open the telemetry dashboard. ## Contact List Each contact shows its name, type icon, and routing status (Direct, Flood, Smart, or number of hops). Unread messages are indicated with a badge. **Filtering and sorting:** - **Search bar** -- filter contacts by name. - **Filter button** -- show All, Favorites, or filter by type (Users, Repeaters, Room Servers, Sensors). - **Sort button** -- sort by name (A-Z), heard recently, or latest messages. Optionally pin favorites to the top. ## Adding Contacts Click the **+** button in the sidebar header: - **Discover Contacts** -- shows nodes heard via advertisements that aren't in your contact list yet. Click the + button next to a node to add it. Reopen to see newly received advertisements. - **Add Manually** -- enter the contact's type, name, and 64-character hex public key. - **Import from Clipboard** -- paste a `meshcore://contact/add?...` URL (e.g., received from another user who exported their contact). Automatically detects and parses the contact information. - **Scan QR Code** -- scan a QR code containing a `meshcore://` URL for a contact or channel. Uses the XDG Camera Portal with PipeWire and GStreamer for video capture. Supports both contact (`meshcore://contact/add?...`) and channel (`meshcore://channel/add?...`) QR codes. Requires camera permissions in Flatpak settings. ## Contact Details To access the **contact details**, you can either click the three-dot button, that appears when you hover the mouse over a contact in the contact list, or long-press a contact in the contact list, or click the name of the contact above the chat. ### Contact Info Click the contact name in the chat header to open the detail dialog: - **Name** -- editable; changes are sent to the device when confirmed. - **Favorite** -- toggle to pin the contact in your list. - **Type** -- Chat, Repeater, Room, or Sensor. - **Public Key** -- full 64-character hex key with a copy button. - **Last Seen** -- when the contact last advertised. - **Location** -- if the contact has GPS coordinates, shown with a "Show on Map" button. ### Routing The **Routing** toggle lets you switch between **Classic** and **Smart** routing for this contact. The toggle shows the currently active strategy -- either the [global default](@/guide/settings.md#routing-and-messaging) or a per-contact override. Changing the toggle creates an explicit override for this contact that persists independently of the global setting. See the [Routing](@/guide/routing.md) page for a detailed description of both strategies. Depending on the active strategy, the following controls are shown: **Classic mode:** - **Out Path** -- the current path as comma-separated hex hop identifiers. Editable to manually set a path. - **Reset Path** -- clears the established path and switches to flood routing. - **Force Flood** -- always use flood routing without establishing a path. - **Show Path on Map** -- visualizes the route on a map with colored markers at each hop. **Smart mode:** - **Available Paths** -- opens a list of recently successful paths with their weights, success/failure counts, hop counts, and latency. Each path is color-coded. Individual paths can be removed with the delete button next to each entry. A map view and a Clear History button are available. **Both modes:** - **Automatically Reset Path** -- switch to flood routing after failed delivery attempts. ### Actions For **Repeaters** and **Room Servers**, additional actions are available: - **Room Management** -- opens the room management interface (requires login). Available for Room Servers. - **Repeater Management** -- opens the repeater management interface (requires login). Available for Repeaters. - **Ping** -- measures round-trip time and SNR to the node. - **Discover Paths** -- sends a flood-routed message to discover a path to the node. - **Regions** -- queries the repeater for the regions it has set up. ### Danger Zone - **Remove Contact** -- deletes the contact and all message history (requires confirmation). meshy/docs/content/guide/device.md000066400000000000000000000117201523412555500174730ustar00rootroot00000000000000+++ title = "Device" weight = 2 +++ The **Device** view is the default screen after connecting. It shows information about your companion device and provides actions for managing it. ## Device Information - **Node Name** -- the name your device advertises to other nodes. - **Board** -- hardware model (e.g., "Heltec V3"). - **Firmware** -- version and build date. - **Public Key** -- your device's 64-character hex public key (click the copy button to copy it). - **Telemetry** -- current telemetry data (battery, environment sensors). Click the refresh button to request an update from the device. - **Location** -- the device's current GPS/advertised position, or "Not set". If coordinates are available, a "Show on Map" button appears. ## Battery and Storage - **Battery** -- percentage and voltage (mV) with a visual progress bar > [!NOTE] > The state of charge does not decrease linearly as the voltage drops; therefore, Meshy uses a piecewise linear function with breakpoints at 100%/4.2V, 90%/4.0V, 10%/3.6V, 5%/3.5V, and 0%/3.0V. The MeshCore firmware uses a purely linear function, so the SoC values on the companion device may differ from those in Meshy. The values from Meshy should be more accurate, but even so, they should be considered only as a rough guide. - **Storage** -- percentage and used/total flash storage (KB) with a progress bar ## Radio Configuration Displays the current radio parameters: - **Frequency** (MHz), **Bandwidth** (kHz), **Spreading Factor**, **Coding Rate**, **TX Power** (dBm). - **Default Path Hash Size** -- how many bytes are used per hop in path routing (1, 2, or 3 bytes). These are read-only here. To change them, go to **Settings**. ## Statistics Detailed radio and packet statistics. Click **Refresh Statistics** at the bottom of the section to update: - **Uptime** -- time since last reboot. - **Message Queue** -- number of messages waiting to be sent. - **Noise Floor** -- indicates the level of interference. The lower the better, but absolute values depend on the device, antenna etc. Click the chart button to open a live monitoring dialog that plots noise floor readings over time (updated every 2 seconds, showing the last 2 minutes). - **Last RSSI** -- stands for Received Signal Strength Indicator. It is a measurement of the power level of a received radio signal. RSSI > -70 dBm indicates a good signal. RSSI < -80 dBm indicates a poor signal. Click the chart button to monitor RSSI over time. - **Last SNR** -- stands for Signal-to-Noise Ratio. It is a measure used in wireless communications and electronics to compare the level of a desired signal (e.g., data being transmitted) to the level of background noise (unwanted interference). Click the chart button to monitor SNR over time. - **TX/RX Airtime** -- cumulative time spent transmitting (TX) and receiving (RX). - **Packets** -- counts for received, transmitted, flood, direct, and errors since last reboot. ## Device Actions Available in the sidebar: - **Send Advert** -- expandable section for broadcasting your presence and sharing contact info: - *Zero Hop* -- local broadcast to nearby nodes only. - *Flood Routed* -- relayed through the entire mesh. - *To Clipboard* -- copies your contact as a `meshcore://contact/add?...` URL to the clipboard, which others can import. - *Show QR Code* -- displays a QR code containing your contact information for easy scanning by other users. - **Trace Path** -- test a route by sending a trace packet through a sequence of hops you specify. Opens a dialog where you can: - **Enter path hashes** manually as comma-separated hex identifiers (e.g., `aa,bb,cc` for 1-byte mode). An auto-complete popover suggests known repeaters and rooms (from both saved and discovered contacts) as you type, matching by name or hex hash. - **Pick on Map** -- open an interactive map and click on repeater markers to visually build the route (available when repeaters have known locations). - **Run Trace** -- sends the trace packet. A spinner and "Tracing..." status are shown while waiting for a response (30-second timeout). - **Results** -- on completion, shows a hop-by-hop breakdown with node names (or hex hashes for unknown nodes) and SNR values between each hop. SNR is color-coded: green for good signal (>= 0 dB), dim for moderate (>= -5 dB), and red for poor (< -5 dB). A summary line shows total hop count and round-trip time. Click **Show on Map** to visualize the traced route as a dashed polyline with numbered markers and SNR labels. - Trace Path supports 1-byte and 2-byte path hash modes. 3-byte mode is not supported. - **Discover Nearby Nodes** -- 30-second scan for nodes broadcasting zero-hop advertisements. Shows node name, type, and SNR. Unknown nodes can be added to your contacts directly from the results. - **Rx Log** -- view raw received packet log with hash deduplication. Shows packet hashes, SNR values, and timestamps. Useful for debugging and monitoring network activity. - **Reboot Device** -- restarts your companion device (requires confirmation). meshy/docs/content/guide/encryption.md000066400000000000000000000031451523412555500204300ustar00rootroot00000000000000+++ title = "Encryption and Security" weight = 11 +++ Meshy implements end-to-end encryption for all message types. ## Direct Messages Direct (peer-to-peer) messages use public key cryptography: - Each device has a unique Ed25519 key pair (32-byte public key, 32-byte private key). - Messages are encrypted using the recipient's public key. - Only the recipient's private key can decrypt the message. - Message integrity is verified through cryptographic signatures. ## Channel Messages Channel messages use symmetric encryption with pre-shared keys: - **Public channels:** Use a fixed, well-known PSK (everyone can read). - **Hashtag channels:** PSK derived via SHA-256 hash of the channel name (anyone who knows the name can derive the key). - **Private channels:** Use a random or user-provided 16-byte PSK (only shared members can read). Encryption scheme (encrypt-then-MAC): 1. **AES-128-ECB** encryption of the message content. 2. **HMAC-SHA256** MAC (truncated to 2 bytes) computed over the ciphertext for authentication. 3. MAC is verified on receipt before decryption. ## Transport Security - **BLE:** Nordic UART Service (NUS) with Bluetooth LE security (pairing, encryption at link layer). - **USB:** No transport encryption (physical connection assumed secure). - **TCP:** No transport encryption by default (use VPN/SSH tunnel for network security). ## Key Management - Private keys never leave the device. - Public keys are shared freely via advertisements and contact exports. - Channel PSKs are stored locally in the SQLite database. - Backup files contain PSKs in plaintext (protect backup files accordingly). meshy/docs/content/guide/getting-started.md000066400000000000000000000044031523412555500213410ustar00rootroot00000000000000+++ title = "Getting Started" weight = 1 +++ When you first launch Meshy, you'll see the connection screen. You need a MeshCore companion device (e.g., a Heltec V3, T-Beam, WisBlock, or similar LoRa board running MeshCore firmware) to use the app. ## Connecting via Bluetooth 1. Make sure Bluetooth is enabled on your computer. 2. Under **Paired Companions**, any previously paired MeshCore devices are listed. Click **Connect** to reconnect. 3. To pair a new device, click **Pair with New Companion**. Meshy will scan for nearby devices advertising the Nordic UART Service (NUS), which is how MeshCore companions identify themselves over Bluetooth. 4. Select your device from the scan results. If the device requires a PIN, a dialog will appear. Enter the PIN shown on your device's screen. If you enter a wrong PIN, an error message is shown and the PIN dialog reappears automatically. 5. Once paired, Meshy connects automatically and begins syncing contacts, channels, and messages. **Connection Retry:** If the connection fails, Meshy automatically retries up to 3 times with increasing delays between attempts. ## Connecting via USB 1. Connect your MeshCore device via USB cable. 2. Under **USB Serial**, detected serial devices (ttyACM/ttyUSB) are listed. Click **Connect**. 3. The connection uses 115200 baud by default. 4. If you see a permission error, Meshy will offer to install a udev rule. This requires administrator privileges and sets the correct permissions for the serial device. After installation, unplug and replug the device. ## Connecting via TCP For network-connected companion devices or remote access: 1. Switch to the **TCP** tab in the connection screen. 2. Enter the IP address or hostname (e.g., `192.168.40.10`). 3. Enter the port (default is `5000`). 4. Click **Connect**. TCP connections are saved in preferences and can be reconnected later. To change the address of a saved connection, right-click (or long-press) it and select **Change Address**. You can also remove saved connections from the same context menu. ## Auto-Reconnect Meshy remembers the last connected device and transport method. On subsequent launches, it attempts to reconnect automatically. If the device is unavailable or the connection fails after retries, the connection screen is shown. meshy/docs/content/guide/keyboard-shortcuts.md000066400000000000000000000013571523412555500220750ustar00rootroot00000000000000+++ title = "Keyboard Shortcuts" weight = 13 +++ ### General | Shortcut | Action | |----------|--------| | Ctrl+Q | Quit the application | | Ctrl+D | Disconnect from device | | Ctrl+? | Show keyboard shortcuts | | F1 | Help | ### Navigation | Shortcut | Action | |----------|--------| | Ctrl+1 | Device view | | Ctrl+2 | Contacts | | Ctrl+3 | Channels | | Ctrl+4 | Map | | Ctrl+5 | Settings | | Alt+Up | Previous contact/channel | | Alt+Down | Next contact/channel | | Alt+Shift+Up | Previous unread | | Alt+Shift+Down | Next unread | ### Chat | Shortcut | Action | |----------|--------| | Enter | Send message | | Ctrl+N | New message | | Ctrl+F | Search | ### Map | Shortcut | Action | |----------|--------| | Ctrl+M | Custom tile sources | meshy/docs/content/guide/map.md000066400000000000000000000026011523412555500170070ustar00rootroot00000000000000+++ title = "Map" weight = 7 +++ The **Map** view displays all contacts with known GPS coordinates on an OpenStreetMap-based map. ## Contact Markers Contacts are shown as colored circle markers with type-specific icons: | Color | Type | |-------|------| | Blue | Chat users | | Green | Repeaters | | Purple | Room servers | | Orange | Sensors | | Red | Your device | Click a marker to open the contact's detail dialog. Hover to see the contact's name and coordinates. ## Filtering Use the sidebar toggles to show or hide specific contact types (Users, Repeaters, Rooms, Sensors). A **Discovered Nodes** toggle controls whether nodes from the Discover Nearby Nodes scan are shown. The status label below shows how many contacts are visible on the map vs. total contacts. ## Clusters When multiple contacts are close together at the current zoom level, they are grouped into numbered cluster markers. Click a cluster to zoom in or expand it into individual markers. ## Path Visualization Path overlays can be shown on the map from: - **Device view > Trace Path** -- after tracing a route, the path is drawn with SNR labels at each segment midpoint - **Contact details > Show Path on Map** -- visualizes the established path to a contact Path markers use the same colored circles as regular markers. SNR values are color-coded: green (good, ≥0 dB), dim (marginal, -5 to 0 dB), red (poor, <-5 dB). meshy/docs/content/guide/repeater-management.md000066400000000000000000000117451523412555500221640ustar00rootroot00000000000000+++ title = "Repeater and Room Management" weight = 9 +++ Repeaters and room servers can be managed remotely through a shared management interface. Both device types use the same set of tabs, but some features are only available for one or the other. | Tab / Feature | Repeater | Room Server | Requires Admin | |---------------|:--------:|:-----------:|:--------------:| | Status | ✔ | ✔ | — | | CLI | ✔ | ✔ | ✔ | | Neighbors | ✔ | — | ✔ | | Access Control (ACL) | ✔ | ✔ | ✔ | | Regions | ✔ | ✔ | ✔ | | Settings | ✔ | ✔ | ✔ | | Guest Access | ✔ | ✔ | ✔ (to configure) | ## Logging In 1. For **repeaters**, click the contact in the contact list (opens the management interface directly). For **room servers**, open the contact detail dialog and click **Room Management** (clicking in the list opens the chat instead). 2. Enter the device password. Optionally check "Save password" to remember it for future sessions. 3. Meshy sends a login command and waits up to 15 seconds for a response. The login response indicates whether you have **admin** or **non-admin** access. Non-admin users only see the Status tab. Admin users see all tabs (CLI, Neighbors, Access Control, Regions, Settings). ## Status Shows real-time operating data from the remote node: - **Clock** -- the device's current time (admin only). If the time is off by more than 5 minutes, a drift warning appears with a **Sync** button (only when the clock is behind). If the clock is ahead, a **Reset** button appears instead (firmware rejects setting time backwards). - **System** -- battery (% and mV), uptime, message queue length, error events. - **Radio** -- last RSSI, last SNR, noise floor, TX/RX airtime, channel utilization, duty cycle. - **Packets** -- sent/received counts, receive errors, flood/direct breakdown, duplicate counts. Click **Refresh** to request updated status (15-second timeout). ## CLI Commands An interactive command-line interface for the remote node (admin only): - **Quick command buttons** -- `get name`, `get radio`, `get tx`, `ver`, `neighbors`, `advert`, `clock`. - **Command input** -- type any CLI command and press Enter or click Send. - **Output area** -- displays command responses in a monospace text area. Commands are sent as CLI data frames to the remote node. See the [MeshCore CLI reference](https://docs.meshcore.io/cli_commands/) for a complete list of commands available on repeaters, room servers, and sensors. ## Neighbors Lists other nodes heard via zero-hop advertisements (admin only, **repeaters only**). Room server firmware does not support neighbor queries, so this tab is hidden for room server contacts. - Each entry shows the node name (or key prefix), public key, time since last heard, and SNR. - **Refresh** reloads the neighbor list. **Load More** fetches additional entries. - **Map** shows neighbors with known locations on a map, with SNR labels on each link. ## Access Control (ACL) Manage the device's access control list (admin only): - Each entry shows the node name (or key prefix), permission role, and a remove button. - **Permission roles**: Admin (full access), Read-Write (can send and receive), Read Only (can only read messages). - **Add** opens a contact picker where you select a contact and a role, then sends a `setperm` command. - **Remove** deletes the entry from the ACL (with confirmation). - **Refresh** reloads the ACL from the device. The ACL works the same way for both repeaters and room servers. Note that room server firmware only reports Admin entries in the ACL response -- if you add a contact with Read-Write or Read Only, the permission is applied on the device, but the entry will not appear in the list until it is promoted to Admin. ## Regions Query and configure available regions on the device (admin only): - Regions and the default scope are fetched via CLI commands (`region`, `region default`). - Shows region names with indentation representing the hierarchy. Flood-enabled and home regions are marked. - **Add** / **Remove** to modify the region list. - **Default Scope** sets which region scopes all flood packets by default. - Click **Apply** to send region changes to the device. ## Settings Fetch and modify remote device configuration, organized into sections (admin only): - **Basic** -- device name, repeat mode. Admin and guest passwords can be changed separately. For room servers, a **Guest Access** toggle controls whether users without a password can join with read-only access. - **Location** -- latitude and longitude. A **Pick from Map** button opens a map dialog for visual selection. - **Radio** -- frequency, bandwidth, spreading factor, coding rate, TX power. - **Advert** -- advertisement interval, flood advertisement interval. - **Telemetry** -- request current telemetry data from the device. - **Danger Zone** -- reboot the remote node (requires confirmation). Each section has its own **Fetch** and **Save** buttons. Settings are fetched and saved via CLI commands (e.g., `get name`, `set name`, `get radio`, `set radio`). meshy/docs/content/guide/routing.md000066400000000000000000000124221523412555500177230ustar00rootroot00000000000000+++ title = "Routing" weight = 4 +++ Meshy supports two routing strategies for delivering direct messages: **Classic** and **Smart**. You can set a global default in [Settings > Routing and Messaging](@/guide/settings.md#routing-and-messaging) and optionally override it for individual contacts in the [contact detail dialog](@/guide/contacts.md#routing). ## Classic Routing Classic routing is very similar to the behavior of the official client. If there is no established path between you and the contact, the message is flood routed with up to 3 attempts. If an established path exists, it is used to route the message with up to 4 attempts. The 5th attempt is flood routed unless **Automatically Reset Path** is disabled. If no attempt is successful, it switches to flood routing and tries to find a new path. If an established path exists, it is pre-filled in the **Out Path** field in the form of comma-separated hex hop identifiers (e.g., `9a,74` for 1-byte hashes, `9a3b,744c` for 2-byte). The hash size is auto-detected from the hop length. Classic routing allows you to: - **manually set a path** by typing it in the **Out Path** field. - **reset the path** when you know the established path is outdated and you would like to directly switch to flood routing to discover a new one. - disable **Automatically Reset Path** when you don't want to automatically switch to flood routing after failed delivery attempts. Useful in situations when the established path is not stable, but you still trust it to deliver the message and want to avoid flood routing. - **force flood** when you only want to use flood routing without establishing a path. Please note that the flood routing is very expensive for the mesh network and should be avoided unless necessary. - **show Path on Map**, which visualizes the route on a map with colored markers at each hop. ## Smart Routing Smart routing tries to solve the main problem with classic routing, which is that it uses only one path for delivery attempts. Instead, it uses a list of recently successful paths and intelligently selects the best one for each attempt. If there are no available paths on the list, it uses flood routing with up to 3 attempts, like the classic routing. If delivery is successful, the path used is added to the list with an initial weight of 2.5. Each additional successful delivery increases the path's weight by 0.5, while an unsuccessful delivery decreases it by 0.2. If a path is discovered by the firmware (e.g. via a message received from the contact), it is added to the list with an initial weight of 1.0. The weight is capped at 5.0. If a path's weight drops to 0, it is removed from the list. The weight serves as a health indicator -- it keeps useful paths in the pool and prunes dead ones, but it does not determine the order in which paths are tried. If there are multiple paths on the list, smart routing selects the best path for each delivery attempt (up to 4 attempts total). The selection is based on a composite **score** that combines three factors: | Factor | Weight | Formula | |--------|--------|---------| | Reliability | 60% | *(successes + 1) / (total_attempts + 2)* -- Laplace-smoothed success rate | | Latency | 20% | *1 / (1 + last_trip_ms / 10 000)* -- lower latency -> higher score (0.5 if unknown) | | Recency | 20% | *1 / (1 + age_days)* -- days since last successful delivery (0 if never) | **score = reliability x 0.6 + latency x 0.2 + recency x 0.2** Each attempt uses a different path from the available pool, selected by highest score. If there are fewer paths than attempts, the best paths can be reused. The 5th attempt is always flood routed unless **Automatically Reset Path** is disabled. If there are no paths left in the list, it automatically switches to flood routing and tries to find a new path. Clicking **Available Paths** in the contact detail gives you a list of recently successful paths, showing their weights, the number of successful and unsuccessful attempts, the number of hops and the time taken for the last delivery. Each path is color-coded with a dot for easy identification. Clicking the **map icon** in the header bar opens a map dialog showing all available paths simultaneously, each drawn as a colored line matching its dot in the list. This allows you to visually compare routes and see which repeaters they pass through. If the list is outdated and no longer useful for routing, click the **Clear History** button. This will forget all paths and switch routing to flood to find new paths. ## Choosing Between Classic and Smart | Classic | Smart | |-------|-------| | Direct manual control over paths | Automatic path selection based on history | | Better for mobile nodes | Better for stationary nodes | | Useful for troubleshooting | Learns optimal routes over time | | Single path at a time | Multiple paths with quality tracking | ## Global Default and Per-Contact Override The **Default Routing** toggle in [Settings > Routing and Messaging](@/guide/settings.md#routing-and-messaging) sets the routing strategy used for all contacts. When you change a contact's routing mode in the [contact detail dialog](@/guide/contacts.md#routing), it becomes a per-contact override that persists independently of the global setting. Changing the global default will not affect contacts that have been explicitly overridden. meshy/docs/content/guide/settings.md000066400000000000000000000123531523412555500200770ustar00rootroot00000000000000+++ title = "Settings" weight = 8 +++ The **Settings** view lets you configure your companion device and application preferences. All device changes require clicking **Apply** in the header bar to be sent to the device. Navigating away without applying discards changes. ## Application Application-level preferences (applied immediately): - **Style** -- Follow System, Light, Dark, or Palette Theme. When Palette Theme is selected, a **Palette Theme** row appears to pick a custom theme. - **Channel Notifications** -- Default notification level for all channels (All Messages / Mentions Only / None). - **Run in Background** -- Keep receiving messages after closing the window (uses XDG Background Portal). - **Autostart** -- Launch Meshy automatically on login. ## Radio LoRa radio parameters and regional presets: - **Regional Preset** -- dropdown with 23 regional configurations (Australia, EU/UK, USA/Canada, Czech Republic, etc.) plus 3 Off-Grid presets. Selecting a preset fills in the frequency, bandwidth, spreading factor, and coding rate automatically. - **Preset Configuration** -- expandable section showing the radio parameters set by the preset: - **Frequency** (MHz) - **Bandwidth** (kHz) - **Spreading Factor** (SF 5-12) - **Coding Rate** (4/5 to 4/8) Editing these manually resets the preset to "Custom". - **Repeat Mode** -- act as a portable repeater on an off-grid frequency (shown only when supported by firmware). When enabled, the frequency switches to the off-grid value while other radio parameters (bandwidth, spreading factor, coding rate) remain from the current preset and can be edited. The [Device Info](@/guide/device.md) screen shows a "Repeat Mode" badge next to the frequency as an indicator. - **TX Power** (dBm) -- transmission power (2-30 dBm), capped by device hardware maximum. **Important:** Changing radio parameters means your device will no longer communicate with nodes on different settings. All nodes in your mesh must use the same frequency, bandwidth, spreading factor, and coding rate. ## Advert - **Device Name** -- the name your node broadcasts in advertisements and uses as your nickname in channel chats (max 31 bytes). Longer names reduce the character limit for channel messages. Meshy and most other clients use the first emoji in the name as an avatar. - **Include Location in Advert** -- toggle whether your position is included in advertisements sent to other nodes. ## Routing and Messaging Path routing and message delivery settings: - **Default Routing** -- sets the global routing strategy for all contacts: Classic or Smart. Individual contacts can override this in the [contact detail dialog](@/guide/contacts.md#routing). See the [Routing](@/guide/routing.md) page for a detailed description of both strategies. - **Default Path Hash Size** -- 1-byte (max 64 hops), 2-byte (max 32 hops), or 3-byte (max 21 hops). Larger sizes reduce routing collisions in dense networks. Availability depends on firmware version. - **Direct Message Confirmations** -- number of ACKs sent when receiving a direct message (1 or 2). Higher values improve delivery reliability at the cost of additional airtime. ## Location Set your companion's position via GPS or manually: - **Companion GPS** -- enable GPS on the companion device; your position will update automatically (shown when the device supports GPS). - **Set Location Manually** -- expandable section with methods to set a fixed position: - **Latitude / Longitude** -- manual coordinate entry (decimal degrees). - **Use My Location** -- fills coordinates from your computer's location service (requires location permissions). - **Pick on Map** -- opens an interactive map with a crosshair; pan and zoom to your location, then confirm. ## Auto-Add Contacts Configure which types of nodes are automatically added to your contact list when their advertisements are heard: - **Auto-Add Contacts** -- master toggle. - **Auto-Add Settings** -- expandable section with per-type toggles: - **Chat Nodes** -- regular user devices. - **Repeaters** -- relay nodes. - **Room Servers** -- chat room hosts. - **Sensors** -- environmental sensors. - **Overwrite oldest when full** -- if the contact list is full, replace the oldest entry. - **Hop limit** -- maximum number of hops for auto-added nodes (0-63, empty for no limit). ## Telemetry Privacy Control what telemetry data others can query from your device. Each category has a visibility level: - **Battery & Sensors** -- battery, voltage, temperature, current. - **Location** -- GPS coordinates. - **Environment** -- humidity, pressure, air quality. ## Regions Limit flood scope of channel messages to specific regions: - **Add Region** -- enter a region name (e.g. "Prague") to add it to the device. - **Default Scope** -- if set, all flood packets will be scoped to this region. ## Bluetooth Bluetooth pairing settings (shown when connected via Bluetooth): - **Pairing PIN** -- set a custom pairing PIN. After changing the PIN, restart the device, then unpair and re-pair. Setting 0 resets the PIN to default. ## Applying Changes Click the **Apply** button in the header bar to send all modified settings to the device. The button only becomes active when changes are detected. If you navigate away without applying, all changes are discarded. meshy/docs/content/guide/troubleshooting.md000066400000000000000000000101441523412555500214620ustar00rootroot00000000000000+++ title = "Troubleshooting" weight = 14 +++ ## Device won't connect via Bluetooth - Ensure Bluetooth is enabled on your computer: `bluetoothctl power on` - Check that your MeshCore device is powered on and in companion mode - Meshy scans for devices advertising the Nordic UART Service (NUS) UUID - Try removing the pairing and re-pairing: use your system Bluetooth settings to forget the device, then pair again through Meshy - Check BlueZ service status: `systemctl status bluetooth` - Meshy retries connection 3 times automatically; if all fail, try rebooting the device ## Device won't connect via USB - Check that the USB cable supports data transfer (not charge-only cables) - Verify the device appears in `/dev/` as `ttyUSB0`, `ttyACM0`, or similar - If you see a permission error, click the button to install the udev rule, then unplug and replug the device - For Flatpak: ensure the device permission is granted in Flatseal or via `flatpak override --user --device=all page.codeberg.sesivany.Meshy` - Check dmesg for USB connection errors: `dmesg | grep tty` ## TCP connection fails - Verify the companion device is reachable: `ping ` - Check the port is open: `telnet 5000` or `nc -zv 5000` - Ensure firewall isn't blocking the connection - Verify the companion device is configured for TCP companion mode - Check companion device logs for connection attempts ## QR Scanner doesn't work - Ensure you have a working webcam: `ls /dev/video*` - Grant camera permissions in Flatpak settings (Flatseal or `flatpak override`) - Check XDG Camera Portal is running: `systemctl --user status xdg-desktop-portal` - Verify PipeWire/GStreamer camera access works in other apps - For Flatpak, check portal permissions: `flatpak permission-show page.codeberg.sesivany.Meshy` ## Messages stuck at "Sending" (Pending) - Check connection status (green indicator in header bar) - Device battery may be low or turned off - Radio channel may be congested (high traffic) - Try enabling Force Flood in contact detail → Routing section - Check device queue isn't full (Device view → Statistics → Message Queue) - If disconnected during send, messages are marked failed on reconnect; resend via right-click → Send Again ## Messages failing repeatedly - Verify the contact is reachable (check Last Seen timestamp) - Try resetting the path: Contact details → Reset Path - Switch to flood routing temporarily to re-establish path - Check radio parameters match between all devices - Verify contact's public key is correct (64 hex characters) - Check for interference or obstacles blocking signal path ## Contact path shows wrong hops - With 1-byte path hashes, multiple contacts may share the same prefix (hash collision) - Meshy resolves collisions using geographic proximity, starting from your device's advertised position and chaining through each hop - If the wrong node is picked, try using 2-byte path hashes in Settings → Routing and Messaging → Default Path Hash Size - 3-byte hashes provide the most accurate resolution with max 21 hops per path ## Channels not working - Verify all participants use the exact same PSK (32 hex characters for private channels) - For hashtag channels, ensure the name is identical including the # prefix - Check channel slots aren't all occupied (the number of available slots depends on the device) - Verify radio parameters match across all mesh participants - Try sending a flood-routed advert via Device Actions → Send Advert → Flood Routed ## Map not showing contacts - Contacts need GPS coordinates to appear on map (check Location in contact details) - Verify contacts have enabled "Include Location in Advert" - Check map filter toggles in the sidebar aren't hiding contact types - GPS coordinates of 0.0, 0.0 are filtered out (invalid) ## High battery drain on device - Reduce TX power in Settings → Radio → TX Power - Increase spreading factor for longer range with less frequent transmissions - Disable GPS on the device if not needed (Settings → Location → Companion GPS) - Reduce flood advertisement frequency on repeaters - Check for routing loops (frequent path resets) meshy/docs/static/000077500000000000000000000000001523412555500144315ustar00rootroot00000000000000meshy/docs/static/logo.svg000066400000000000000000000051021523412555500161100ustar00rootroot00000000000000 meshy/docs/themes/000077500000000000000000000000001523412555500144275ustar00rootroot00000000000000meshy/docs/themes/easydocs/000077500000000000000000000000001523412555500162415ustar00rootroot00000000000000meshy/docs/themes/easydocs/.gitignore000066400000000000000000000000321523412555500202240ustar00rootroot00000000000000/public README.md LICENSE meshy/docs/themes/easydocs/config.toml000066400000000000000000000020401523412555500203770ustar00rootroot00000000000000base_url = "https://zola-easydocs-theme.pages.dev" title = "Docs" compile_sass = true build_search_index = true [markdown] [markdown.highlighting] theme = "github-light" [extra] logo = "https://zola-easydocs-theme.pages.dev/logo.svg" release = "https://api.github.com/repos/getzola/zola/releases/latest" favicon = "https://www.getzola.org/favicon.ico" easydocs_logo_always_clickable = false # For use with offline sites. If set to true links in the nav are generated with the full path indcluding index.html # Insired by Abridge theme https://www.getzola.org/themes/abridge/ # Notes: # - Also requries the base URL to be set to the local folder where the site will be stored eg. base_url = file:///home/user/mysite/public/ # - This is not portable and only works with a specific local folder easydocs_uglyurls = false # Minimum number of headings needed on a page before the headings show in the navigation on the left. Defaults to 5. # Can be used for example to always show headings on each page by setting it to 1. easydocs_heading_threshold = 5 meshy/docs/themes/easydocs/sass/000077500000000000000000000000001523412555500172125ustar00rootroot00000000000000meshy/docs/themes/easydocs/sass/_search.scss000066400000000000000000000015661523412555500215230ustar00rootroot00000000000000.search-container { display: none; &--is-visible { display: block; width: 100%; } #search { width: 100%; display: block; border:none; border-left: 1px solid $color; padding:1px 0; text-align: left; line-height: $baseline; font-size: $font-size; font-family:$font-family; color:$color; background:transparent; } #search:focus { outline:none; border:none; } .search-results { &__header { font-weight: bold; padding: 1rem 0rem; } &__items { margin: 0 2vw; padding: 0; list-style: circle; } &__item { margin-bottom: 1rem; } &__teaser { } } } #on_right { display: block; text-align: right; margin-bottom: $baseline; } #search-ico { font-family: 'FabricMDL2Icons'; cursor: pointer; font-size: $baseline; line-height: 1; } meshy/docs/themes/easydocs/sass/_variables.scss000066400000000000000000000010611523412555500222140ustar00rootroot00000000000000$baseline: 1.5rem; $background: #f9fafb; $color: #1f2937; $links: #17b7d3; $hover-links: #0fa3bd; $visited-links: #17b7d3; $font-size: 1.05rem; $font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; $line-height: 1.75; $code_font: 400 0.95rem/1.6 "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; $quote-bg: #e8f8fb; $quote-border: #17b7d3 4px solid; $quote-alert-note-color: #17b7d3; $quote-alert-tip-color: #10712b; $quote-alert-important-color: #7544d0; $quote-alert-warning-color: #915f00; $quote-alert-caution-color: #bf1626; meshy/docs/themes/easydocs/sass/fabric-icons-inline.scss000066400000000000000000000125101523412555500237210ustar00rootroot00000000000000/* Your use of the content in the files referenced here is subject to the terms of the license at https://aka.ms/fabric-assets-license */ @font-face { font-family: 'FabricMDL2Icons'; src: url('data:application/octet-stream;base64,d09GRgABAAAAAAusAA4AAAAAFLgABDXDAAAAAAAAAAAAAAAAAAAAAAAAAABPUy8yAAABRAAAAEgAAABgMUZ1H2NtYXAAAAGMAAAAWgAAAYKg2Y81Y3Z0IAAAAegAAAAgAAAAKgnZCa9mcGdtAAACCAAAAPAAAAFZ/J7mjmdhc3AAAAL4AAAADAAAAAwACAAbZ2x5ZgAAAwQAAANyAAAEuLnx29VoZWFkAAAGeAAAADIAAAA2A3zu4GhoZWEAAAasAAAAFQAAACQQAQgDaG10eAAABsQAAAAYAAAAGA+HAaZsb2NhAAAG3AAAABYAAAAWBoYE+m1heHAAAAb0AAAAHQAAACAAJAHEbmFtZQAABxQAAAP3AAAJ+o6N8lFwb3N0AAALDAAAABQAAAAg/1EAgXByZXAAAAsgAAAAiQAAANN4vfIOeJxjYGHfzjiBgZWBgXUWqzEDA6M0hGa+yJDGJMTBysrFyMQIBgxAIMCAAL7BCgoMDs8Z3ulxgPkQkgGsjgXCU2BgAADc3QgGeJxjYGBgZoBgGQZGBhCoAfIYwXwWhgQgLcIgABRhec7wXPG50XO/54df7H5x4mXBO73//xkYsIlKMko8lLgqsVXCUdxL3E5shuBtqMkYgJENu/hIAgCdyyInAAB4nGPQYghlKGBoYFjFyMDYwOzAeIDBAYsIEAAAqhwHlXicXY+/TsNADMZzJLSEJ0A6IZ11KkOViJ3phksk1CUlDOelgNRKpO+AlIXFA8/ibhnzYgjMEf4utr/P+ny/c6f5yXx2nKVHKilWnDfhoNQLDurtmf35IU/vNmVhTNV5VvdlwWoJomtOF/VNsGjI0PWWTG0eH7acLWKXxY7w0nDShk7qbQB2qL/HHeJVPJLFI4QS30/xfYxL+rUsVobTiyasA/des/OoAUzFYxN49BoQf8ikP3VnE+NsOWXbwE5zgkSfygL3RJqE+0uPf/Wgkv+G+23Iv6tB9U3c9Bb0h2HBgrChl2fbUAkaYPkOhPxkxgABAAIACAAK//8AD3icXVNNaBtXEJ55b1dPsl0165UqUOJ1dze7mx+quFrJilwQwgQ3P8UpOGCKUhNfSnrqJb/Q4BcoGAr9CfSUGHpyLr2VJCT0klsv7SVXQ29uySmJCbQr7646byWnpjvsm583b+bNN/OAwX0A7Sv9GnAQAC3DNjzbsO/zP+JH7FFyFvRr/a9/0BaBPg6AMg85OgAFKMJR+CWzctCOPwY48ATegtzrJzAGnNZ8Juskz7yPdtMuG2+WPPwD//26lDIGKRmurQFTifJE4EKL8tUtrVwqaq7jB5ijtdloYQ2bjY5m1jus2agx1ymycslienf1wcbti/X6xdsbD1ZvbV+KX5jVqm/yA+cvDG3Xn230ehvPro94Hobm4bEL5+OXpl+tmty4tH1raNuFfe4Zp8olSEFE9U9CFYLsjozqCoxGh4VI4NEfEtnoEpquUSHRsAUcrLmlaHu75NYOIsWJCbeESkfJQO6CvPsZJ1lItR/JP/W7yj8BJndlKhEGhHtCR/r37jFIYdgPCdS0vOqHIOwBVSLTLmTcEBBJreehl26hTCGW+lbfy9NZ9KKeTkhHFAPf4D0OUEBRwKCArQJWtDv8izsxEfFsIZUuvV+NlQtAhgkImgwKMw4GVEY3IQRCMww8ewSKQoEqTYH3UEpvczOWzBtAQppGNSZSA21r10OZIy2Vm1sIfckIlL5Us6fCMwnvwTn4fIR6qchc26mxwC7yTGiqHti0VbE7PEQakVY2NLMfYE15DEeFPEazoywirL9TLuWOo8XD3NP5K8thuHxlfgDty0tzE+nribmly+0BzF9drteXr87j0I4TmT2WVnvm8NjDqu9XH44dnmlbAziy0LCsxsIRXe5JA/i/F5Mqh4rpn1o5eXLllK9iq9x7egqTzokpGkh/6oQzqaLsRVN8/x4gfgoi96GI1NMsMNVAtsijWLziIo5eCZJiscMFFzv0HiWwPIhf4W0wqVM+1FW3iAQaNDg50VS8hUYL9SHGOYG6iR2szDYbvuuQKlqcusXenU7WeJd3F+YSme6w038n371MHqd/6c+PnZmdtg4lYbq+wn6fOt0rH50uVseSj5x1HLBPsBt/n75Yw672Mf6YrqY7485P6dM00JbSn7/EdvLtDVz8JpVW88yx4CxFWcGb7LepQ1HZmg4KFGXdgX8Bg/8uhAAAeJxjYGRgYGAxPVwnx6UQz2/zlYGbgwEE9v892ACi78Sumg+iORjA4pwMTCAKAB/CCRAAAHicY2BkYOBgAAE4yciACpgAAsoAHQAAAAUqAKYIAAAAAAAAgACAAAAAgAAAAV0AgAAAABYASACYAN4BAAEiAVQB4gH4AlwAAHicY2BkYGDgYshiYGUAAUYwyQXCjJEgJgAOogDqAAAAeJy1VE+LGzcUf1472S1plhIo5KhDKZvFjLNuIDQ5LUlzyl42YSGXgjySZ0TGIyFpMkzpoccc+jF6CfRTlBZ67LmfoOeeeux7bzReb+yGbaEeRvPT0/v7e08GgLujz2EE/e8LfHs8gju46/Ee7MNXCY9R/jzhCeKvE74BH4NL+CZ8At8mvA9fwvcJH8Cn8EvCt+AYfk/49ujn0SThQzje+xWjjCYf4U7t/ZnwCD4bXyS8B4fjbxIeo/xtwhPEPyZ8A+6Of0v4JojxHwnvg58cJHwAx5PBzy14Ofkh4dvjt5O/Ej6Elwff/fROzO+fPBRnJvc22GUUT6x31stobJ2J06oS56YoYxDnOmj/RqvsmVx4k4uzp8/n4jQEHcO5LppK+u2DbcmF9gE9iwfZ/KQ/pcP+7IUurBYmCCmil0qvpH8t7FLEUm/kV3jbOBLnduVkbXTIdiZfxugezWZt22ar4TxDm1nsnC28dGU3W9o6htmleWicq4xWgg4y8co2YiU70QSNSWBiJBbRitxrGfVUKBNcJbupkLUSzhs8zVFF41cG4bRfmRjR3aLjIiqT65p84UEQ1g9gSRGm26U6b1WTx6kg5tF2SjZDAFOLtjR5uZFZi0FNnVeNwjats7d11Ykjc0/o1QJzuVRHDx/KltWVqQvhdYjYKWL1MgCZr309ZgaODEaJekUt8AajKtvWlZXqKnuyp0p7KsdiKFyb6JoolKYySafUlbvKKA5j3SV1agg6RH5KszCYc3b9bsM7EDCH+3ACDxGdgYEcPFgI+C4houwJIo93nlaJEoOohgxPTqHCR8A5ygoo8SzwTuNXo/YbXBVqPkO7Be7JN8V4iv8sc7YPrEl2ZFVAg/4kal7H4jo6F5xHSDkLeIDZzLHWTdvBctPuBWdjcRWoQ1VJfCMzoFC64ixfo4xYopOSdXfxV/C+QQYH7Ry/K9xLzMkwW9m/YJ54jih9BDN8Wn4y9Pe+fZbizBB37KVgPw49dChdsjeqdrYzeuCcHXbEcB/F2oJ6/4prEsxEh9+GueuZ6BkbtElmuWqPGlSHhinuFes57njHEuKD4jjuTG+bJy867SX7dtxXqjnyGVktOI+hExVXRFZDXr1F4C74LclyXcP0Wl11vFdok+N+ynz1M9/Hna7jvF+B4Ulsmacc192ctalS0s6xmobnTu3knmwqRkeofw+/NKGLxMsu730O/5XbS++KPRUo8zzHMd2pYVZ3VTBE387r8cYMUCV9LZHjDbeA/Pe1KpS0XLnlW/mh2ZNXpkpzX2xa+6p63PDNatiSsh26OfghzYpv8j/PaP/PWKfOXHofbohJLNP8UL4LZrrv7f9wt/8GD0U4iAB4nGNgZgCD/34M5QyYgAsAKTQB0nic28CgzbCJkZNJm3ETF4jcztWaG2qrysChvZ07NdhBTwbE4onwsNCQBLF4nc215YVBLD4dFRkRHhCLX05CmI8DxBLg4+FkZwGxBMEAxBLaMKEgwADIYtjOCDeaCW40M9xoFrjRrHCj2eQkoUazw43mgBvNCTd6kzAju/YGBgXX2kwJFwDEASgaAAAA') format('truetype'); } .ms-Icon { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; display: inline-block; font-family: 'FabricMDL2Icons'; font-style: normal; font-weight: normal; speak: none; } // Mixins @mixin ms-Icon--ChevronRightSmall { content: "\E970"; } @mixin ms-Icon--ChromeClose { content: "\E8BB"; } @mixin ms-Icon--Copy { content: "\E8C8"; } @mixin ms-Icon--GlobalNavButton { content: "\E700"; } @mixin ms-Icon--MiniLink { content: "\E732"; } @mixin ms-Icon--Page { content: "\E7C3"; } @mixin ms-Icon--ProductRelease { content: "\EE2E"; } @mixin ms-Icon--Save { content: "\E74E"; } @mixin ms-Icon--Search { content: "\E721"; } // Classes .ms-Icon--ChevronRightSmall:before { @include ms-Icon--ChevronRightSmall } .ms-Icon--ChromeClose:before { @include ms-Icon--ChromeClose } .ms-Icon--Copy:before { @include ms-Icon--Copy } .ms-Icon--GlobalNavButton:before { @include ms-Icon--GlobalNavButton } .ms-Icon--MiniLink:before { @include ms-Icon--MiniLink } .ms-Icon--Page:before { @include ms-Icon--Page } .ms-Icon--ProductRelease:before { @include ms-Icon--ProductRelease } .ms-Icon--Save:before { @include ms-Icon--Save } .ms-Icon--Search:before { @include ms-Icon--Search } meshy/docs/themes/easydocs/sass/main.scss000066400000000000000000000142241523412555500210360ustar00rootroot00000000000000@import "variables"; html { font-kerning: normal; text-rendering: optimizeLegibility; scroll-behavior: smooth; } body { margin:$baseline 0; font-size:$font-size; font-family:$font-family; line-height: $line-height; background:$background; color:$color; } @keyframes fade-in { 0% { opacity: 0; } 50% { opacity: 0.8; } 100% { opacity: 1; } } a { &:link { color:$links; text-decoration: none; } &:hover { color:$hover-links; } &:visited { color:$visited-links; } } h2, h3, h4 { .anchor { visibility: hidden; text-decoration: none; cursor: pointer; line-height: 1; color:$color; } &:hover { .anchor { visibility: visible; animation: fade-in 0.3s ease-in-out; font-family: 'FabricMDL2Icons'; } } } table td, table th { border: 1px solid $color; } pre { margin:$baseline 0; border-radius: 4px; padding:$baseline; overflow: auto; position: relative; code { background:transparent; &::after { content: attr(data-lang); font-style: italic; line-height: 1; opacity:0.3; position:absolute; bottom:$baseline; right:$baseline; z-index:1; } } } code { font: $code_font; } .copy-code-button { font-family: 'FabricMDL2Icons'; display:none; background:$background; border-radius: 4px; border:none; cursor: pointer; animation: fade-in 0.3s ease-in-out; font-size: $baseline; color:$color; z-index: 10; position:absolute; top:$baseline; right:$baseline; } pre:hover .copy-code-button { display: block; } nav { position: sticky; height: 92vh; top:$baseline; left:$baseline; bottom: $baseline; padding-right: $baseline; width: 20rem; img { width:64px; } h1 { margin:0; line-height: 1; } } #toc { margin-left:calc(#{$baseline} + #{$font-size}); padding:0; margin:0 0 0 $baseline; font-size: 80%; li { color:$color; margin-left: $font-size; &::before { display: inline-block; content:""; } ul { padding:0; } } } main { display: flex; flex-flow: row nowrap; animation: fade-in 0.4s ease-in-out; } #release { text-align: left; margin:$baseline 0; &::before { display: inline-block; content: "\EE2E"; font-family: 'FabricMDL2Icons'; margin-right:calc(#{$baseline} / 8); } } @keyframes slideIn { 0% { max-height:0; opacity:0 } 100% { max-height:999px; opacity:1 } } @keyframes slideOut { 0% { height:auto; opacity:1 } 100% { height:0; opacity:0 } } nav label { display:block; } #trees { overflow-y: auto; height: 80%; } .subtree { overflow:hidden; margin:calc(#{$baseline} / 8) 0; transition: overflow 0.2s ease-in-out; padding: 0; } .tree-toggle-label { user-select:none; cursor:pointer; } .tree-toggle-label::before { display:inline-block; content:"\E970"; font-family:'FabricMDL2Icons'; font-size:.75rem; transform:rotate(0deg); transform-origin:50% 50% 0px; transition:transform 0.1s linear 0s; margin-right: 2px; } .tree-toggle { position:absolute; opacity:0; z-index:-1; } .tree-toggle:checked+.tree-toggle-label::before { content:"\E970"; font-family:'FabricMDL2Icons'; font-size:.75rem; transform:rotate(90deg); transform-origin:50% 50% 0px; transition:transform 0.1s linear 0s; margin-right: 2px; } .tree-toggle:checked+.tree-toggle-label { font-weight:bold; } .tree-toggle+.tree-toggle-label+.subtree { animation-name:slideOut; animation-duration:0.25s; animation-fill-mode:both; } .tree-toggle:checked+.tree-toggle-label+.subtree { animation-name:slideIn; animation-duration:0.25s; animation-fill-mode:both; } .subtree li { list-style-type: none; margin-left: $baseline; a { color:$color; } &::before { content: ""; } } .active a { font-weight: bold; } article { width: calc(100% - (#{$baseline} * 4 + 20rem)); margin-left:calc(#{$baseline} * 2); img { max-width:100%; } } #mobile { display: none; } @media screen and (max-width: 1023px) { main { flex-flow: column nowrap; width: 100%; } nav { position: inherit; height:auto; margin:$baseline $baseline 0 $baseline; } article { width: calc(100% - (#{$baseline} * 2)); margin:0 $baseline; z-index:1; } #mobile { font-family: 'FabricMDL2Icons'; cursor: pointer; font-size: $baseline; margin:0 $baseline 0 0; display: block; color:$color; background: transparent; border: none; padding: 0; } #trees { display: none; position:absolute; background: $background; height:auto; width:100vw; z-index:10; box-shadow: 0 2px 0 rgba(0,0,0,.1); } #on_right { margin-top:$baseline; } } blockquote { border-left: $quote-border; margin: 0; background-color: $quote-bg; padding: 0 20px; } blockquote.markdown-alert-note:before { content: "🔍 Note"; font-weight: bold; color: $quote-alert-note-color; } blockquote.markdown-alert-note { border-left-color: $quote-alert-note-color; background-color: $background; } blockquote.markdown-alert-tip:before { content: "💡 Tip"; font-weight: bold; color: $quote-alert-tip-color; } blockquote.markdown-alert-tip { border-left-color: $quote-alert-tip-color; background-color: $background; } blockquote.markdown-alert-warning:before { content: "⚠️ Warning"; font-weight: bold; color: $quote-alert-warning-color; } blockquote.markdown-alert-warning { border-left-color: $quote-alert-warning-color; background-color: $background; } blockquote.markdown-alert-important:before { content: "📌 Important"; font-weight: bold; color: $quote-alert-important-color; } blockquote.markdown-alert-important { border-left-color: $quote-alert-important-color; background-color: $background; } blockquote.markdown-alert-caution:before { content: "☢️ Caution"; font-weight: bold; color: $quote-alert-caution-color; } blockquote.markdown-alert-caution { border-left-color: $quote-alert-caution-color; background-color: $background; } @import "fabric-icons-inline"; @import "search"; meshy/docs/themes/easydocs/static/000077500000000000000000000000001523412555500175305ustar00rootroot00000000000000meshy/docs/themes/easydocs/static/js.js000066400000000000000000000217071523412555500205110ustar00rootroot00000000000000// search script, borrowed from book theme function debounce(func, wait) { var timeout; return function () { var context = this; var args = arguments; clearTimeout(timeout); timeout = setTimeout(function () { timeout = null; func.apply(context, args); }, wait); }; } // Taken from mdbook // The strategy is as follows: // First, assign a value to each word in the document: // Words that correspond to search terms (stemmer aware): 40 // Normal words: 2 // First word in a sentence: 8 // Then use a sliding window with a constant number of words and count the // sum of the values of the words within the window. Then use the window that got the // maximum sum. If there are multiple maximas, then get the last one. // Enclose the terms in . function makeTeaser(body, terms) { var TERM_WEIGHT = 40; var NORMAL_WORD_WEIGHT = 2; var FIRST_WORD_WEIGHT = 8; var TEASER_MAX_WORDS = 30; var stemmedTerms = terms.map(function (w) { return elasticlunr.stemmer(w.toLowerCase()); }); var termFound = false; var index = 0; var weighted = []; // contains elements of ["word", weight, index_in_document] // split in sentences, then words var sentences = body.toLowerCase().split(". "); for (var i in sentences) { var words = sentences[i].split(" "); var value = FIRST_WORD_WEIGHT; for (var j in words) { var word = words[j]; if (word.length > 0) { for (var k in stemmedTerms) { if (elasticlunr.stemmer(word).startsWith(stemmedTerms[k])) { value = TERM_WEIGHT; termFound = true; } } weighted.push([word, value, index]); value = NORMAL_WORD_WEIGHT; } index += word.length; index += 1; // ' ' or '.' if last word in sentence } index += 1; // because we split at a two-char boundary '. ' } if (weighted.length === 0) { return body; } var windowWeights = []; var windowSize = Math.min(weighted.length, TEASER_MAX_WORDS); // We add a window with all the weights first var curSum = 0; for (var i = 0; i < windowSize; i++) { curSum += weighted[i][1]; } windowWeights.push(curSum); for (var i = 0; i < weighted.length - windowSize; i++) { curSum -= weighted[i][1]; curSum += weighted[i + windowSize][1]; windowWeights.push(curSum); } // If we didn't find the term, just pick the first window var maxSumIndex = 0; if (termFound) { var maxFound = 0; // backwards for (var i = windowWeights.length - 1; i >= 0; i--) { if (windowWeights[i] > maxFound) { maxFound = windowWeights[i]; maxSumIndex = i; } } } var teaser = []; var startIndex = weighted[maxSumIndex][2]; for (var i = maxSumIndex; i < maxSumIndex + windowSize; i++) { var word = weighted[i]; if (startIndex < word[2]) { // missing text from index to start of `word` teaser.push(body.substring(startIndex, word[2])); startIndex = word[2]; } // add around search terms if (word[1] === TERM_WEIGHT) { teaser.push(""); } startIndex = word[2] + word[0].length; teaser.push(body.substring(word[2], startIndex)); if (word[1] === TERM_WEIGHT) { teaser.push(""); } } teaser.push("…"); return teaser.join(""); } function formatSearchResultItem(item, terms) { var li = document.createElement("li"); li.classList.add("search-results__item"); li.innerHTML = `${item.doc.title}`; li.innerHTML += `
${makeTeaser(item.doc.body, terms)}
`; return li; } // Go from the book view to the search view function toggleSearchMode() { var $wrapContent = document.querySelector("#wrap"); var $searchIcon = document.querySelector("#search-ico"); var $searchContainer = document.querySelector(".search-container"); if ($searchContainer.classList.contains("search-container--is-visible")) { $searchContainer.classList.remove("search-container--is-visible"); $wrapContent.style.display = ""; $searchIcon.className = "ms-Icon--Search"; } else { $searchContainer.classList.add("search-container--is-visible"); $wrapContent.style.display = "none"; $searchIcon.className = "ms-Icon--ChromeClose"; document.getElementById("search").focus(); } } function initSearch() { var $searchInput = document.getElementById("search"); if (!$searchInput) { return; } var $searchIcon = document.querySelector("#search-ico"); $searchIcon.addEventListener("click", toggleSearchMode); var $searchResults = document.querySelector(".search-results"); var $searchResultsHeader = document.querySelector(".search-results__header"); var $searchResultsItems = document.querySelector(".search-results__items"); var MAX_ITEMS = 100; var options = { bool: "AND", fields: { title: {boost: 2}, body: {boost: 1}, } }; var currentTerm = ""; var index = elasticlunr.Index.load(window.searchIndex); $searchInput.addEventListener("keyup", debounce(function() { var term = $searchInput.value.trim(); if (term === currentTerm || !index) { return; } $searchResults.style.display = term === "" ? "none" : "block"; $searchResultsItems.innerHTML = ""; if (term === "") { return; } var results = index.search(term, options).filter(function (r) { return r.doc.body !== ""; }); if (results.length === 0) { $searchResultsHeader.innerText = `Nothing like «${term}»`; return; } currentTerm = term; $searchResultsHeader.innerText = `${results.length} found for «${term}»:`; for (var i = 0; i < Math.min(results.length, MAX_ITEMS); i++) { if (!results[i].doc.body) { continue; } // var item = document.createElement("li"); // item.innerHTML = formatSearchResultItem(results[i], term.split(" ")); console.log(results[i]); $searchResultsItems.appendChild(formatSearchResultItem(results[i], term.split(" "))); } }, 150)); } if (document.readyState === "complete" || (document.readyState !== "loading" && !document.documentElement.doScroll) ) { initSearch(); } else { document.addEventListener("DOMContentLoaded", initSearch); } // mobile function burger() { let x = document.querySelector("#trees"); let y = document.querySelector("#mobile"); if (x.style.display === "block") { x.style.display = "none"; y.className = "ms-Icon--GlobalNavButton"; } else { x.style.display = "block"; y.className = "ms-Icon--ChromeClose"; } } // https://aaronluna.dev/blog/add-copy-button-to-code-blocks-hugo-chroma/ function createCopyButton(highlightDiv) { const button = document.createElement("button"); button.className = "copy-code-button "; button.type = "button"; button.innerHTML = ""; button.addEventListener("click", () => copyCodeToClipboard(button, highlightDiv) ); addCopyButtonToDom(button, highlightDiv); } async function copyCodeToClipboard(button, highlightDiv) { const codeToCopy = highlightDiv.querySelector(":last-child > code") .innerText; try { result = await navigator.permissions.query({ name: "clipboard-write" }); if (result.state == "granted" || result.state == "prompt") { await navigator.clipboard.writeText(codeToCopy); } else { copyCodeBlockExecCommand(codeToCopy, highlightDiv); } } catch (_) { copyCodeBlockExecCommand(codeToCopy, highlightDiv); } finally { codeWasCopied(button); } } function copyCodeBlockExecCommand(codeToCopy, highlightDiv) { const textArea = document.createElement("textArea"); textArea.contentEditable = "true"; textArea.readOnly = "false"; textArea.className = "copyable-text-area"; textArea.value = codeToCopy; highlightDiv.insertBefore(textArea, highlightDiv.firstChild); const range = document.createRange(); range.selectNodeContents(textArea); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); textArea.setSelectionRange(0, 999999); document.execCommand("copy"); highlightDiv.removeChild(textArea); } function codeWasCopied(button) { button.blur(); button.innerHTML = ""; setTimeout(function () { button.innerHTML = ""; }, 2000); } function addCopyButtonToDom(button, highlightDiv) { highlightDiv.insertBefore(button, highlightDiv.firstChild); const wrapper = document.createElement("div"); wrapper.className = "highlight-wrapper"; highlightDiv.parentNode.insertBefore(wrapper, highlightDiv); wrapper.appendChild(highlightDiv); } document .querySelectorAll("pre") .forEach((highlightDiv) => createCopyButton(highlightDiv)); meshy/docs/themes/easydocs/static/logo.svg000066400000000000000000000051021523412555500212070ustar00rootroot00000000000000 meshy/docs/themes/easydocs/templates/000077500000000000000000000000001523412555500202375ustar00rootroot00000000000000meshy/docs/themes/easydocs/templates/404.html000066400000000000000000000004701523412555500214350ustar00rootroot00000000000000{% extends "index.html" %} {% block title %}{{ config.title }} | 404 Page Not Found{% endblock title %} {% block content %}

404: Page Not Found

The page you requested could not be found.

Return to Home

{% endblock content %} meshy/docs/themes/easydocs/templates/anchor-link.html000066400000000000000000000000661523412555500233340ustar00rootroot00000000000000  meshy/docs/themes/easydocs/templates/index.html000066400000000000000000000174241523412555500222440ustar00rootroot00000000000000 {% if config.extra.favicon -%} {% set _favicon = config.extra.favicon -%} {% if (_favicon is starting_with("http")) == false -%} {% set _favicon = get_url(path=config.extra.favicon) -%} {% endif -%} {% endif -%} {% block title %}{{ config.title }}{% endblock title %} {% if config.extra.release -%} {% endif -%}
{# Create variable to allow appending index.html at end of links if set in config #} {% if not config.extra.easydocs_uglyurls -%} {% set _ugly_url = "" -%} {% else %} {% set _ugly_url = "index.html" -%} {% endif -%} {%- block nav -%} {% set current_path_safe = current_path | default(value="") %} {% endblock nav -%}
{% if config.build_search_index -%}
    {% endif -%}
    {% block content -%} {%- if section.word_count -%} {{ section.content |safe }} {%- else -%} {%- include "sec_toc_2_level.html" -%} {% endif -%} {% endblock content %}
    {% if config.build_search_index %} {% endif -%} meshy/docs/themes/easydocs/templates/page.html000066400000000000000000000003371523412555500220440ustar00rootroot00000000000000{% extends "index.html" %} {% block title %} {{ config.title }} | {{ page.title }} {% endblock title %} {% block content %}

    {{ page.title }}

    {{ page.content | safe }} {% endblock content %} meshy/docs/themes/easydocs/templates/sec_toc_2_level.html000066400000000000000000000013021523412555500241500ustar00rootroot00000000000000

    Table of Contents

    {% if section.subsections -%}
      {% for subsec in section.subsections -%} {% set sec_ = get_section(path=subsec) -%}
    • {{ sec_.title }} {% if sec_.pages -%} {% endif -%}
    • {% endfor %}
    {% else -%}

    No Sections Found

    {% endif -%} meshy/docs/themes/easydocs/templates/section.html000066400000000000000000000020251523412555500225700ustar00rootroot00000000000000{% extends "index.html" %} {% block title %} {{ config.title }} | {{ section.title }} {% endblock title %} {% block content %} {% if section.word_count > 0 -%} {{ section.content | safe }} {% else -%}

    {{ section.title }}

    {% if section.subsections -%}

    Subsections:

      {% for subsec in section.subsections -%} {% set sec_ = get_section(path=subsec) -%}
    • {{ sec_.title }}
    • {% endfor %}
    {% endif -%}

    Pages:

      {% if section.pages -%} {% for page in section.pages -%}
    • {{ page.title }}
    • {% endfor -%} {% else -%}
    • No pages
    • {% endif %}
    {% endif -%} {% endblock content %} meshy/docs/themes/easydocs/theme.toml000066400000000000000000000004601523412555500202400ustar00rootroot00000000000000name = "EasyDocs" description = "An easy way to create docs for your project" license = "MIT" homepage = "https://github.com/codeandmedia/zola_easydocs_theme" min_version = "0.21.0" demo = "https://zola-easydocs-theme.pages.dev" [author] name = "Roman Soldatenkov" homepage = "https://codeandmedia.com" meshy/meson.build000066400000000000000000000041661523412555500143630ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later project('meshy', version: '26.08', meson_version: '>= 0.62.0', ) i18n = import('i18n') gnome = import('gnome') python = import('python') # Platform detection host_system = host_machine.system() is_macos = host_system == 'darwin' is_linux = host_system == 'linux' py_installation = python.find_installation('python3') # Check required Python modules _pip_names = {'Crypto': 'pycryptodome', 'serial': 'pyserial'} _required_modules = ['Crypto', 'serial'] # Platform-specific modules if is_linux and get_option('qr_scanner') _required_modules += ['pyzbar'] endif if is_macos _macos_modules = ['objc', 'CoreBluetooth', 'CoreLocation'] if get_option('qr_scanner') _macos_modules += ['AVFoundation', 'Vision', 'libdispatch'] endif _required_modules += _macos_modules _pip_names += {'objc': 'pyobjc', 'CoreBluetooth': 'pyobjc-framework-CoreBluetooth', 'CoreLocation': 'pyobjc-framework-CoreLocation', 'AVFoundation': 'pyobjc-framework-AVFoundation', 'Vision': 'pyobjc-framework-Vision', 'libdispatch': 'pyobjc-framework-libdispatch'} endif foreach mod : _required_modules if run_command(py_installation, '-c', 'import @0@'.format(mod), check: false).returncode() != 0 _pip = _pip_names.get(mod, mod) warning('Python module "@0@" not found. Install it via pip: pip install @1@'.format(mod, _pip)) endif endforeach APPLICATION_ID = 'page.codeberg.sesivany.Meshy' pkgdatadir = get_option('prefix') / get_option('datadir') / 'meshy' conf = configuration_data() conf.set('PYTHON', py_installation.full_path()) conf.set('VERSION', meson.project_version()) conf.set('APPLICATION_ID', APPLICATION_ID) conf.set('localedir', get_option('prefix') / get_option('localedir')) conf.set('pkgdatadir', pkgdatadir) conf.set('QR_SCANNER_ENABLED', get_option('qr_scanner').to_string()) conf.set('SHORTCUTS_DIALOG_ENABLED', get_option('shortcuts_dialog').to_string()) subdir('data') subdir('src') subdir('po') gnome.post_install( glib_compile_schemas: true, gtk_update_icon_cache: true, update_desktop_database: true, ) meshy/meson_options.txt000066400000000000000000000006301523412555500156460ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later option('qr_scanner', type: 'boolean', value: true, description: 'Enable QR code scanning support (Linux: pyzbar/zbar, macOS: AVFoundation/Vision)') option('shortcuts_dialog', type: 'boolean', value: true, description: 'Enable keyboard shortcuts dialog (requires libadwaita >= 1.8)') meshy/po/000077500000000000000000000000001523412555500126305ustar00rootroot00000000000000meshy/po/LINGUAS000066400000000000000000000000601523412555500136510ustar00rootroot00000000000000cs da de es et fi fr hu it lt lv nl pl pt sk sv meshy/po/POTFILES000066400000000000000000000036451523412555500140100ustar00rootroot00000000000000data/page.codeberg.sesivany.Meshy.desktop.in data/page.codeberg.sesivany.Meshy.metainfo.xml.in data/gtk/help-overlay.ui data/ui/window.ui data/ui/device-view.ui data/ui/settings-view.ui data/ui/connection-view.ui data/ui/contacts-view.ui data/ui/chat-view.ui data/ui/channels-view.ui data/ui/channel-chat-widget.ui data/ui/repeater-view.ui data/ui/sensor-view.ui data/ui/device-actions.ui data/ui/rx-log-dialog.ui data/ui/stats-chart-dialog.ui data/ui/settings-sidebar.ui data/ui/manual-backup-dialog.ui data/ui/auto-backup-dialog.ui data/ui/import-backup-dialog.ui data/ui/discover-dialog.ui data/ui/share-contact-dialog.ui data/ui/theme-chooser-dialog.ui data/ui/contact-detail-dialog.ui data/ui/trace-path-dialog.ui data/ui/add-contact-content.ui data/ui/channel-message-item.ui data/ui/channel-participants-dialog.ui data/ui/chat-message-item.ui data/ui/create-private-channel-content.ui data/ui/custom-tiles-dialog.ui data/ui/discover-nearby-dialog.ui data/ui/discover-regions-dialog.ui data/ui/join-hashtag-channel-content.ui data/ui/join-private-channel-content.ui data/ui/location-dialog.ui data/ui/map-sidebar-filters.ui data/ui/map-view.ui data/ui/message-paths-dialog.ui data/ui/pair-dialog.ui data/ui/passkey-dialog-content.ui data/ui/qr-code-dialog.ui data/ui/qr-contact-content.ui data/ui/qr-scanner-dialog.ui data/ui/region-add-content.ui data/ui/room-login-dialog.ui data/ui/sensor-login-dialog.ui data/ui/tcp-add-content.ui data/ui/tcp-change-address-content.ui data/ui/trace-picker-bottom-bar.ui src/application.py src/connection_controller.py src/frame_handler.py src/message_controller.py src/window.py src/qr_scanner.py src/views/__init__.py src/views/connection_view.py src/views/contact_detail_dialog.py src/views/contacts_view.py src/views/channels_view.py src/views/device_view.py src/views/settings_view.py src/views/repeater_view.py src/views/sensor_view.py src/views/chat_helpers.py src/views/chat_view.py src/views/map_view.py meshy/po/cs.po000066400000000000000000003175151523412555500136110ustar00rootroot00000000000000# Czech translations for meshy package. # Copyright (C) 2026 THE meshy'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # Automatically generated, 2026. # Jiří Eischmann , 2026. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: 2026-07-30 12:23+0000\n" "Last-Translator: sesivany \n" "Language-Team: Czech \n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" "X-Generator: Weblate 2026.7.1\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "Meshy" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "Klient sítě mesh MeshCore" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "mesh;lora;rádio;chat;síť;" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" "Meshy je klient pro zařízení sítě mesh MeshCore LoRa. Připojte se ke svému " "companionu přes Bluetooth a posílejte a přijímejte šifrované zprávy, " "spravujte kontakty, nastavujte kanály a sledujte svou síť mesh." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "Funkce:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "Připojení přes Bluetooth LE a USB sériový port" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "Šifrování zpráv mezi koncovými body" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "Veřejné, soukromé a hashtagové kanály" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "Správa kontaktů se sledováním polohy" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "Interaktivní zobrazení mapy s trasováním cest" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "Správa zařízení a repeaterů" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "Skenování QR kódů pro rychlou výměnu kontaktů" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "Jiří Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "Chaty kanálů" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "Správa repeateru" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "Nastavení - tmavý styl" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "Zobrazení mapy s polohami kontaktů" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "Obecné" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "Odpojit" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "Ukončit" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "Nápověda" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "Klávesové zkratky" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "Navigace" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "Zařízení" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "Kontakty" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "Kanály" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "Mapa" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "Nastavení" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "Zprávy" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "Hledat kontakty" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "Přepnutí na psaní zprávy" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "Předchozí kontakt/kanál" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "Další kontakt/kanál" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "Předchozí nepřečtená" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "Další nepřečtená" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "O aplikaci Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "Připojit" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "Nabídka" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "Připojování…" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "Zobrazit navigaci" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "Nepřipojeno" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "Odpojeno" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Stav" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "Informace o zařízení" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "Název uzlu" #: data/ui/device-view.ui:54 msgid "Board" msgstr "Deska" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:63 msgid "Update" msgstr "Aktualizovat" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "Otevřít flasher firmwaru" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "Veřejný klíč" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "Telemetrie" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "Vyžádat telemetrii" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "Synchronizace…" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "Poloha" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "Zobrazit na mapě" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "Baterie a úložiště" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "Baterie" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "Úložiště" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "Konfigurace rádia" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "Frekvence" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "Šířka pásma" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "Faktor rozprostření" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "Kódovací poměr" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "Vysílací výkon" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Výchozí velikost hashe trasy" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "Statistiky" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "Obnovit statistiky" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "Úroveň šumu" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "Poslední RSSI" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "Poslední SNR" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "Doba vysílání" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Pakety" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "Přímé" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Doba běhu" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Fronta zpráv" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "Aplikace" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "Nastavte chování a vzhled aplikace" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "Styl" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "Zvolte vzhled aplikace" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Podle systému" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "Světlý" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "Tmavý" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "Barevné téma" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "MeshCore Dark" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "Oznámení kanálů" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "Výchozí úroveň oznámení pro všechny kanály" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "Všechny zprávy" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "Pouze zmínky" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "Žádné" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "Běžet na pozadí" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "Přijímejte zprávy i po zavření okna" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "Automatický start" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "Po přihlášení automaticky spustit" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "Rádio" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "Parametry rádia LoRa a regionální předvolby" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Regionální preset" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "Konfigurace presetu" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "Frekvence, šířka pásma, faktor rozprostření, kódovací poměr" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "Frekvence (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "Šířka pásma (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "Režim přeposílání" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "Fungovat jako přenosný repeater na off-grid frekvenci" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "Vysílací výkon (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "Advert" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "Nastavte, jak se váš uzel ohlašuje na síti" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "Název zařízení" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "Zahrnout polohu v advertu" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "Vysílat vaši polohu ostatním uzlům" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "Směrování a zprávy" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "Nastavení trasování a doručování zpráv" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "Výchozí směrování" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "Pevná trasa nebo automatický výběr cesty" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "Klasické" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "Chytré" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "Zjistěte více o režimech směrování" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "Delší hashe snižují počet kolizí, ale umožňují méně skoků" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "1 bajt (max 64 skoků)" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "2 bajty (max 32 skoků)" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "3 bajty (max 21 skoků)" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "Potvrzení přímých zpráv" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "Počet ACKů odeslaných při přijetí přímé zprávy" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "Nastavte polohu companiona přes GPS nebo ručně" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "GPS companiona" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "Zapnout GPS na companionu; poloha se bude aktualizovat automaticky" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "Nastavit polohu ručně" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "Zvolte jiný způsob nastavení polohy, bude fixní" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "Zeměpisná šířka" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "Zeměpisná délka" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Použít moji polohu" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "Nastavit podle polohy tohoto počítače" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "Vybrat na mapě" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "Zvolte polohu na mapě" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "Automatické přidávání kontaktů" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "Automaticky přidávat uzly zjištěné z advertů" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "Nastavení automatického přidávání" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "Typy zařízení, politika přepisu, limit hopů" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "Chatové uzly" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "Repeatery" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "Room servery" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "Senzory" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "Přepsat nejstarší při plné kapacitě" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "Limit hopů" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "0–63, ponechte prázdné pro žádný limit" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "Soukromí telemetrie" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "Určete, co ostatní mohou dotazovat z vašeho zařízení" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "Baterie a senzory" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "Baterie, napětí, teplota, proud" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "Souřadnice GPS" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "Prostředí" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "Vlhkost, tlak, kvalita vzduchu" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "Regiony" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "Omezit dosah floodu u zpráv kanálů na konkrétní regiony" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "Přidat region…" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "Objevit regiony z repeaterů" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "Přidat region" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "Výchozí oblast" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "Všechny flood pakety budou omezeny na tento region, pokud je nastavena" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" "Po změně PINu restartujte zařízení, odpárujte jej a znovu spárujte. " "Nastavení 0 nastaví PIN na výchozí hodnotu." #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "Párovací PIN" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "Připojte se ke companionu a začněte používat Meshy." #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "Spárovat s novým companionem" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "USB sériové" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "Wi-Fi / TCP" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "Přidat TCP companiona" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Hledat…" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "Žádné kontakty" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "Přidat kontakt" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "Vyberte kontakt" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "Vyberte kontakt ze seznamu a začněte chatovat" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "↑ Nové zprávy" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "Napište zprávu…" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "Vytvořit soukromý kanál" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "Připojit se k soukromému kanálu" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Připojit se k veřejnému kanálu" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "Připojit se ke kanálu s hashtagem" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "Žádné kanály" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "Přidat kanál" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "Vyberte kanál" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "Vyberte kanál ze seznamu" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "Zpráva do kanálu…" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "Systém" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "Hodiny při přihlášení" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "Synchronizovat" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "Resetovat a restartovat" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "Chybové události" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "Doba vysílání TX" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "Doba vysílání RX" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "Využití kanálu" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "Střída signálu" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "Odesláno" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "Přijato" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "Chyby na příjmu" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "Duplikáty" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "Obnovit" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "Vyžádat telemetrii" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "Příkaz CLI…" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "Sousedé" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "Načíst další" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "Zobrazit na mapě" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "Řízení přístupu" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "Přidat" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "Načítání regionů…" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "Zkusit znovu" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "Výchozí omezující region" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" "Pakety šířící se floodem budou omezeny na zadaný region. Pouze repeatery " "povolující tento region budou pakety s omezeným dosahem posílat dál. " "Ponechte prázdné pro šíření bez omezení." #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "Použít" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "Základní" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "Načíst" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "Uložit" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "Název" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "Opakování paketů" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "Přístup pro hosta" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "Povolit přístup pro hosta bez hesla s možností pouze číst" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "Hesla" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "Heslo správce" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "Heslo hosta" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "Advert" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "Místní interval (min)" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "Interval floodu (hod)" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "Nebezpečná zóna" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "Restartovat zařízení" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "Vybrat zařízení" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "Odhlásit se" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "Vyberte senzor" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "Obnovit telemetrii" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "Přihlášení pro přístup správce" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "Odeslat advert" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "Zero hop" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "Vysílat lokálně" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "Poslat floodem" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "Vysílat napříč sítí mesh" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "Do schránky" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "Kopírovat vlastní kontaktní údaje" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "Zobrazit QR kód" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "Aby vás ostatní mohli naskenovat a přidat" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "Trasovat cestu" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "Trasovat cestu a zobrazit kvalitu signálu na každém skoku" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "Objevit blízké uzly" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "Prohledat síť pro blízké uzly" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "Rx Log" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "Zobrazit přijaté rádiové pakety" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "Vymazat log" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "Žádné pakety" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "Přijaté rádiové pakety se zobrazí zde" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "dBm za poslední 2 minuty" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "Záloha" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "Ruční" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "Vyberte, co zálohovat a uložit do souboru" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "Automaticky" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "Denní zálohy, uložené 7 dní" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "Obnovit" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "Ze souboru" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "Obnovit z ručně uložené zálohy" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "Z automatické zálohy" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "Obnovit z automatického snapshotu" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "Obnovení továrního nastavení" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "Vymazat všechna data na companionu" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "Ruční záloha" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "Identifikace zařízení" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "Nastavení zařízení" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "Místní nastavení" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "Seznam kontaktů" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "Objevené kontakty" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "Zprávy a směrování" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "Zprávy" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "Historie chytrého směrování" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "Exportovat" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "Automatická záloha" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "Povolit automatické zálohy" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "Denní zálohy po syncu, uložené 7 dní" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "Importovat zálohu" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "Objevit kontakty" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "Hledat" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "Filtrovat podle typu" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "Řadit podle" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "Sdílet kontakt" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Vyberte téma" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "Informace o kontaktu" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "Oblíbený" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "Připnout na začátek seznamu kontaktů" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "Typ" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "Zkopírovat do schránky" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "Naposledy viděn" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "Směrování" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "Odchozí trasa" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "Resetovat trasu" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "Vymazat zavedenou trasu a přepnout na flood" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "Dostupné cesty" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "Automaticky resetovat trasu" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "Přepnout do floodu po neúspěšných pokusech" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "Vynutit flood" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "Vždy vysílat všesměrově, nikdy nepoužívat zavedenou trasu" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "Dotázat se na data telemetrie tohoto kontaktu" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "Odebrat kontakt" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "Trasa" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "Zvolte trasu na mapě" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "Spustit trasování" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "Zadejte hashe trasy a stiskněte Spustit trasování" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "Typ kontaktu" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "Veřejný klíč (64 hex znaků)" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "Otevřít v mapové aplikaci" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "Účastníci" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "Filtr" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "Název kanálu" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "Vlastní mapové dlaždice" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "Šablona URL" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "Resetovat do výchozí" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "Naslouchání…" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "Čekání na odpověď blízkých uzlů" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "Objevit regiony" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "Hledání…" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "Vyhledávání blízkých repeaterů" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "Přidat vybrané" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (např. #obecný)" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "Soukromý klíč (32 hex znaků)" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "Poloha zařízení" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "Uživatelé" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Roomy" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "Objevené uzly" #: data/ui/map-view.ui:30 msgid "User" msgstr "Uživatel" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "Repeater" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "Room" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "Senzor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "Trasy zprávy" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "Hledání blízkých zařízení MeshCore" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "Spárovat nového companiona" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "Hledání zařízení MeshCore…" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "PIN" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "Váš kontaktní QR kód" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "Naskenujte tento QR kód pro přidání kontaktu" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "Naskenovat QR kód" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "Namiřte kameru na QR kód…" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "Název regionu" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "Rodič" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "Heslo" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Uložit heslo" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "Přihlásit" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "Název hostitele / IP adresa" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "Port" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "Vrátit" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "Vymazat" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "Hotovo" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "Chat" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "Neznámý" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" "Název: {name}\n" "Typ: {type}" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" "Typ: {type}\n" "Klíč: {key}…" #: src/application.py:132 msgid "Import Contact" msgstr "Importovat kontakt" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "Kontakt {}" #: src/application.py:136 msgid "imported" msgstr "naimportován" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "Neplatný odkaz meshcore://" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "Neplatný tajný klíč kanálu v odkazu" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "Neplatná délka tajného klíče kanálu" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" "Název: {name}\n" "Typ: Soukromý" #: src/application.py:161 msgid "Unnamed" msgstr "Nepojmenovaný" #: src/application.py:177 msgid "Invalid public key in link" msgstr "Neplatný veřejný klíč v odkazu" #: src/application.py:180 msgid "Invalid public key length" msgstr "Neplatná délka veřejného klíče" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "{} je již ve vašem seznamu" #: src/application.py:184 msgid "Contact" msgstr "Kontakt" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "Přidat kontakt typu {}" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" "Název: {name}\n" "Veřejný klíč: {key}…" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "Kontakt {} přidán" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "Nerozpoznaný odkaz meshcore://" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "Zrušit" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "Zprávy se přijímají na pozadí" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" "gallegonovato — španělština\n" "lebeno — francouzština, nizozemština\n" "bjawebos — němčina\n" "Manuel Traut — němčina\n" "lejun — francouzština\n" "LievenBlancke — nizozemština\n" "Jiří Eischmann — čeština" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "GTK klient pro zařízení sítě mesh MeshCore LoRa" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "Symbolické ikony Adwaita" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "Připojování…" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "Hledat zařízení" #: src/connection_controller.py:160 msgid "Scan" msgstr "Hledat" #: src/connection_controller.py:277 msgid "Stop" msgstr "Zastavit" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Opětovné připojení ({current}/{total})…" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "Synchronizace…" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "Zařízení neodpovědělo. Nemusí podporovat tento typ spojení." #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "Synchronizace kanálů" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "Při odpojení jste obdrželi {} zprávu." msgstr[1] "Při odpojení jste obdrželi {} zprávy." msgstr[2] "Při odpojení jste obdrželi {} zpráv." #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "Chyba zařízení: {}" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "Synchronizace kontaktů" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "{} (pozdě)" #: src/frame_handler.py:506 msgid "late" msgstr "pozdě" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "Hodiny zařízení byly {}min pozadu — synchronizovány" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Hodiny zařízení jsou {}min napřed oproti systému" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "Kanál {}" #: src/frame_handler.py:1208 msgid "Someone" msgstr "Někdo" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "Já" #: src/message_controller.py:218 msgid "flood" msgstr "flood" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "přímá" #: src/message_controller.py:222 msgid "path" msgstr "trasa" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "{attempt}/{total} – {route}" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "čekání {} (rádio zaneprázdněno)" #: src/window.py:497 msgid "Raster" msgstr "Rasterové" #: src/window.py:497 msgid "Vector" msgstr "Vektorové" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" "Použij {z}, {x}, {y} jako zástupné symboly.\n" "Příklad: https://tile.openstreetmap.org/{z}/{x}/{y}.png" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" "URL k JSON stylu MapLibre/Mapbox.\n" "Příklad: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" #: src/window.py:515 msgid "Style JSON URL" msgstr "URL k JSON stylu" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "Vlastní mapové dlaždice nastaveny" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "Nelze načíst vlastní mapové dlaždice" #: src/window.py:576 msgid "Sort" msgstr "Řadit" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "Akce" #: src/window.py:661 msgid "Channel" msgstr "Kanál" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "Záloha a obnova" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:859 msgid "Management" msgstr "Správa" #: src/window.py:859 msgid "Guest" msgstr "Host" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:961 msgid "Excellent" msgstr "Vynikající" #: src/window.py:962 msgid "Good" msgstr "Dobrý" #: src/window.py:963 msgid "Fair" msgstr "Slušný" #: src/window.py:964 msgid "Poor" msgstr "Špatný" #: src/window.py:965 msgid "Very poor" msgstr "Velmi špatný" #: src/window.py:1191 msgid "flood routed" msgstr "směrováno floodem" #: src/window.py:1191 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "Advert odeslán ({})" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Kanál „{}“ přidán" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "Nejsou dostupné žádné volné sloty kanálů" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "Nelze se připojit ke sběrnici relace: {}" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "Na tomto systému nebyla zjištěna žádná kamera." #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "Nepodařilo se přistoupit ke kameře: {}" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "Přístup ke kameře byl odepřen." #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "Nepodařilo se otevřít proud kamery." #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "Nebyl přijat deskriptor souboru kamery." #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "Nepodařilo se otevřít kameru: {}" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Chyba kanálu kamery: {}" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" "QR kód zatím nebyl rozpoznán.\n" "Ujistěte se, že je kód dobře osvětlený, ostrý a zabírá většinu snímku." #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" "Nepodařilo se rozpoznat QR kód.\n" "Zkuste kontakt naimportovat ze schránky." #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "QR kód nalezen!" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "Chyba kamery: {}" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "Chyba kamery" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "OK" #: src/views/__init__.py:52 msgid "Today" msgstr "Dnes" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "Včera" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "%A, %-d. %B" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "%A, %-d. %B %Y" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "{total} kontaktů" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} kontaktů" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "Odebrat" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "Bluetooth není dostupné" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "BLE vyžaduje spuštění z balíčku aplikace v systému macOS" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "Bluetooth je vypnutý" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "Zapněte Bluetooth v nastavení systému pro připojení" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "Žádná spárovaná zařízení" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "Pomocí tlačítka níže spárujte nového companiona" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "Nebyla zjištěna žádná USB zařízení" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "Připojte companiona MeshCore přes USB" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "Odebrat companiona" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "Odebrat {} ({})? Pro opětovné připojení bude potřeba znovu spárovat." #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "Smazat uložená data" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "Připojení USB selhalo" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "Přístup k USB odepřen" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" "Nelze přistoupit k {}.\n" "\n" "Pro přístup k USB sériovým zařízením je potřeba pravidlo udev. Klikněte na " "„Nainstalovat pravidlo“ pro jeho instalaci (vyžaduje heslo správce)." #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "Nainstalovat pravidlo" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" "Nelze přistoupit k {}.\n" "\n" "Pro přístup k USB sériovým zařízením je potřeba pravidlo udev. Pusťte " "následující příkaz v terminálu:\n" "\n" "{}" #: src/views/connection_view.py:280 msgid "Close" msgstr "Zavřít" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "Kopírovat příkaz" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "Příkaz zkopírován do schránky" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "Instalace selhala" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Nepodařilo se nainstalovat pravidlo udev:\n" "{}" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "Žádní uložení companioni" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "Pomocí tlačítka níže přidejte companiona" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "Změnit adresu" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "Odebrat TCP companiona {}?" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "Vložit novou adresu pro tento companion." #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "Zadejte IP adresu a port vašeho zařízení MeshCore." #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "Spárovat" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "Zadejte PIN pro párování" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "Zadejte PIN zobrazený na vašem zařízení MeshCore." #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "Nikdy" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "Zobrazit trasu na mapě" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "Zobrazit trasu zprávy na mapě" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "Povolit požadavky na telemetrii" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "Povolit tomuto kontaktu dotazovat baterii, napětí, teplotu" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "Zahrnout polohu" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "Zahrnout souřadnice GPS v odpovědích telemetrie" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "Zahrnout senzory prostředí" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "Zahrnout data o vlhkosti, tlaku, kvalitě vzduchu" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "Správa roomu" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "Správa repeateru" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "Přihlásit se a získat přístup ke stavu, CLI, sousedům a nastavení" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "Změřit dobu odezvy k tomuto uzlu" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "Objevit trasy" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "Najít trasy k tomuto uzlu pomocí floodu" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "Získat regiony z tohoto repeateru" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "Dotazování..." #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "Bez odpovědi" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "Veřejný klíč zkopírován" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "%d tras dostupných" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "Zatím žádné trasy" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "Chytré cesty - %s" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "Dostupné trasy" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "Skóre: %.2f" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "Váha: %.1f" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "%d skoků" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "Zatím nezaznamenány žádné trasy" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "Trasy se objevují při výměně zpráv" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "Vymazat historii" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "Žádná data polohy nejsou k dispozici" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "Chytré cesty k %s" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "Moje zařízení" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "Odebrat kontakt?" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "Odebrat {} a všechny zprávy?" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "Přidat ručně" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "Importovat ze schránky" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "Vše" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "Oblíbené" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "Nedávno slyšené" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "Nejnovější zprávy" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "Nejdříve oblíbené" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "{total} kontakt" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" "Zatím nebyly zaznamenány žádné nové kontakty. Nechte aplikaci běžet a " "kontakty se objeví, jakmile budou přijaty jejich adverty." #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "{total} objeveno" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} objeveno" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "Čas přijetí" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "Vzdálenost" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "Zadejte údaje kontaktu." #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "Room server" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "Tento kontakt je již ve vašem seznamu." #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "Import selhal" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "Ve schránce nebyl nalezen odkaz meshcore://." #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "Neplatný tajný klíč kanálu v QR kódu." #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "Tajný klíč kanálu musí mít 16 bajtů, obdrženo {}." #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "Neplatný veřejný klíč v QR kódu." #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "Veřejný klíč musí mít 32 bajtů, obdrženo {}." #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Nepodařilo se zpracovat data QR kódu:\n" "{}" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "QR kód naskenován" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "Veřejný klíč: {}…{}" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "Přihlásit se k {}" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "Přihlášení úspěšné" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "Přihlášení selhalo — nesprávné heslo" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "Přihlášení vypršelo" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "Opakování přes flood…" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "Přihlašování…" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "Hledání…" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d skok" msgstr[1] "%d skoky" msgstr[2] "%d skoků" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "Trasa nalezena" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "Načítání…" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "Žádná uložená trasa" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "Nepodporováno" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "Ping není podporován s 3bajtovými hashi tras" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: vypršel časový limit" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Vyžadování…" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "Bez odpovědi (vypršel časový limit)" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Žádná data telemetrie" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "Telemetrie přijata" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "Trasa k {}" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "Neznámý repeater" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "Slyšená opakování" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "Odeslat znovu" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "Smazat" #: src/views/channels_view.py:151 msgid "Reply" msgstr "Odpovědět" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "Kopírovat text" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "Zobrazit trasy zprávy" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "Sdílet polohu" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "Sdílet polohu z mapy" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "Sdílíte kontakt:" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} s vámi sdílí kontakt:" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "Již přidán" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "Sdílíte polohu:" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} sdílí polohu:" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "Nové zprávy" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d opakování" msgstr[1] "· %d opakování" msgstr[2] "· %d opakování" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "· %d opakované odeslání" msgstr[1] "· %d opakovaná odeslání" msgstr[2] "· %d opakovaných odeslání" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" "Informace o trase nejsou dostupné. Data o trase se zaznamenávají pouze pro " "zprávy přijaté během připojení." #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "Přímá zpráva" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "Žádné repeatery nebyly použity" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "Žádné podrobnosti o repeateru" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "Data o trase se zaznamenávají pouze přes LOG_DATA během připojení" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "Trasa zprávy" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "Zatím nebyla slyšena žádná opakování." #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "Veřejný" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "Hashtag" #: src/views/channels_view.py:1465 msgid "Private" msgstr "Soukromý" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "{visible}/{total} kanálů" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "{total} kanálů" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "{total} kanál" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "Informace o kanálu" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "Soukromý klíč" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "Soukromý klíč zkopírován do schránky" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} účastník" msgstr[1] "{} účastníci" msgstr[2] "{} účastníků" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "Uchovávání zpráv" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "1 den" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "1 týden" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "1 měsíc" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "3 měsíce" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "Napořád" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "Úroveň oznámení" #: src/views/channels_view.py:1775 msgid "Default" msgstr "Výchozí" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "Region" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "Omezovat zprávy šíření floodem na konkrétní region" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "Nejsou definovány žádné regiony" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" "Chcete-li kanálům přiřadit regiony, musíte je nejdříve přidat v Nastaveních." #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "Vymazat zprávy" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "Vymazat zprávy?" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "Smazat všechny zprávy v „{}“?" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "Zprávy smazány" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "Odebrat kanál" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "Odebrat kanál?" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "Odebrat „{}“ a všechny jeho zprávy?" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "Zablokovaní" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} blokovaný" msgstr[1] "{} blokovaní" msgstr[2] "{} blokovaných" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "Odblokovat" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "Blokovat" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} zpráva" msgstr[1] "{} zprávy" msgstr[2] "{} zpráv" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "Odblokovaný {}" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "Blokovat {}?" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" "Zprávy v kanálech neobsahují identitu odesílatele. Toto blokování je jen na " "základě jména – odesílatel může své jméno změnit a blokování tak obejít." #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "Blokovat a smazat zprávy" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "Zablokovaný {}" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "Žádné volné sloty" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "Všech 8 slotů kanálů je obsazeno." #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" "Bude vygenerován náhodný PSK. Sdílejte ho s ostatními, aby se mohli připojit." #: src/views/channels_view.py:2233 msgid "Create" msgstr "Vytvořit" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "Zadejte název kanálu a soukromý klíč, který vám byl sdílen." #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "Připojit se" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "Již připojeno" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "Již jste na veřejném kanálu." #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Zadejte název hashtagu. Kdokoliv se stejným hashtagem může komunikovat." #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "Veřejný klíč zařízení není dostupný" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "Knihovna QR kódů není dostupná" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "Obnovit tovární nastavení?" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" "Tímto trvale vymažete VŠECHNA data na companionu, včetně kontaktů, zpráv, " "kanálů, nastavení a identity zařízení (klíčů). Tuto akci nelze vrátit zpět." #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "Restartovat zařízení?" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "Zařízení se odpojí a restartuje." #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "Restartovat" #: src/views/device_view.py:327 msgid "Not set" msgstr "Nenastaveno" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "Poloha (ručně nastavená)" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "Poloha (GPS)" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "Poloha (GPS zapnutý, bez signálu)" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "Poloha (GPS vypnutý)" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "Vyžaduje se…" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "{days}d" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "{} zpráv" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "{count} chyby" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "Připojeno" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "Trasování cesty není podporováno s 3bajtovými hashi cest" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "např. aa,bb,cc" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "např. aabb,ccdd" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "např. aabbcc,ddeeff" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "Žádné repeatery se známou polohou" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trasování dokončeno: {} skoků, {}" #: src/views/device_view.py:689 msgid "Result" msgstr "Výsledek" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "Trasování vypršelo" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "Neplatný hex v trase" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "Trasování…" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Hledání… zbývá {}s" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "Přidat do kontaktů" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "Přidáno {}" #: src/views/device_view.py:1015 msgid "Node" msgstr "Uzel" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "Hotovo. Nalezeno {} uzlů." #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "Hotovo. Nalezen {} uzel." #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} za poslední 2 minuty" #: src/views/device_view.py:1301 msgid "Size" msgstr "Velikost" #: src/views/device_view.py:1301 msgid "bytes" msgstr "bajtů" #: src/views/device_view.py:1306 msgid "hops" msgstr "hopů" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "Hashe trasy" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "bajt na hop" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "Dostupná aktualizace: {version}" #: src/views/settings_view.py:143 msgid "Custom" msgstr "Vlastní" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "Odmítnout vše" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "Povolit podle kontaktů" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "Povolit vše" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "Nastavení uloženo na zařízení" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "Neznámá chyba" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "Chyba nastavení: {}" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Vysílací výkon (dBm, max {})" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "Poloha nedostupná" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "Poloha vyplněna, klikněte na Použít pro uložení" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "Zvolit polohu" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "Nastavit polohu" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "Nejprve se připojte k zařízení" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "Objevování repeaterů v dosahu..." #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "Nalezeny {} regiony" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "Nalezen {} region" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" "Seznam kontaktů v zařízení je plný. Odstraňte některé kontakty, aby bylo " "možné objevit regiony." #: src/views/settings_view.py:973 msgid "No regions found" msgstr "Žádné regiony nenalezeny" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "Žádné repeatery nejsou v dosahu" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "od {}" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "(již přidáno)" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "Dotazování {} repeaterů…" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "Dotazování {} repeateru…" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "Přidány {} regiony" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "Přidán {} region" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "Nepřipojeno" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "Pro export zálohy se nejprve připojte k zařízení." #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "Soubory JSON" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "Záloha úspěšně exportována" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "Export selhal: {}" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "Zařízení nepodporuje export klíče" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "Pro import zálohy se nejprve připojte k zařízení." #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "Před obnovou zálohy se nejdřív připojte k zařízení." #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "Žádné automatické zálohy" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "Žádné automatické zálohy nebyly nalezeny na tomto zařízení." #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "Vyberte snapshot" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Nepodařilo se přečíst soubor zálohy: {}" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "Zdroj: {}" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "{} kontaktů" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "{} kanálů" #: src/views/settings_view.py:1495 msgid "messages" msgstr "zpráv" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "identifikace zařízení" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "Importováno: {}" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "Nic nového k importu" #: src/views/repeater_view.py:149 msgid "room" msgstr "místnost" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "repeater" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "Synchronizace času poslána do {}" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "Resetovat hodiny a restartovat?" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" "Čas je napřed.\n" "Firmware nedovoluje posunout čas zpět.\n" "\n" "Tohle resetuje čas a restartuje zařízení." #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "Restartování…" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "Reset času a restart poslány do {}" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "Požadavek vypršel" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} z {} sousedů" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "Mapa sousedů" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "{} záznam" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "{} záznamů" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "Odebrat z ACL?" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "Odebrat {} ze seznamu řízení přístupu?" #: src/views/repeater_view.py:744 msgid "Role" msgstr "Role" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "Správce" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "Čtení-zápis" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "Pouze čtení" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "Přidat do řízení přístupu" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "Globální (wildcard)" #: src/views/repeater_view.py:875 msgid "Home" msgstr "Domácí" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "Flood povolen" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "Flood odmítán" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "Odmítat flood" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "Povolit flood" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "Nastavit jako domácí" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "Nejdříve odstranit podřízené regiony" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "Neuložené změny" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "Máte neuložené změny v regionech." #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "Zahodit" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "Přidat region" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "Zadejte název a volitelně vyberte rodičovský region." #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "Heslo správce změněno — je potřeba se znovu přihlásit" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "Nastavení odesláno na {}" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "Restartovat {}?" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "Restartovat {}? Bude krátce nedostupný." #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "Čas nesouhlasí" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "Přihlásit se k {}" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "Přihlášení selhalo" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "Sdílená poloha" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "Sdílená" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "Sdílet" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "Poloha není k dispozici" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "Váš companion nemá k dispozici polohu GPS." #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "Poslat automaticky" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "Poslat přímo" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "Poslat floodem" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "Odesílá se" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "Místnost pouze pro čtení" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} z {} kontaktů na mapě" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "{} z {} lokalizovaných na mapě" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "a {} dalších" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "Pozice je ilustrativní - skutečné umístění neznámé" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "SNR: {} dB" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "Vyberte trasovací cestu" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "Trasovací cestu vytvoříte klepáním na repeatery" #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Přidat oblast (např. cz-jmk)" #, python-brace-format #~ msgid "{d}d" #~ msgstr "{d}d" #, python-brace-format #~ msgid "{h}h" #~ msgstr "{h}h" #, python-brace-format #~ msgid "{m}m" #~ msgstr "{m}m" #, python-brace-format #~ msgid "{secs}s" #~ msgstr "{secs}s" #, python-brace-format #~ msgid "{} sec ago" #~ msgid_plural "{} secs ago" #~ msgstr[0] "před {} s" #~ msgstr[1] "před {} s" #~ msgstr[2] "před {} s" #, python-brace-format #~ msgid "{} min ago" #~ msgid_plural "{} min ago" #~ msgstr[0] "před {} minutou" #~ msgstr[1] "před {} minutami" #~ msgstr[2] "před {} minutami" #, python-brace-format #~ msgid "{} hour ago" #~ msgid_plural "{} hours ago" #~ msgstr[0] "před {} hodinou" #~ msgstr[1] "před {} hodinami" #~ msgstr[2] "před {} hodinami" #, python-brace-format #~ msgid "{} day ago" #~ msgid_plural "{} days ago" #~ msgstr[0] "před {} dnem" #~ msgstr[1] "před {} dny" #~ msgstr[2] "před {} dny" #~ msgid "Wrong PIN — try again" #~ msgstr "Špatný PIN — zkuste to znovu" #~ msgid "Pairing cancelled" #~ msgstr "Párování zrušeno" #~ msgid "Pairing failed — could not connect" #~ msgstr "Párování selhalo — nelze se připojit" #~ msgid "Pairing failed" #~ msgstr "Párování selhalo" #~ msgid "Reboot Repeater" #~ msgstr "Restartovat repeater" #~ msgid "Select a repeater" #~ msgstr "Vyberte repeater" #~ msgid "Login and access CLI, status, and settings" #~ msgstr "Přihlásit se a přistoupit k CLI, stavu a nastavení" #~ msgid "{} — Management" #~ msgstr "{} — Správa" #~ msgid "Just now" #~ msgstr "Právě teď" #~ msgid "{}s ago" #~ msgstr "před {}s" #~ msgid "{}m ago" #~ msgstr "před {}m" #~ msgid "{}h ago" #~ msgstr "před {}h" #~ msgid "{}d ago" #~ msgstr "před {}d" #~ msgid "Time sync sent to repeater" #~ msgstr "Synchronizace času odeslána na repeateru" #~ msgid "" #~ "The repeater clock is ahead of the current time.\n" #~ "Firmware does not allow setting time backwards.\n" #~ "\n" #~ "This will reset the clock and reboot the repeater." #~ msgstr "" #~ "Hodiny repeateru jsou napřed oproti aktuálnímu času.\n" #~ "Firmware neumožňuje nastavit čas dozadu.\n" #~ "\n" #~ "Tímto se hodiny resetují a repeater se restartuje." #~ msgid "Clock reset & reboot sent to repeater" #~ msgstr "Reset hodin a restart odeslán na repeater" #~ msgid "Select a contact and role." #~ msgstr "Vyberte kontakt a roli." #~ msgid "Reboot Repeater?" #~ msgstr "Restartovat repeater?" #~ msgid "SNR: {:.1f} dB" #~ msgstr "SNR: {:.1f} dB" #, python-brace-format #~ msgid "{}ms (late)" #~ msgstr "{}ms (pozdě)" #, python-format #~ msgid "%d ms" #~ msgstr "%d ms" #~ msgid "Select Auto Backup" #~ msgstr "Vyberte automatickou zálohu" #, python-brace-format #~ msgid "{contacts} contacts, {channels} channels" #~ msgstr "{contacts} kontaktů, {channels} kanálů" #~ msgid "New features:" #~ msgstr "Nové vlastnosti:" #~ msgid "" #~ "Smart routing — automatic path selection based on success history, " #~ "reliability, and latency scoring" #~ msgstr "" #~ "Chytré směrování — automatický výběr trasy na základě historie " #~ "úspěšnosti, spolehlivosti a hodnocení latence" #~ msgid "" #~ "One-time routing override via long-press/right-click on the Send button" #~ msgstr "" #~ "Jednorázové přepsání trasy dlouhým stisknutím nebo kliknutím pravým " #~ "tlačítkem myši na tlačítko „Odeslat“" #~ msgid "" #~ "Region discovery — scan for nearby repeaters and query them for regions" #~ msgstr "" #~ "Vyhledávání regionů — vyhledání blízkých opakovačů a dotazování se jich " #~ "na regiony" #~ msgid "Custom map tile sources (Ctrl+M)" #~ msgstr "Vlastní zdroje mapových dlaždic (Ctrl+M)" #~ msgid "Map view for routing paths in contact details" #~ msgstr "Zobrazení mapy pro trasy v detailech kontaktu" #~ msgid "Hop count displayed on received channel messages" #~ msgstr "Počet skoků zobrazený v přijatých zprávách kanálu" #~ msgid "Arrival Time sorting option for discovered contacts" #~ msgstr "Řazení podle času přijetí pro objevené kontakty" #~ msgid "New message indicators in the roster" #~ msgstr "Indikátor nových zpráv" #~ msgid "Receive Errors percentage in device stats" #~ msgstr "Procento chyb při příjmu ve statistikách zařízení" #~ msgid "Disconnect shortcut and app menu item" #~ msgstr "Odpojit jako klávesová zkratka a položka v nabídce aplikace" #~ msgid "Experimental macOS support with CoreBluetooth BLE transport" #~ msgstr "Experimentální podpora pro macOS s transportem CoreBluetooth BLE" #~ msgid "Improvements:" #~ msgstr "Vylepšení:" #~ msgid "" #~ "Reworked message deduplication for contact chats — more reliable " #~ "duplicate detection with fewer false positives" #~ msgstr "" #~ "Vylepšená deduplikace zpráv v chatech s kontakty – spolehlivější detekce " #~ "duplicit s menším počtem falešných poplachů" #~ msgid "More reliable BLE connection handling" #~ msgstr "Spolehlivější spojení přes BLE" #~ msgid "Support for non-Linux/macOS unix systems (FreeBSD etc.)" #~ msgstr "Podpora pro nelinuxové a nemacové unixové systémy (jako FreeBSD)" #~ msgid "" #~ "Redesigned routing section in contact details with Classic/Smart strategy " #~ "toggle" #~ msgstr "" #~ "Přepracovaná část věnovaná směrování v kontaktních údajích s přepínačem " #~ "mezi strategiemi Classic a Smart" #~ msgid "Improved search bar UX — search field hidden when not active" #~ msgstr "" #~ "Vylepšené UX vyhledávacího pole — vyhledávací pole se skryje, když není " #~ "aktivní" #~ msgid "" #~ "MeshCore 1.16 protocol support (channel data, default scope, login " #~ "fields, unscoped flood override)" #~ msgstr "" #~ "Podpora protokolu MeshCore 1.16 (data kanálu, výchozí rozsah, " #~ "přihlašovací pole, přepsání flood bez omezení rozsahu)" #~ msgid "" #~ "Updated radio presets — added Australia (Mid/Wide), Australia QLD, " #~ "Brazil, Netherlands; revised existing presets" #~ msgstr "" #~ "Aktualizované předvolby rádia — přidány: Austrálie (Mid/Wide), Austrálie " #~ "(QLD), Brazílie, Nizozemsko; upraveny stávající předvolby" #~ msgid "" #~ "Significant memory consumption optimizations — map tile cleanup, view " #~ "lifecycle management" #~ msgstr "" #~ "Významné optimalizace spotřeby paměti — uvolňování mapových dlaždic, " #~ "správa životního cyklu zobrazení" #~ msgid "" #~ "Code refactoring and performance optimizations across the application" #~ msgstr "Refaktorizace kódu a optimalizace výkonu v celé aplikaci" #~ msgid "New user documentation (Zola-based)" #~ msgstr "Nová uživatelská dokumentace (založená na Zola)" #~ msgid "New translations: French, Spanish, Dutch" #~ msgstr "Nové překlady: francouzština, španělština, nizozemština" #~ msgid "Fixes:" #~ msgstr "Opravy:" #~ msgid "Messages getting stuck on 'Sending' status" #~ msgstr "Zprávy uvíznou ve stavu „Odesílání“" #~ msgid "Contacts with invalid GPS coordinates breaking map views" #~ msgstr "Kontakty s neplatnými GPS souřadnicemi narušují zobrazení mapy" #~ msgid "'Order by last message' not updating after received messages" #~ msgstr "„Seřadit podle poslední zprávy“ se po přijetí zpráv neaktualizuje" #~ msgid "Custom radio settings overwritten by preset detection after save" #~ msgstr "Vlastní nastavení rádia se po uložení přepíše detekcí předvoleb" #~ msgid "Spurious 'Device error: illegal argument' toast on connect" #~ msgstr "" #~ "Neoprávněná zpráva „Chyba zařízení: neplatný argument“ při připojení" #~ msgid "Chat view shifting up when switching focus" #~ msgstr "Při přesunu fokusů se zobrazení chatu posune nahoru" #~ msgid "Map node layering order making nodes invisible" #~ msgstr "Pořadí vrstev uzlů na mapě způsobující jejich skrytí" #~ msgid "ACK rewards incorrectly assigned to retry attempts" #~ msgstr "Odměny za acky chybně přiřazené jiným pokusům" #~ msgid "Missing BLE device import in Linux BLE transport" #~ msgstr "Chybějící import BLE zařízení v transportu Linux BLE" #~ msgid "Broken device connection caused by missing CONTACT_TYPE_ICONS export" #~ msgstr "" #~ "Rozbité spojení se zařízením kvůli chybějícímu exportu CONTACT_TYPE_ICONS" #~ msgid "Unsupported max-width and max-height CSS properties" #~ msgstr "Nepodporované vlastnosti CSS „max-width“ a „max-height“" #~ msgid "Various issues found by static analysis" #~ msgstr "Různé problémy nalezené statickou analýzou" #~ msgid "First stable release of Meshy." #~ msgstr "První stabilní vydání Meshy." #~ msgid "Add from Contacts" #~ msgstr "Přidat z kontaktů" #~ msgid "No repeaters or rooms in contacts" #~ msgstr "V kontaktech nejsou žádné repeatery ani roomy" #~ msgid "Notifications" #~ msgstr "Oznámení" #~ msgid "Save configuration and messages to a file" #~ msgstr "Uložit nastavení a zprávy do souboru" #~ msgid "Device settings: name, radio, location" #~ msgstr "Nastavení zařízení: název, rádio, poloha" #, python-brace-format #~ msgid "Contacts: {} ({} new)" #~ msgstr "Kontakty: {} ({} nových)" #, python-brace-format #~ msgid "Channels: {} ({} new)" #~ msgstr "Kanály: {} ({} nových)" #, python-brace-format #~ msgid "Messages: {}" #~ msgstr "Zprávy: {}" #, python-brace-format #~ msgid "Channel messages: {}" #~ msgstr "Zprávy kanálu: {}" #, python-brace-format #~ msgid "Saved passwords: {}" #~ msgstr "Uložená hesla: {}" #~ msgid "Contacts & Channels Only" #~ msgstr "Pouze kontakty a kanály" #~ msgid "Everything" #~ msgstr "Vše" #~ msgid "Comma-separated hex hops (e.g. 9a,74 or 9a3b,744c for 2-byte)" #~ msgstr "Hex skoky oddělené čárkou (např. 9a,74 nebo 9a3b,744c pro 2bajtové)" #~ msgid "Routing Settings" #~ msgstr "Rozbalovací nabídka Nastavení směrování" #~ msgid "Automatically pick the best route from history" #~ msgstr "Automaticky vybrat nejlepší cesty z historie" #~ msgid "Save to a file" #~ msgstr "Uložit do souboru" #~ msgid "Waiting..." #~ msgstr "Čekání…" #~ msgid "Querying repeaters for regions" #~ msgstr "Dotazování repeaterů na regiony" #~ msgid "Contacts view with messaging" #~ msgstr "Zobrazení kontaktů se zprávami" #~ msgid "Channel list with different channel types" #~ msgstr "Seznam kanálů s různými typy kanálů" #~ msgid "Device information and settings" #~ msgstr "Informace o zařízení a nastavení" #, python-brace-format #~ msgid "Syncing channels: {index}/{total}" #~ msgstr "Synchronizace kanálů: {index}/{total}" #, python-brace-format #~ msgid "Syncing contacts: {current}/{total}" #~ msgstr "Synchronizace kontaktů: {current}/{total}" #~ msgid "Search contacts..." #~ msgstr "Hledat kontakty..." #~ msgid "Auto-add Chat nodes" #~ msgstr "Automaticky přidávat chatovací uzly" #~ msgid "Auto-add Repeaters" #~ msgstr "Automaticky přidávat repeatery" #~ msgid "Auto-add Room Servers" #~ msgstr "Automaticky přidávat room servery" #~ msgid "Auto-add Sensors" #~ msgstr "Automaticky přidávat senzory" #~ msgid "Hop limit for auto-adding" #~ msgstr "Limit skoků pro automatické přidávání" #~ msgid "Paired Companions" #~ msgstr "Spárovaní companioni" #~ msgid "No Device Connected" #~ msgstr "Nepřipojeno žádné zařízení" #~ msgid "Select a companion device from the sidebar to connect." #~ msgstr "Vyberte companiona z postranního panelu pro připojení." #, python-brace-format #~ msgid "retrying {current}/{total}..." #~ msgstr "opakování {current}/{total}…" #~ msgid "Settings sent to companion" #~ msgstr "Nastavení odesláno na companiona" #~ msgid "Backup & Restore" #~ msgstr "Záloha a obnova" meshy/po/da.po000066400000000000000000002220071523412555500135570ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: da\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/de.po000066400000000000000000002235331523412555500135700ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: 2026-07-31 18:25+0000\n" "Last-Translator: bobidle \n" "Language-Team: German \n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 2026.7.1\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "Meshy" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "MeshCore – Mesh-Netzwerk-Client" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "Mesh;LoRa;Funk;Chat;" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" "Meshy ist ein Client für MeshCore-LoRa-Netzwerke. Verbinde dich per " "Bluetooth mit deinem Gerät, um verschlüsselte Nachrichten zu senden, " "Kontakte zu verwalten, Kanäle einzurichten und dein Netzwerk zu überwachen." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "Funktionen:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "Bluetooth LE- und USB-Serial-Verbindung" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "Ende-zu-Ende-verschlüsselte Nachrichten" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "Kontakte" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "Kanäle" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "Karte" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "Einstellungen" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "Verbinden" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "Room-Server" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "Sensoren" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "Regionen" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "Kontakt hinzufügen" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "Nachricht verfassen..." #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "Privaten Kanal erstellen" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "Privaten Kanal beitreten" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "Hashtag-Kanal beitreten" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "Kanal hinzufügen" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "Auf Karte ansehen" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "Name" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "Kontakte entdecken" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "Kontakt teilen" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "Pfad" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "Kontakt importieren" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "importiert" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "Neustart" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" #, fuzzy #~ msgid "New features:" #~ msgstr "Funktionen:" meshy/po/es.po000066400000000000000000003226371523412555500136140ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: 2026-07-30 12:23+0000\n" "Last-Translator: gallegonovato \n" "Language-Team: Spanish \n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 2026.7.1\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "Meshy" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "Cliente de red de malla MeshCore" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "red en malla; LoRa (radio de largo alcance); radio; chat" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" "Meshy es una aplicación para los dispositivos de red en malla MeshCore LoRa. " "Conéctate a tu dispositivo complementario por Bluetooth para enviar y " "recibir mensajes cifrados, gestionar contactos, configurar canales y " "supervisar tu red en malla." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "Características:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "Conectividad Bluetooth LE y puerto serie USB" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "Mensajería cifrada de extremo a extremo" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "Canales públicos, privados y de hashtags" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "Gestión de contactos con seguimiento de la ubicación" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "Vista de mapa interactiva con trazado de rutas" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "Gestión de dispositivos y repetidores" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "Escanea los códigos QR para intercambiar los contactos rápidamente" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "Canales de chat" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "Gestión de repetidores" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "Ajustes - Tema oscuro" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "Vista del mapa con las ubicaciones del contacto" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "General" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "Desconectar" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "Salir" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "Ayuda" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "Atajos del teclado" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "Exploración" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "Dispositivo" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "Contactos" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "Canales" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "Mapa" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "Ajustes" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "Mensajería" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "Buscar contactos" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "Entrada de mensajes destacados" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "Contacto/Canal anterior" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "Contacto/canal siguiente" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "Anteriores sin leer" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "Siguiente sin leer" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "Acerca de Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "Conectar" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "Menú" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "Conectando…" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "Mostrar navegación" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "No conectado/-a" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "Desconectado/-a" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Estado" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "Información del dispositivo" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "Nombre del nodo" #: data/ui/device-view.ui:54 msgid "Board" msgstr "Teclado" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:63 msgid "Update" msgstr "Actualización" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "Open firmware flasher" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "Clave pública" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "Telemetría" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "Solicitar datos de telemetría" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "Sincronizando…" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "Ubicación" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "Ver en el mapa" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "Batería y almacenamiento" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "Batería" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "Almacenamiento" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "Ajustar frecuencias" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "Frecuencia" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "Ancho de banda" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "Factor de ensanchamiento" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "Tasa de codificación" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "Potencia de transmisión" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Tamaño de hash de ruta predeterminado" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "Estadísticas" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "Actualizar estadísticas" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "Ruido de fondo" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "RSSI más reciente" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "SNR más reciente" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "Airtime" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Paquetes" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "Flood (inundación)" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "Privado" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Tiempo de actividad" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Cola de mensajes" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "Aplicación" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "Configurar el comportamiento y la apariencia de la aplicación." #: data/ui/settings-view.ui:20 msgid "Style" msgstr "Estilo" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "Elige la apariencia de la aplicación" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Sistema" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "Claro" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "Oscuro" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "Paleta de colores" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "MeshCore oscuro" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "Notificaciones del canal" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "Nivel de notificación predeterminado para todos los canales" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "Todos los mensajes" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "Solo menciones" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "Ninguno" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "Ejecutarse en segundo plano" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "Sigo recibiendo mensajes después de cerrar la ventana." #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "Inicio automático" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "Se iniciará automáticamente al iniciar sesión" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "Radio" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "Parámetros de radio LoRa y preajustes regionales" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Preajuste regional" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "Configuración preestablecida" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" "Frecuencia , ancho de banda , factor de dispersión , tasa de codificación" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "Frecuencia (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "Ancho de banda (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "Modo de repetición" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "Funciona como un repetidor portátil en una frecuencia fuera de la red." #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "Potencia de transmisión (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "Anuncio" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "Configura cómo se anuncia tu nodo en la malla." #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "Nombre del dispositivo" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "Incluir la ubicación en el anuncio" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "Transmite tu posición a otros nodos." #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "Enrutamiento y mensajería" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "Configuración de enrutamiento de rutas y entrega de mensajes" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "Enrutamiento por defecto" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "Selección de ruta fija o automática" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "Clásico" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "Inteligentes" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "Obtén más información sobre los modos de enrutamiento." #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" "Los hashes más grandes (con más bits o más espacio) distribuyen mejor los " "datos," #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "1 byte (máximo 64 saltos)" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "2 bytes (máximo 32 saltos)" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "3 bytes (máximo 21 saltos)" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "Confirmaciones por mensaje privado" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "Número de ACK enviados al recibir un mensaje privado" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "Establece la posición de tu compañero mediante GPS o manualmente." #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "GPS compañero" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" "Activa el GPS en el dispositivo, tu posición se actualizará automáticamente." #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "Establecer ubicación manualmente" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "Elija otro método para establecer la ubicación, se fijará." #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "Latitud" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "Longitud" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Usar mi ubicación" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "Establecer desde la ubicación de este ordenador" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "Seleccionar en el mapa" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "Seleccione una ubicación en el mapa." #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "Añadir contactos automáticamente" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "Añadir automáticamente nodos descubiertos a través de anuncios." #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "Añadir ajustes automáticamente" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "Tipos de dispositivos, política de sobrescritura, límite de saltos" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "Nodos de chat" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "Repetidores" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "Sala de servidores" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "Sensores" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "Sobrescribir los archivos más antiguos cuando se llenen" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "Límite de saltos" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "0–63, dejar vacío para no tener límite" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "Privacidad de la telemetría" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "Controla lo que otros pueden consultar desde tu dispositivo" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "Batería y sensores" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "Batería, voltaje, temperatura, corriente" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "Coordenadas GPS" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "Ambiente" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "Humedad, presión, calidad del aire" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "Regiones" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" "Limitar el alcance de inundación de los mensajes del canal a regiones " "específicas" #: data/ui/settings-view.ui:421 #, fuzzy msgid "Add region…" msgstr "Añadir una región" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "Obtener las regiones de estos repetidores" #: data/ui/settings-view.ui:430 #, fuzzy msgid "Add region" msgstr "Añadir una región" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "Ámbito predeterminado" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" "Todos los paquetes de inundación se limitarán a esta región si está " "configurada" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" "Después de cambiar el PIN, reinicia el dispositivo, luego desvincula y " "vuelve a vincular. Configurar en 0 restablece el PIN al valor predeterminado." #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "PIN de emparejamiento" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "Conéctalo a un dispositivo complementario para empezar a usar Meshy." #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "Empareja a tu nuevo compañero" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "USB serie" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "WiFi / TCP" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "Añadir un complemento TCP" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Buscar…" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "Sin contactos" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "Añadir contacto" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "Seleccione un contacto" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "Elige un contacto de la lista para empezar a chatear." #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "↑ Nuevos mensajes" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "Escribe un mensaje..." #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "Crear un canal privado" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "Únete a un canal privado" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Únete al canal público" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "Únete a un canal de hashtags" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "No hay canales" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "Añadir canal" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "Seleccione un canal" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "Elige un canal de la lista." #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "Mensaje para el canal..." #: data/ui/repeater-view.ui:37 msgid "System" msgstr "Sistema" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "Reloj al iniciar sesión" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "Sincronizar ahora" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "Restablecer y reiniciar" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "Eventos de error" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "Tiempo de emisión en TX" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "Tiempo de emisión en RX" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "Utilización del canal" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "Ciclo de trabajo" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "Enviado" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "Recibido" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "Errores en la recepción" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "Duplicados" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "Actualizar" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "Solicitar telemetría" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "Comando cli ..." #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "Vecinos" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "Cargar más" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "Mostrar en el mapa" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "Control de acceso" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "Añadir" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "Cargando regiones ..." #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "Reintentar" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "Ámbito regional predeterminado" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" "Los paquetes de inundación se limitarán a la región especificada . Solo los " "repetidores que permitan el acceso a dicha región reenviarán los paquetes " "limitados . Deje este campo en blanco para que no se apliquen paquetes " "limitados." #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "Aplicar" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "Básico" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "Traer" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "Guardar" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "Nombre" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "Repetición de paquetes" #: data/ui/repeater-view.ui:633 #, fuzzy msgid "Guest Access" msgstr "Contraseña de invitado" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "Contraseñas" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "Contraseña de administrador" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "Contraseña de invitado" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "Anuncio" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "Intervalo local (min)" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "Intervalo de inundación (horas)" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "Zona de peligro" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "Reiniciar dispositivo" #: data/ui/repeater-view.ui:890 #, fuzzy msgid "Select a device" msgstr "Seleccione un repetidor" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "Finalizar la sesión" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "Selecciona un sensor" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "Actualizar telemetría" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "Iniciar sesión para acceder como administrador" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "Enviar anuncio" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "Sin saltos" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "Transmitido localmente" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "Desviación de la inundación" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "Transmitido a través de la malla" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "Al portapapeles" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "Copiar mis datos de contacto" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "Mostrar código QR" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "Para que otros puedan escanearte y agregarte" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "Rastrear ruta" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "Traza una ruta y muestra la calidad de la señal en cada salto." #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "Descubre nodos cercanos" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "Escanea la red en busca de nodos cercanos." #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "Recepción de datos" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "Visualización de los paquetes de radio recibidos" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "Borrar registro" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "Sin paquetes" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "Los paquetes de radio recibidos aparecerán aquí." #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "Nivel de señal (en dBm) en los últimos 2 minutos" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "Copia de seguridad" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "Manual" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "Elige qué datos quieres copiar y guárdalos en un archivo" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "Automático" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "Copias de seguridad diarias, que se conservan durante 7 días" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "Restaurar" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "Del archivo" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "Restaurar a partir de una copia de seguridad guardada manualmente" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "Desde la copia de seguridad automática" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "Restaurar a partir de una instantánea automática" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "Restaurar valores de fábrica" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "Borrar todos los datos del dispositivo" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "Copia de seguridad manual" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "Identidad del dispositivo" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "Ajustes del dispositivo" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "Configuración local" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "Lista de contactos" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "Contactos encontrados" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "Mensajes y enrutamiento" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "Mensajes" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "Historial de rutas inteligentes" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "Exportar" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "Copia de seguridad automática" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "Activar copias de seguridad automáticas" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" "Copias de seguridad diarias tras la sincronización, que se conservan durante " "7 días" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "Importar copia de seguridad" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "Descubre tus contactos" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "Buscar" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "Filtrar por tipo" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "Ordenar por" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "Compartir contacto" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Elegir el tema" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "Información del contacto" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "Favorito" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "Fijar en la parte superior de la lista de contactos" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "Tipo" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "Copiar al portapapeles" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "Última conexión" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "Enrutamiento" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "Ruta de salida" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "Restablecer ruta" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "Despeje el camino establecido y cambie a inundación." #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "Rutas disponibles" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "Restablecer la ruta automáticamente" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "Cambiar a envío masivo tras varios intentos fallidos de entrega" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "Inundación forzada" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "Transmita siempre, nunca utilice una ruta establecida." #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "Consultar la telemetría de este contacto" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "Eliminar contacto" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "Ruta" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "Seleccione la ruta en el mapa." #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "Ejecutar rastreo" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "Introduce los hash de ruta y pulsa «Ejecutar rastreo»" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "Tipo de contacto" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "Clave pública (64 caracteres hexadecimales)" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "Abrir en la aplicación Mapas" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "Participantes" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "Filtrar" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "Nombre del canal" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "Mosaicos de mapa personalizados" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "Plantilla del enlace" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "Restablecer valores predeterminados" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "Escuchando..." #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "A la espera de que respondan los nodos cercanos" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "Mostrar las regiones" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "Escaneando…" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "Buscando repetidores cercanos" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "Añadir seleccionados" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (p. ej. #general )" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "Clave privada (32 caracteres hexadecimales)" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "Ubicación del dispositivo" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "Usuarios" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Salas" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "Nodos detectados" #: data/ui/map-view.ui:30 msgid "User" msgstr "Usuario" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "Repetidor" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "Room" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "Sensor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "Rutas de los mensajes" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "Buscar dispositivos MeshCore cercanos" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "Emparejar nuevo compañero" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "Buscando dispositivos MeshCore..." #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "PIN" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "Tu código QR de contacto" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "Escanea este código QR para añadir este contacto" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "Escanear el código QR" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "Apunta la cámara hacia un código QR..." #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "Nombre de la región" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "Principal" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "Contraseña" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Guardar contraseña" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "Acceso" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "Nombre de host / Dirección IP" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "Puerto" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "Deshacer" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "Borrar" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "Hecho" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "Chat" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "Desconocido" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" "Nombre: {name}\n" "Tipo: {type}" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" "Tipo: {type}\n" "Clave: {key}…" #: src/application.py:132 msgid "Import Contact" msgstr "Importar contacto" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "Contacto {}" #: src/application.py:136 msgid "imported" msgstr "importado" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "Enlace meshcore:// no válido" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "Secreto de canal no válido en el enlace" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "Longitud de secreto de canal no válida" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" "Nombre: {name}\n" "Tipo: Privado" #: src/application.py:161 msgid "Unnamed" msgstr "Sin nombre" #: src/application.py:177 msgid "Invalid public key in link" msgstr "Clave pública no válida en el enlace" #: src/application.py:180 msgid "Invalid public key length" msgstr "Longitud de clave pública no válida" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "{} ya aparece en tu lista" #: src/application.py:184 msgid "Contact" msgstr "Contacto" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "Añadir {} contacto" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" "Nombre: {name}\n" "Clave pública: {key}…" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "Contacto {} añadido" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "Enlace «meshcore://» no reconocido" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "Cancelar" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "Recibir mensajes en segundo plano" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" "gallegonovato — español\n" "lebeno — francés, neerlandés\n" "bjawebos — alemán\n" "Manuel Traut — alemán\n" "lejun — francés\n" "LievenBlancke — neerlandés\n" "sesivany — checo" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "Cliente GTK para dispositivos de red mallada MeshCore LoRa" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "Iconos simbólicos de Adwaita" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "Conectando…" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "Buscar dispositivos" #: src/connection_controller.py:160 msgid "Scan" msgstr "Escanear" #: src/connection_controller.py:277 msgid "Stop" msgstr "Detener" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Volviendo a conectar ({current}/{total})}…" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "Sincronizando…" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" "El dispositivo no ha respondido. Es posible que no sea compatible con este " "tipo de conexión." #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "Sincronizando canales" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "Recibiste {} mensaje mientras estabas desconectado." msgstr[1] "Recibiste {} mensajes mientras estabas desconectado." #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "Error del dispositivo: {}" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "Sincronizando contactos" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "{} (tarde)" #: src/frame_handler.py:506 msgid "late" msgstr "tarde" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "El reloj del dispositivo se retrasaba {} minutos — sincronizado" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "El reloj del dispositivo se adelanta {} minutos al del sistema" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "Canal {}" #: src/frame_handler.py:1208 msgid "Someone" msgstr "Alguien" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "A mí" #: src/message_controller.py:218 msgid "flood" msgstr "flood (inundación)" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "privado" #: src/message_controller.py:222 msgid "path" msgstr "ruta" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "en espera {} (radio ocupada)" #: src/window.py:497 msgid "Raster" msgstr "Ráster (o imagen rasterizada)" #: src/window.py:497 msgid "Vector" msgstr "Vector" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" "Utiliza {z}, {x} y {y} como marcadores de posición.\n" "Ejemplo: https://tile.openstreetmap.org/{z}/{x}/{y}.png" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" "Enlace a un archivo JSON al estilo MapLibre/Mapbox.\n" "Ejemplo: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" #: src/window.py:515 msgid "Style JSON URL" msgstr "Enlace del estilo JSON" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "Mosaicos de mapas personalizados aplicados" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "No se han podido cargar los mosaicos del mapa personalizado" #: src/window.py:576 msgid "Sort" msgstr "Clasificar" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "Comportamiento" #: src/window.py:661 msgid "Channel" msgstr "Canal" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "Copia de seguridad y restauración" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:859 msgid "Management" msgstr "Dirección" #: src/window.py:859 msgid "Guest" msgstr "Invitado" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:961 msgid "Excellent" msgstr "Excelente" #: src/window.py:962 msgid "Good" msgstr "Bien" #: src/window.py:963 msgid "Fair" msgstr "Bueno" #: src/window.py:964 msgid "Poor" msgstr "Pobre" #: src/window.py:965 msgid "Very poor" msgstr "Muy pobre" #: src/window.py:1191 msgid "flood routed" msgstr "enrutamiento" #: src/window.py:1191 msgid "zero-hop" msgstr "sin salto" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "Anuncio enviado ( {} )" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Canal «{}» añadido" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "No hay canales libres disponibles" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "No se puede conectar al bus de sesión: {}" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "No se ha detectado ninguna cámara en este sistema." #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "No se ha podido acceder a la cámara: {}" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "Se ha denegado el acceso a la cámara." #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "No se ha podido abrir la transmisión de la cámara." #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "No se ha recibido ningún descriptor de archivo de cámara." #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "No se ha podido abrir la cámara: {}" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Error en el proceso de la cámara: {}" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" "Aún no se ha detectado ningún código QR.\n" "Asegúrate de que el código esté bien iluminado, nítido y ocupe la mayor " "parte del encuadre." #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" "No se ha podido detectar el código QR.\n" "Prueba a importar el contacto desde el portapapeles." #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "¡Encontrado un código QR!" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "Error de la cámara: {}" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "Error de la cámara" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "De acuerdo" #: src/views/__init__.py:52 msgid "Today" msgstr "Hoy" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "Ayer" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "%A, %~d %B" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "%A, %-d de %B de %Y" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "{total} contactos" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contactos" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "Quitar" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "Bluetooth no disponible" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "BLE requiere ejecutarse desde el paquete de la aplicación en macOS" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "Bluetooth desactivado" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "Activa el Bluetooth en los ajustes del sistema para conectarte" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "No hay dispositivos emparejados" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "Utiliza el botón inferior para emparejar un nuevo dispositivo" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "Sin dispositivos USB" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "Conecte un dispositivo MeshCore complementario mediante USB." #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "Eliminar compañero" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" "¿Eliminar {} ({})? Tendrás que volver a emparejarlo para volver a conectarte." #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "Borrar los datos almacenados" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "Error en la conexión usb" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "Acceso al usb denegado" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" "No se puede acceder a {}.\n" "\n" "Se necesita una regla de udev para permitir el acceso a los dispositivos " "serie USB. Haz clic en «Instalar regla» para instalarla (se requiere la " "contraseña de administrador)." #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "Instalar regla" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" "No se puede acceder a {}.\n" "\n" "Se necesita una regla de udev para permitir el acceso a los dispositivos " "serie USB. Ejecuta el siguiente comando en un terminal:\n" "\n" "{}" #: src/views/connection_view.py:280 msgid "Close" msgstr "Cerrar" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "Copiar el comando" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "Comando copiado al portapapeles" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "Error en la instalación" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "No se ha podido instalar la regla de udev:\n" "{}" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "No hay compañeros guardados" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "Utiliza el botón inferior para añadir uno" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "Cambiar la dirección" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "¿Eliminar el complemento TCP {}?" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "Ingrese la nueva dirección para este acompañante." #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "Introduce la dirección IP y el puerto de tu dispositivo MeshCore." #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "Par" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "Introduce el pin de emparejamiento" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "Introduce el pin que aparece en tu dispositivo MeshCore." #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "Nunca" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "Mostrar ruta en el mapa" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "Visualiza la ruta del mensaje en el mapa" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "Permitir solicitudes de telemetría" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" "Permitir que este contacto consulte la batería, el voltaje y la temperatura." #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "Incluir ubicación" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "Incluir las coordenadas GPS en la telemetría" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "Incluir sensores ambientales" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "Incluir datos sobre la humedad, la presión y la calidad del aire" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "Gestionar la sala" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "Gestión de repetidores" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "Estado de inicio de sesión y acceso, CLI, vecinos y configuración" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "Mide el tiempo de respuesta de este nodo." #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "Descubre las rutas" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "Buscar rutas hacia este nodo mediante el algoritmo de inundación" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "Obtener las regiones de este repetidor" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "Consultando…" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "Sin respuesta" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "Clave pública copiada" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "%d rutas disponibles" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "Aún no hay rutas" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "Enrutamiento inteligente— %s" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "Rutas disponibles" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "Resultado: %.2f" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "Weight: %.1f" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "%d saltos" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "Aún no se han registrado rutas." #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "Los caminos se descubren a medida que se intercambian mensajes." #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "Borrar historial" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "Ubicación no disponible" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "Rutas a %s" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "Mi dispositivo" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "¿Eliminar contacto?" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "¿Eliminar {} y todos los mensajes?" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "Añadir manualmente" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "Importar desde el portapapeles" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "Todo" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "Favoritos" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "Escuchado recientemente" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "Últimos mensajes" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "Lo más destacado primero" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "{total} contacto" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" "Aún no se han recibido nuevos contactos . Deja la aplicación abierta y los " "contactos aparecerán a medida que se reciban sus anuncios ." #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "{total} encontrados" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} encontrados" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "Llegada" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "Distancia" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "Introduce los datos de contacto." #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "Sala de servidores" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "Este contacto ya figura en tu lista." #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "Error al importar" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "Sin enlace meshcore:// en el portapapeles." #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "Clave secreta de canal no válida en el código QR." #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "El canal privado debe tener 16 bytes; se ha obtenido {}." #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "Clave pública no válida en el código QR." #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "La clave pública debe tener 32 bytes; se ha obtenido {}." #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "No se han podido analizar los datos del código QR:\n" "{}" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "Código QR escaneado" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "Clave pública: {}...{}" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "Iniciar sesión en {}" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "Inicio de sesión correcto" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "Error al iniciar sesión: contraseña incorrecta" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "Se ha agotado el tiempo de espera de la sesión" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "Volviendo a intentarlo mediante inundación..." #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "Iniciando sesión..." #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "Buscando…" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d salto" msgstr[1] "%d saltos" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "Ruta encontrada" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "Cargando…" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "No se ha almacenado ninguna ruta en caché." #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "No compatible" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "La función ping no es compatible con los hash de ruta de 3 bytes" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: tiempo de espera agotado" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Solicitando..." #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "Sin respuesta (se ha agotado el tiempo de espera)" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Sin telemetría" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "Telemetría recibida" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetría — {}" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "Ruta a {}" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "Repetidor desconocido" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "Repeticiones escuchadas" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "Reenviar" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "Borrar" #: src/views/channels_view.py:151 msgid "Reply" msgstr "Responder" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "Copiar texto" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "Ver rutas de mensajes" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "Compartir ubicación" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "Compartir la ubicación desde el mapa" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "Has compartido un contacto:" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} te ha enviado un contacto:" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "Ya añadido" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "Has compartido tu ubicación:" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} ha compartido una ubicación:" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "Mensajes nuevos" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d repetición" msgstr[1] "· %d repeticiones" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "· %d reenvío" msgstr[1] "· %d reenvíos" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" "No hay información de la ruta disponible. Los datos de ruta solo se " "registran para los mensajes recibidos mientras se está conectado." #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "Mensaje privado" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "No se utilizaron repetidores" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "No hay datos sobre el repetidor" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" "Los datos de la ruta solo se registran a través de LOG_DATA mientras se está " "conectado" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "Ruta del mensaje" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "Aún no se han escuchado repeticiones." #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "Público" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "Hashtag" #: src/views/channels_view.py:1465 msgid "Private" msgstr "Privado" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "{visible}/{total} canales" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "{total} canales" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "{total} canal" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "Información del canal" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "Clave privada" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "Clave privada copiada en el portapapeles" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} participante" msgstr[1] "{} participantes" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "Conservación de mensajes" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "1 día" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "1 semana" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "1 mes" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "3 meses" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "Para siempre" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "Nivel de notificación" #: src/views/channels_view.py:1775 msgid "Default" msgstr "Por defecto:" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "Región" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "Limitar los mensajes de inundación a una región concreta" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "Sinregiones definidas" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "Añade regiones en «Configuración» para asignarlas a los canales" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "Borrar mensajes" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "¿Borrar los mensajes?" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "¿Deseas eliminar todos los mensajes de «{}»?" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "Mensajes eliminados" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "Eliminar el canal" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "¿Eliminar canal?" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "¿Quitar\" {}\" y todos sus mensajes?" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "Bloqueado" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} bloqueo" msgstr[1] "{} bloqueos" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "Desbloquear" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "Bloquear" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} mensaje" msgstr[1] "{} mensajes" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "Desbloqueado {}" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "¿Bloquear {}?" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" "Los mensajes del canal no incluyen la identidad del remitente. Este filtro " "solo se aplica por el nombre; el remitente podría cambiar su nombre para " "eludirlo." #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "Bloquear y borrar mensajes" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "{} bloqueado" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "No hay plazas disponibles" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "Los 8 canales están ocupados." #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" "Se generará una clave PSK aleatoria. Compártela con otros para que puedan " "unirse." #: src/views/channels_view.py:2233 msgid "Create" msgstr "Crear" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" "Introduce el nombre del canal y la clave privada que te han facilitado." #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "¡Únete!" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "Ya me uní" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "¡Ya estás en el canal público!." #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Escribe un nombre para el hashtag. Cualquiera que utilice ese mismo hashtag " "podrá comunicarse." #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "La clave pública del dispositivo no está disponible" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "La biblioteca de códigos QR no está disponible" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "¿Restaurar de fábrica?" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" "Esto borrará de forma permanente TODOS los datos del dispositivo asociado, " "incluidos los contactos, los mensajes, los canales, la configuración y la " "identidad del dispositivo (claves). Esta acción no se puede deshacer." #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "¿Reiniciar el dispositivo?" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "El dispositivo se desconectará y se reiniciará." #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "Reiniciar" #: src/views/device_view.py:327 msgid "Not set" msgstr "Sin configurar" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "Ubicación (configurada manualmente)" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "Ubicación (GPS)" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "Ubicación (GPS activado, sin señal)" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "Ubicación (GPS desactivado)" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "Solicitando…" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "{days}día(s)" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "{hours}hora(s)" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "{mins}minuto(s)" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "{} mensajes" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "{count} error(es)" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "Conectado" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" "La función «Trace Path» no es compatible con los hash de rutas de 3 bytes" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "p. ej. aa,bb,cc" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "p. ej., aabb,ccdd" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "p. ej., aabbcc,ddeeff" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "No hay repetidores cuya ubicación se conozca" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Rastreo completado: {} saltos, {}" #: src/views/device_view.py:689 msgid "Result" msgstr "Resultado" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "El seguimiento ha expirado" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "Valor hexadecimal no válido en la ruta" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "Cargando..." #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Escaneando... Quedan {}segundo(s)" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "Añadir a los contactos" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "Añadido {}" #: src/views/device_view.py:1015 msgid "Node" msgstr "Nodo" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "Hecho. Se han encontrado {} nodos." #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "Hecho. Se ha encontrado el nodo {}." #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} durante los últimos 2 minutos" #: src/views/device_view.py:1301 msgid "Size" msgstr "Tamaño" #: src/views/device_view.py:1301 msgid "bytes" msgstr "bytes" #: src/views/device_view.py:1306 msgid "hops" msgstr "saltos" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "Rutas hash" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "byte por salto" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "Hay una actualización disponible: {version}" #: src/views/settings_view.py:143 msgid "Custom" msgstr "Personalizado" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "Rechazar todo" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "Permitir por contacto" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "Permitir todo" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "Configuración aplicada en el dispositivo" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "Error desconocido" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "Error de configuración: {}" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Potencia de transmisión (dBm, máx. {})" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "Ubicación no disponible" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "El campo ya está rellenado; haz clic en «Aplicar» para guardar" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "Seleccionar ubicación" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "Establecer ubicación" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "Conéctate primero a un dispositivo" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "Obteniendo los repetidores dentro del alcance..." #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "Se encontraron {} regiones" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "Se encontró {} región" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "Sin regiones" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "Sin repetidores en el radio de alcance" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "de {}" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "(ya añadido)" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "Consultando {} repetidores..." #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "Consultando {} repetidor..." #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "Regiones {} añadidas" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "Región {} añadida" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "Sin conexión" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" "Primero conéctese a un dispositivo para exportar una copia de seguridad" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "Archivos json" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "¡Copia de seguridad exportada correctamente!" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "Error al exportar: {}" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "El dispositivo no admite la exportación de claves" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" "Primero conéctese a un dispositivo para importar una copia de seguridad" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" "Conéctate primero a un dispositivo para restaurar una copia de seguridad." #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "No hay copias de seguridad automáticas" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" "No se han encontrado copias de seguridad automáticas para este dispositivo." #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "Seleccionar instantánea" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "No se ha podido leer el archivo de copia de seguridad: {}" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "Fuente: {}" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "{} contactos" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "{} canales" #: src/views/settings_view.py:1495 msgid "messages" msgstr "mensajes" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "identidad del dispositivo" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "Importado: {}" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "No hay nada nuevo que importar" #: src/views/repeater_view.py:149 msgid "room" msgstr "Sala" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "repetidor" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "Sincronización horaria enviada a {}" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "¿Restablecer el reloj y reiniciar?" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" "El reloj está adelantado con respecto a la hora actual.\n" "El firmware no permite retrasar la hora.\n" "\n" "Esto restablecerá el reloj y reiniciará el dispositivo." #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "Reiniciando..." #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "Se ha enviado el reinicio del reloj y el reinicio del sistema a {}" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "La solicitud ha expirado" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} de {} vecinos" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "Mapa del vecindario" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "{} entrada" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "{} entradas" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "¿Eliminar de la lista ACL?" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "¿Eliminar {} de la lista de control de acceso?" #: src/views/repeater_view.py:744 msgid "Role" msgstr "Cargo o función" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "Administración" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "Lectura-Escritura" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "Solo lectura" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "Añadir al control de acceso" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "Global (comodín)" #: src/views/repeater_view.py:875 msgid "Home" msgstr "Inicio" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "Inundación permitida" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "Inundación denegada" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "Rechazar Flood" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "Permitir inundación" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "Establecer como página de inicio" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "Elimina primero las regiones secundarias" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "Cambios sin guardar" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "Tienes cambios en la región sin guardar." #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "Descartar" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "Añadir una región" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "Introduce un nombre y, si lo deseas, selecciona una región principal." #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" "La contraseña de administrador ha cambiado; es necesario iniciar sesión de " "nuevo." #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "Configuración enviada a {}" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "¿Reiniciar {}?" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "¿Reiniciar {}? Estará fuera de servicio durante unos instantes." #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "La hora no es correcta" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "Sesión iniciada en {}" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "Error al iniciar sesión" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "Ubicación compartida" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "Compartida" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "Compartir" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "Ubicación no disponible" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "Tu acompañante no tiene señal de GPS." #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "Enviar automáticamente" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "Enviar directamente" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "Enviar Flood" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "Enviando" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "Sala de solo lectura" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} de {} contactos en el mapa" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "{} de {} en el mapa" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "y {} más" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "La ubicación es meramente ilustrativa; se desconoce la ubicación real" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "Relación señal/ruido: {} dB" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "Seleccione Traceroute" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "Pulsa en los repetidores para trazar la ruta" #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Añadir región (por ejemplo, Praga)" #, python-brace-format #~ msgid "{d}d" #~ msgstr "{d}día(s)" #, python-brace-format #~ msgid "{h}h" #~ msgstr "{h}hora(s)" #, python-brace-format #~ msgid "{m}m" #~ msgstr "{m}minuto(s)" #, python-brace-format #~ msgid "{secs}s" #~ msgstr "{secs}segundo(s)" #, python-brace-format #~ msgid "{} sec ago" #~ msgid_plural "{} secs ago" #~ msgstr[0] "hace {} segundo" #~ msgstr[1] "hace {} segundos" #, python-brace-format #~ msgid "{} min ago" #~ msgid_plural "{} min ago" #~ msgstr[0] "Hace {} minuto" #~ msgstr[1] "Hace {} minutos" #, python-brace-format #~ msgid "{} hour ago" #~ msgid_plural "{} hours ago" #~ msgstr[0] "Hace {} hora" #~ msgstr[1] "Hace {} horas" #, python-brace-format #~ msgid "{} day ago" #~ msgid_plural "{} days ago" #~ msgstr[0] "Hace {} día" #~ msgstr[1] "Hace {} días" #~ msgid "Wrong PIN — try again" #~ msgstr "Código PIN incorrecto. Por favor, inténtelo de nuevo." #~ msgid "Pairing cancelled" #~ msgstr "Emparejamiento cancelado" #~ msgid "Pairing failed — could not connect" #~ msgstr "Error en el emparejamiento: no se ha podido establecer la conexión" #~ msgid "Pairing failed" #~ msgstr "El emparejamiento ha fallado" #~ msgid "Reboot Repeater" #~ msgstr "Reiniciar el repetidor" #~ msgid "Login and access CLI, status, and settings" #~ msgstr "Inicia sesión y accede a la CLI, al estado y a la configuración" #~ msgid "{} — Management" #~ msgstr "{} — Dirección" #~ msgid "Just now" #~ msgstr "Hace un momento" #~ msgid "{}s ago" #~ msgstr "hace {}segundo(s)" #~ msgid "{}m ago" #~ msgstr "hace {}minuto(s)" #~ msgid "{}h ago" #~ msgstr "hace {}hora(s)" #~ msgid "{}d ago" #~ msgstr "hace {}día(s)" #~ msgid "Time sync sent to repeater" #~ msgstr "Sincronización horaria enviada al repetidor" #~ msgid "" #~ "The repeater clock is ahead of the current time.\n" #~ "Firmware does not allow setting time backwards.\n" #~ "\n" #~ "This will reset the clock and reboot the repeater." #~ msgstr "" #~ "El reloj del repetidor está adelantado con respecto a la hora actual.\n" #~ "El firmware no permite retrasar la hora.\n" #~ "\n" #~ "Esto restablecerá el reloj y reiniciará el repetidor." #~ msgid "Clock reset & reboot sent to repeater" #~ msgstr "Reinicio del reloj y reinicio enviados al repetidor" #~ msgid "Select a contact and role." #~ msgstr "Selecciona un contacto y un cargo." #~ msgid "Reboot Repeater?" #~ msgstr "¿Reiniciar el repetidor?" #~ msgid "SNR: {:.1f} dB" #~ msgstr "SNR: {:.1f} dB" #, python-brace-format #~ msgid "{}ms (late)" #~ msgstr "{}ms (tarde)" #, python-format #~ msgid "%d ms" #~ msgstr "%d ms" #~ msgid "Select Auto Backup" #~ msgstr "Seleccionar «Copia de seguridad automática»" #, python-brace-format #~ msgid "{contacts} contacts, {channels} channels" #~ msgstr "{contacts} contactos, {channels} canales" #~ msgid "New features:" #~ msgstr "Nuevas funciones::" #~ msgid "" #~ "Smart routing — automatic path selection based on success history, " #~ "reliability, and latency scoring" #~ msgstr "" #~ "Enrutamiento inteligente: selección automática de rutas basada en el " #~ "historial de éxito, la fiabilidad y la puntuación de latencia" #~ msgid "" #~ "One-time routing override via long-press/right-click on the Send button" #~ msgstr "" #~ "Modificación puntual de la ruta mediante una pulsación prolongada o un " #~ "clic con el botón derecho del ratón sobre el botón «Enviar»" #~ msgid "" #~ "Region discovery — scan for nearby repeaters and query them for regions" #~ msgstr "" #~ "Detección de regiones: busca repetidores cercanos y solicita información " #~ "sobre las regiones" #~ msgid "Custom map tile sources (Ctrl+M)" #~ msgstr "Fuentes de mosaicos de mapa personalizados (Ctrl+M)" #~ msgid "Map view for routing paths in contact details" #~ msgstr "Vista de mapa para las rutas en los datos de contacto" #~ msgid "Hop count displayed on received channel messages" #~ msgstr "Número de saltos que se muestra en los mensajes recibidos por canal" #~ msgid "Arrival Time sorting option for discovered contacts" #~ msgstr "" #~ "Opción de ordenación por «Hora de llegada» para los contactos encontrados" #~ msgid "New message indicators in the roster" #~ msgstr "Indicadores de mensajes nuevos en la lista de contactos" #~ msgid "Receive Errors percentage in device stats" #~ msgstr "" #~ "Porcentaje de errores de recepción en las estadísticas del dispositivo" #~ msgid "Disconnect shortcut and app menu item" #~ msgstr "Desactivar el acceso directo y la entrada del menú de la aplicación" #~ msgid "Experimental macOS support with CoreBluetooth BLE transport" #~ msgstr "" #~ "Compatibilidad experimental con macOS mediante el protocolo de transporte " #~ "BLE de CoreBluetooth" #~ msgid "Improvements:" #~ msgstr "Mejoras:" #~ msgid "" #~ "Reworked message deduplication for contact chats — more reliable " #~ "duplicate detection with fewer false positives" #~ msgstr "" #~ "Se ha rediseñado la deduplicación de mensajes en los chats con contactos: " #~ "detección de duplicados más fiable con menos falsos positivos" #~ msgid "More reliable BLE connection handling" #~ msgstr "Gestión más fiable de la conexión BLE" #~ msgid "Support for non-Linux/macOS unix systems (FreeBSD etc.)" #~ msgstr "" #~ "Compatibilidad con sistemas Unix distintos de Linux y macOS (FreeBSD, " #~ "etc.)" #~ msgid "" #~ "Redesigned routing section in contact details with Classic/Smart strategy " #~ "toggle" #~ msgstr "" #~ "Sección de rutas rediseñada en los datos de contacto con el selector de " #~ "estrategia «Clásica»/«Inteligente»" #~ msgid "Improved search bar UX — search field hidden when not active" #~ msgstr "" #~ "Mejora de la experiencia de usuario de la barra de búsqueda: el campo de " #~ "búsqueda se oculta cuando no está activo" #~ msgid "" #~ "MeshCore 1.16 protocol support (channel data, default scope, login " #~ "fields, unscoped flood override)" #~ msgstr "" #~ "Compatibilidad con el protocolo MeshCore 1.16 (datos del canal, ámbito " #~ "predeterminado, campos de inicio de sesión, anulación de la difusión sin " #~ "ámbito)" #~ msgid "" #~ "Updated radio presets — added Australia (Mid/Wide), Australia QLD, " #~ "Brazil, Netherlands; revised existing presets" #~ msgstr "" #~ "Actualización de las emisoras preprogramadas: se han añadido Australia " #~ "(Mid/Wide), Australia (Queensland), Brasil y Países Bajos; se han " #~ "revisado las emisoras preprogramadas existentes." #~ msgid "" #~ "Significant memory consumption optimizations — map tile cleanup, view " #~ "lifecycle management" #~ msgstr "" #~ "Importantes optimizaciones en el consumo de memoria: limpieza de mosaicos " #~ "de mapas y gestión del ciclo de vida de las vistas" #~ msgid "" #~ "Code refactoring and performance optimizations across the application" #~ msgstr "" #~ "Reestructuración del código y optimizaciones de rendimiento en toda la " #~ "aplicación" #~ msgid "New user documentation (Zola-based)" #~ msgstr "Documentación para nuevos usuarios (basada en Zola)" #~ msgid "New translations: French, Spanish, Dutch" #~ msgstr "Nuevas traducciones: francés, español, neerlandés" #~ msgid "Fixes:" #~ msgstr "Correcciones:" #~ msgid "Messages getting stuck on 'Sending' status" #~ msgstr "Los mensajes se quedan atascados en el estado «Enviando»" #~ msgid "Contacts with invalid GPS coordinates breaking map views" #~ msgstr "" #~ "Los contactos con coordenadas GPS no válidas provocan errores en las " #~ "vistas del mapa" #~ msgid "'Order by last message' not updating after received messages" #~ msgstr "" #~ "La opción «Ordenar por último mensaje» no se actualiza tras recibir " #~ "mensajes" #~ msgid "Custom radio settings overwritten by preset detection after save" #~ msgstr "" #~ "La detección de emisoras predefinidas sobrescribe la configuración " #~ "personalizada de la radio tras guardar los cambios" #~ msgid "Spurious 'Device error: illegal argument' toast on connect" #~ msgstr "" #~ "Aviso erróneo «Error del dispositivo: argumento no válido» al conectarse" #~ msgid "Chat view shifting up when switching focus" #~ msgstr "La vista del chat se desplaza hacia arriba al cambiar el foco" #~ msgid "Map node layering order making nodes invisible" #~ msgstr "" #~ "El orden de las capas de los nodos del mapa hace que algunos nodos sean " #~ "invisibles" #~ msgid "ACK rewards incorrectly assigned to retry attempts" #~ msgstr "Recompensas ACK asignadas erróneamente a los intentos de reintento" #~ msgid "Missing BLE device import in Linux BLE transport" #~ msgstr "" #~ "Falta la importación de dispositivos BLE en el transporte BLE de Linux" #~ msgid "Broken device connection caused by missing CONTACT_TYPE_ICONS export" #~ msgstr "" #~ "Conexión del dispositivo interrumpida debido a la falta de la exportación " #~ "CONTACT_TYPE_ICONS" #~ msgid "Unsupported max-width and max-height CSS properties" #~ msgstr "Propiedades CSS «max-width» y «max-height» no compatibles" #~ msgid "Various issues found by static analysis" #~ msgstr "Diversos problemas detectados mediante el análisis estático" #~ msgid "First stable release of Meshy." #~ msgstr "Primera versión estable de Meshy." #~ msgid "Add from Contacts" #~ msgstr "Añadir desde contactos" #~ msgid "No repeaters or rooms in contacts" #~ msgstr "No hay repetidores ni salas en los contactos" #~ msgid "Notifications" #~ msgstr "Notificaciones" #~ msgid "Save configuration and messages to a file" #~ msgstr "Guarda la configuración y los mensajes en un archivo." #~ msgid "Device settings: name, radio, location" #~ msgstr "Configuración del dispositivo: nombre, radio, ubicación" #, python-brace-format #~ msgid "Contacts: {} ({} new)" #~ msgstr "Contactos: {} ({} nuevo)" #, python-brace-format #~ msgid "Channels: {} ({} new)" #~ msgstr "Canales: {} ({} nuevo(s))" #, python-brace-format #~ msgid "Messages: {}" #~ msgstr "Mensajes: {}" #, python-brace-format #~ msgid "Channel messages: {}" #~ msgstr "Mensajes del canal: {}" #, python-brace-format #~ msgid "Saved passwords: {}" #~ msgstr "Contraseña(s) guardada(s): {}" #~ msgid "Contacts & Channels Only" #~ msgstr "Solo contactos y canales" #~ msgid "Everything" #~ msgstr "Todo" #~ msgid "Comma-separated hex hops (e.g. 9a,74 or 9a3b,744c for 2-byte)" #~ msgstr "" #~ "Saltos hexadecimales separados por comas (por ejemplo, 9a,74 o 9a3b,744c " #~ "para 2 bytes)" #~ msgid "Routing Settings" #~ msgstr "Configuración de rutas" #~ msgid "Automatically pick the best route from history" #~ msgstr "Seleccionar automáticamente la mejor ruta a partir del historial" #~ msgid "Save to a file" #~ msgstr "Guardar en un archivo" #~ msgid "Waiting..." #~ msgstr "Esperando..." #~ msgid "Querying repeaters for regions" #~ msgstr "Consultando repetidores por regiones" #~ msgid "Contacts view with messaging" #~ msgstr "Vista de contactos con mensajería" #~ msgid "Channel list with different channel types" #~ msgstr "Lista de canales con varios tipos de canales" #~ msgid "Device information and settings" #~ msgstr "Información y ajustes del dispositivo" meshy/po/et.po000066400000000000000000002220071523412555500136030ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: et\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/fi.po000066400000000000000000002220071523412555500135710ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: fi\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/fr.po000066400000000000000000002731601523412555500136100ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: 2026-07-25 10:07+0000\n" "Last-Translator: LievenBlancke \n" "Language-Team: French \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" "X-Generator: Weblate 2026.6.1\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "Meshy" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "MeshCore client de réseau maillé" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "mesh;lora;radio;chat;" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" "Meshy est un client pour les appareils MeshCore LoRa en réseau maillé. " "Connectez-vous à votre appareil compagnon via Bluetooth pour envoyer et " "recevoir des messages chiffrés, gérer les contacts, configurer les canaux et " "surveiller votre réseau maillé." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "Caractéristiques:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "Connectivité Bluetooth LE et USB série" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "Messagerie chiffrée de bout en bout" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "Canaux publics, privés et hashtag" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "Gestion des contacts avec suivi de localisation" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "Vue cartographique interactive avec traçage d'itinéraire" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "Gestion des appareils et des répéteurs" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "Scan de code QR pour échange rapide de contacts" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 #, fuzzy msgid "Channel chats" msgstr "Canaux" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 #, fuzzy msgid "Repeater management" msgstr "Gestion des appareils et des répéteurs" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "Vue cartographique affichant les positions des contacts" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "Général" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "Déconnecter" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "Quitter" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "Raccourcis clavier" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "Navigation" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "Appareil" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "Contacts" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "Canaux" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "Carte" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "Paramètres" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "Messagerie" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "Rechercher des Contacts" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "Concentrer Saisie du Message" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "Contact/Canal Précédent" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "Contact/Canal Suivant" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "Non Lu Précédent" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "Non Lu Suivant" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "À propos de Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "Connecter" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "Menu" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "De Liaison…" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "Afficher la Navigation" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "Non Connecté" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "Déconnecté" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Statut" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "Informations sur l'Appareil" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "Nom du Nœud" #: data/ui/device-view.ui:54 msgid "Board" msgstr "Carte" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:63 msgid "Update" msgstr "Mise à Jour" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "Ouvrir le flasheur du firmware" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "Clé Publique" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "Télémétrie" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "Demande de télémétrie" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "Synchronisation…" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "Position" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "Afficher sur la carte" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "Batterie & Stockage" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "Batterie" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "Stockage" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "Configuration Radio" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "Fréquence" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "Débit" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 #, fuzzy msgid "Spreading Factor" msgstr "Facteur d'Étalement" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "Puissance TX" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Taille Hash Chemin Défaut" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "Statistiques" #: data/ui/device-view.ui:220 #, fuzzy msgid "Refresh statistics" msgstr "Rafraîchir les statistiques" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "Plancher de Bruit" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "Dernier RSSI" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "Dernier SNR" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "Temps d'Antenne" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Paquets" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "Direct" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Temps de Disponibilité" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Liste de messages" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "Application" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "Configurer le comportement et l'apparence de l'application" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "Thème" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "Choisir l'apparence de l'application" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Thème du système" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "Clair" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "Sombre" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "Palette du thème" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "Meshcore Dark" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "Notifications de Canal" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "Niveau de notification par défaut pour tous les canaux" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "Tous les messages" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "Mentions uniquement" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "Aucun" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "Actif en arrière-plan" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "Continuer de recevoir des messages après la fermeture de la fenêtre" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "Démarrage automatique" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "Démarrage automatique à la connexion" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "Radio" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "Paramètres LoRa et pré-réglages régionaux" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Pré-réglages régionaux" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "Configuration de Préréglage" #: data/ui/settings-view.ui:90 #, fuzzy msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "Fréquence, Débit, facteur d'étalement, taux de codage" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "Fréquence (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "Débit (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "Mode répéteur" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "Utiliser comme répéteur portatif sur une fréquence dédiée" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "Puissance TX (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "Annonce" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "Configurer comment votre nœud s'annonce sur le maillage" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "Nom de l'appareil" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "Inclure Position dans Annonce" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "Diffusez votre position aux autres nœuds" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 #, fuzzy msgid "Default Routing" msgstr "Portée par Défaut" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "Classique" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "1 octet (max 64 sauts)" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "2 octets (max 32 sauts)" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "3 octets (max 21 sauts)" #: data/ui/settings-view.ui:220 #, fuzzy msgid "Direct Message Confirmations" msgstr "Configuration de Préréglage" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "Indiquer la position de l'appareil par GPS ou manuellement" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "GPS de l'appareil" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" "Activer le GPS de l'appareil, la position sera mise à jour automatiquement" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "Indiquer la position manuellement" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "Choisir une autre méthode pour définir la position, elle sera fixe" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "Latitude" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "Longitude" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Utiliser ma position" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "Utiliser la position de l'ordinateur" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "Indiquer sur une carte" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "Choisir une position sur la carte" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "Ajout Automatique de Contacts" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "Ajouter automatiquement les nœuds découverts via les annonces" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "Paramètres Ajout Automatique" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "Types d'Appareil, politique de remplacement, limite de saut" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "Nœuds de Chat" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "Répéteurs" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "Serveurs de Salon" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "Capteurs" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "Écraser le plus ancien lorsque pleine" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "Limite de sauts" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "0-63, vide pour ne pas imposer de limite" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "Confidentialité de la Télémétrie" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "Contrôlez ce que les autres peuvent interroger depuis votre appareil" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "Batterie & Capteurs" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "Batterie, tension, température, courant" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "Coordonnées GPS" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "Environnement" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "Humidité, pression, qualité de l'air" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "Régions" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" "Limiter la portée Flood des messages de canal à des régions spécifiques" #: data/ui/settings-view.ui:421 #, fuzzy msgid "Add region…" msgstr "Ajouter Région" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 #, fuzzy msgid "Add region" msgstr "Ajouter Région" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "Portée par Défaut" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" "Tous les paquets Flood auront pour portée cette région si elle est définie." #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" "Après avoir changé le code PIN, redémarrez l'appareil, puis désappairez et " "réappairez. La valeur 0 réinitialise le code PIN à la valeur par défaut." #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "Code PIN d'Appariement" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" "Connectez-vous à un appareil compagnon pour commencer à utiliser Meshy." #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "Associer à un Nouveau Compagnon" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "Série USB" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "WiFi / TCP" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "Ajouter un Compagnon TCP" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 #, fuzzy msgid "Search..." msgstr "Recherche..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "Aucun contact" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "Ajouter un Contact" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "Sélectionner un Contact" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "Choisissez un contact dans la liste pour commencer à discuter" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "↑ Nouveaux Messages" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "Tapez un message..." #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "Créer un Canal Privé" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "Rejoindre un Canal Privé" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Rejoindre le Canal Public" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "Rejoindre un Canal Hashtag" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "Aucun Canal" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "Ajouter un Canal" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "Sélectionner un Canal" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "Choisissez un canal dans la liste" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "Canal de message..." #: data/ui/repeater-view.ui:37 msgid "System" msgstr "Système" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "Horloge à la Connexion" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "Synchroniser Maintenant" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "Réinitialiser et Redémarrer" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "Événements d'Erreur" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "Temps d'Antenne TX" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "Temps d'Antenne RX" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "Utilisation du Canal" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "Cycle d'utilisation" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "Envoyé" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "Reçu" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "Erreurs de Réception" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "Doublons" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "Actualiser" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "Demander la Télémétrie" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "Commande CLI..." #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "Voisins" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "Charger plus" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "Afficher sur la Carte" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "Contrôle d'Accès" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "Ajouter" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "Chargement des Régions..." #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "Réessayer" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "Région par défaut" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" "Les paquets Flood auront pour portée la région fournie. Seuls les répéteurs " "autorisant la région transmettront les paquets avec cette portée. Laisser " "vide pour une portée non définie." #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "Appliquer" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "Basique" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "Récupérer" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "Sauvegarder" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "Nom" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "Répétition de Paquet" #: data/ui/repeater-view.ui:633 #, fuzzy msgid "Guest Access" msgstr "Mot de passe invité" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "Mots de passe" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "Mot de passe administration" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "Mot de passe invité" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "Annonce" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "Intervalle local (min)" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "Intervalle Flood(hrs)" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "Zone de danger" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "Redémarrer l'Appareil" #: data/ui/repeater-view.ui:890 #, fuzzy msgid "Select a device" msgstr "Choisir un répéteur" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "Déconnexion" #: data/ui/sensor-view.ui:14 #, fuzzy msgid "Select a Sensor" msgstr "Choisir un répéteur" #: data/ui/sensor-view.ui:76 #, fuzzy msgid "Refresh Telemetry" msgstr "Demander la Télémétrie" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "Envoyer Annonce" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "Zéro Saut" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "Diffuser Localement" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "Routé en Flood" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "Diffusé via le maillage" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "Copier dans le Presse-Papiers" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "Copier mes données de contact" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "Afficher le Code QR" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "Pour que d'autres puissent scanner et vous ajouter" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "Tracer Chemin" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "Tracer une route et afficher la qualité du signal par saut" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "Découvrir les Nœuds à Proximité" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "Analysez le réseau pour les nœuds à proximité" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "Journal Rx" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "Afficher les paquets radio reçus" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "Effacer le Journal" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "Aucun Paquet" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "Les paquets radio reçus s'afficheront ici" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "Sauvegarde" #: data/ui/settings-sidebar.ui:23 #, fuzzy msgid "Manual" msgstr "Ajouter Manuellement" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" "Choisissez ce que vous souhaitez sauvegarder et enregistrer dans un fichier" #: data/ui/settings-sidebar.ui:36 #, fuzzy msgid "Automatic" msgstr "Démarrage automatique à la connexion" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "Restauration" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 #, fuzzy msgid "Restore from a manually saved backup" msgstr "Restauration depuis un fichier de sauvegarde" #: data/ui/settings-sidebar.ui:72 #, fuzzy msgid "From Auto Backup" msgstr "Sauvegarde Automatique" #: data/ui/settings-sidebar.ui:73 #, fuzzy msgid "Restore from an automatic snapshot" msgstr "Restauration depuis un fichier de sauvegarde" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "Paramètres d'usine" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "Efface toutes les données de l'appareil" #: data/ui/manual-backup-dialog.ui:8 #, fuzzy msgid "Manual Backup" msgstr "Sauvegarde" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 #, fuzzy msgid "Device Identity" msgstr "Informations sur l'Appareil" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 #, fuzzy msgid "Device Settings" msgstr "Paramètres" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 #, fuzzy msgid "Local Settings" msgstr "Paramètres" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 #, fuzzy msgid "Contact List" msgstr "Contacts" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 #, fuzzy msgid "Discovered Contacts" msgstr "Découvrir Les Contacts" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 #, fuzzy msgid "Messages and Routing" msgstr "Chemin du Message" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 #, fuzzy msgid "Messages" msgstr "Tous les messages" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 #, fuzzy msgid "Smart Route History" msgstr "Sélection d'Itinéraire Intelligente" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "Sauvegarde Automatique" #: data/ui/auto-backup-dialog.ui:35 #, fuzzy msgid "Enable Automatic Backups" msgstr "Sauvegarde Automatique" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 #, fuzzy msgid "Import Backup" msgstr "Importer un Contact" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "Découvrir Les Contacts" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "Recherche" #: data/ui/discover-dialog.ui:27 #, fuzzy msgid "Filter by type" msgstr "Filtre" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 #, fuzzy msgid "Sort by" msgstr "Tri" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "Partager le Contact" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Choisir un thème" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "Informations de Contact" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "Préféré" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "Épingler en haut de la liste de contacts" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "Type" #: data/ui/contact-detail-dialog.ui:68 #, fuzzy msgid "Copy to clipboard" msgstr "Commande copiée dans le presse-papiers" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "Dernière connexion" #: data/ui/contact-detail-dialog.ui:89 #, fuzzy msgid "Routing" msgstr "Paramètres de routage" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "Chemin Sortant" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "Réinitialiser Chemin" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "Effacer le chemin établi et passer au flood" #: data/ui/contact-detail-dialog.ui:148 #, fuzzy msgid "Available Routes" msgstr "Chemins Disponibles" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "Réinitialiser Automatiquement le Chemin" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 #, fuzzy msgid "Force Flood" msgstr "Flood" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "Toujours diffuser, ne jamais utiliser un chemin établi" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "Interroger les données télémétriques de ce contact" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "Supprimer le Contact" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "Chemin" #: data/ui/trace-path-dialog.ui:40 #, fuzzy msgid "Choose route on the map" msgstr "Choisir une position sur la carte" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "Exécuter la Trace" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "Entrez les hachages de chemin et appuyez sur Exécuter Traçage" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "Type de Contact" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "Clé Publique (64 caractères hexadécimaux)" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "Ouvrir dans l'Application Cartes" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "Filtre" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "Nom du Canal" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 #, fuzzy msgid "Reset to Default" msgstr "Réinitialiser Chemin" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "Écoute en cours..." #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "En attente de la réponse des nœuds à proximité" #: data/ui/discover-regions-dialog.ui:8 #, fuzzy msgid "Discover Regions" msgstr "Découvrir Les Contacts" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "Scanner..." #: data/ui/discover-regions-dialog.ui:61 #, fuzzy msgid "Listening for nearby repeaters" msgstr "Recherche d'appareils MeshCore à proximité" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "Ajouter Sélection" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 #, fuzzy msgid "Private Key (32 hex characters)" msgstr "Clé Publique (64 caractères hexadécimaux)" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "Position Appareil" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "Utilisateurs" #: data/ui/map-sidebar-filters.ui:39 #, fuzzy msgid "Rooms" msgstr "Salon" #: data/ui/map-sidebar-filters.ui:65 #, fuzzy msgid "Discovered Nodes" msgstr "Découvrir les Nœuds à Proximité" #: data/ui/map-view.ui:30 #, fuzzy msgid "User" msgstr "Utilisateurs" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "Répéteur" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "Salon" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "Capteur" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "Chemins des Messages" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "Recherche d'appareils MeshCore à proximité" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "Associer un Nouveau Compagnon" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "Recherche de périphériques MeshCore..." #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "PIN" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "Votre Code QR de Contact" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "Scannez ce code QR pour ajouter ce contact." #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "Scanner le code QR" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "Etiquette d'état" #: data/ui/region-add-content.ui:19 #, fuzzy msgid "Region Name" msgstr "Régions" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "Mot de Passe" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Sauvegarder le mot de passe" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "Se Connecter" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "Nom d'Hôte/Adresse IP" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "Port" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "Effacer" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "Tchat" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "Inconnu" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" "Nom : {name}\n" "Type : {type}" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" "Type : {type}\n" "Clé : {key}" #: src/application.py:132 msgid "Import Contact" msgstr "Importer un Contact" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "Contact {}" #: src/application.py:136 msgid "imported" msgstr "importé" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "Lien meshcore:// non valide" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "Secret du canal invalide dans le lien" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "Secret du canal de longueur invalide" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" "Nom : {name}\n" "Type : Privé" #: src/application.py:161 msgid "Unnamed" msgstr "Sans nom" #: src/application.py:177 msgid "Invalid public key in link" msgstr "Clé publique du lien non valide" #: src/application.py:180 msgid "Invalid public key length" msgstr "Longueur de la clé publique non valide" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "{} est déjà dans votre liste" #: src/application.py:184 msgid "Contact" msgstr "Contact" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "Ajouter {} Contact" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" "Nom: {name}\n" "Clé Publique: {key}…" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "Contact {} ajouté" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "Lien meshcore:// non reconnu" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "Annuler" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "Recevoir des messages en arrière-plan" #: src/application.py:430 #, fuzzy msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" "gallegonovato — Espagnol\n" "lebeno — Français, Néerlandais\n" "bjawebos — Allemand\n" "Manuel Traut — Allemand\n" "lejun — Français\n" "LievenBlancke — Néerlandais" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "Un client GTK pour les appareils de réseau maillé MeshCore LoRa" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "Icônes Symboliques Adwaita" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "Connecter..." #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "Rechercher des Appareils" #: src/connection_controller.py:160 msgid "Scan" msgstr "Scanner" #: src/connection_controller.py:277 msgid "Stop" msgstr "Arrêter" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Reconnexion ({current}/{total})..." #: src/connection_controller.py:301 msgid "Syncing..." msgstr "Synchronisation..." #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" "L'appareil n'a pas répondu. Il se peut qu'il ne prenne pas en charge ce type " "de connexion." #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "Synchronisation des canaux" #: src/connection_controller.py:594 #, fuzzy, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "Vous avez reçu {} message alors que vous étiez déconnecté." msgstr[1] "Vous avez reçu {} messages alors que vous étiez déconnecté." #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "Erreur de l'appareil: {}" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "Synchronisation des contacts" #: src/frame_handler.py:506 #, fuzzy, python-brace-format msgid "{} (late)" msgstr "{}s (en retard)" #: src/frame_handler.py:506 msgid "late" msgstr "en retard" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "L'horloge de l'appareil était en retard de {}min — synchronisée" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "L'horloge de l'appareil est en avance de {}min sur celle du système" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "Canal {}" #: src/frame_handler.py:1208 msgid "Someone" msgstr "Une personne" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender} : {text}" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "Moi" #: src/message_controller.py:218 msgid "flood" msgstr "Flood" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "direct" #: src/message_controller.py:222 msgid "path" msgstr "chemin" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:285 #, fuzzy, python-brace-format msgid "waiting {} (radio busy)" msgstr "en attente {:.1f}s (radio occupée)" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "Tri" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "Actions" #: src/window.py:661 msgid "Channel" msgstr "Canal" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "Sauvegarde & restauration" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "Gestion" #: src/window.py:859 msgid "Guest" msgstr "Invitée" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:961 msgid "Excellent" msgstr "Excellent" #: src/window.py:962 msgid "Good" msgstr "Bien" #: src/window.py:963 msgid "Fair" msgstr "Passable" #: src/window.py:964 msgid "Poor" msgstr "Mauvais" #: src/window.py:965 msgid "Very poor" msgstr "Très Mauvais" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "saut zéro" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "Annonce envoyée ({})" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Canal \"{}\" ajouté" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "Aucun emplacement de canal vide disponible" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "Impossible de se connecter à un bus session : {}" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "Aucune caméra détectée sur ce système." #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "Impossible de se connecter à la caméra : {}" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "Permission d'accès à la caméra refusée" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "Impossible d'ouvrir un flux caméra." #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "Aucun descripteur de fichier de caméra reçu." #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "Impossible d'ouvrir la caméra : {}" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Erreur de pipeline de caméra: {}" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" "Aucun code QR détecté pour le moment.\n" "Assurez-vous que le code est bien éclairé, net et occupe la majeure partie " "du cadre." #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" "Impossible de détecter le code QR.\n" "Essayez d'importer le contact via le presse-papiers à la place." #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "QR code détecté!" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "Erreur de caméra: {}" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "Erreur de Caméra" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "OK" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "%A %-d %B" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "%A %-d %B %Y" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "{total} contacts" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contacts" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "Retirer" #: src/views/connection_view.py:80 #, fuzzy msgid "Bluetooth not available" msgstr "Le Bluetooth est désactivé" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "Le Bluetooth est désactivé" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "Activer Bluetooth dans les paramètres système pour se connecter" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "Aucun appareil couplé" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "Utilisez le bouton ci-dessous pour associer un nouveau compagnon" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "Aucun appareil USB détecté" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "Connecter un compagnon MeshCore via USB" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "Supprimer un Compagnon" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" "Retirer {} ({})? Vous devrez effectuer un nouveau couplage pour vous " "reconnecter." #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "Supprimer les données stockées" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "Échec de la Connexion USB" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "Autorisation USB Refusée" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" "Impossible d'accéder à {}. \n" "\n" "Une règle udev est nécessaire pour accorder l'accès aux périphériques série " "USB. Cliquez sur \"Installer la Règle\" pour l'installer (nécessite un mot " "de passe administrateur)." #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "Installer la Règle" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" "Impossible d'accéder à {}. \n" "\n" "Une règle udev est nécessaire pour accorder l'accès aux périphériques série " "USB. Exécutez la commande suivante dans un terminal : \n" "\n" "{}" #: src/views/connection_view.py:280 msgid "Close" msgstr "Fermer" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "Copier la Commande" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "Commande copiée dans le presse-papiers" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "Échec de l'Installation" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Impossible d'installer la règle udev:\n" "{}" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "Aucuns compagnons sauvegardés" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "Utilisez le bouton ci-dessous pour en ajouter un" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "Changer l'Adresse" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "Supprimer le compagnon TCP {}?" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "Entrez l'adresse IP et le port de votre appareil MeshCore." #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "Associer" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "Entrer le Code PIN d'Appairage" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "Entrez le code PIN affiché sur votre appareil MeshCore." #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "Jamais" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "Afficher Chemin sur la Carte" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "Visualiser l'itinéraire du message sur la carte" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "Autoriser les Demandes de Télémétrie" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" "Autoriser ce contact à interroger la batterie, la tension et la température" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "Inclure Position" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "Inclure les coordonnées GPS dans les réponses de télémétrie" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "Inclure les Capteurs d'Environnement" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "Inclure l'humidité, la pression, les données de qualité de l'air" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "Gestion de Salle" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "Gestion de Répéteur" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "Se connecter et accéder à statut, CLI, voisins et paramètres" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "Mesurer le temps aller-retour vers ce nœud" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "Découvrir Chemins" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "Trouver des itinéraires vers ce nœud via Flood" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 #, fuzzy msgid "Querying..." msgstr "Recherche..." #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "Aucune réponse" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "Clé publique copiée" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "%d chemins disponibles" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "Aucun chemin pour le moment" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "Itinéraires Intelligents — %s" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "Chemins Disponibles" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "Poids: %.1f" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "%d sauts" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "Aucun chemin enregistré pour le moment" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" "Les chemins sont découverts au fur et à mesure que les messages sont échangés" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "Effacer le Journal" #: src/views/contact_detail_dialog.py:591 #, fuzzy msgid "No location data available" msgstr "Localisation non disponible" #: src/views/contact_detail_dialog.py:595 #, fuzzy, python-format msgid "Routes to %s" msgstr "Itinéraires Intelligents — %s" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "Mon Appareil" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "Supprimer le Contact?" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "Supprimer {} et tous les messages?" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "Ajouter Manuellement" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "Importer depuis le Presse-papiers" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "Tout" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "Favoris" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "Entendu Récemment" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "Derniers Messages" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "Favoris d'Abord" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "{total} contact" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" "Aucun nouveau contact n'a encore été détecté. Laissez l'application " "fonctionner et les contacts apparaîtront au fur et à mesure que leurs " "annonces seront reçues." #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "{total} découvert" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} découvert" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "Heure d'Arrivée" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "Distance" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "Entrez les détails du contact." #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "Serveur de Salle" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "Ce contact est déjà dans votre liste." #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "Échec de l'Importation" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "Aucun lien meshcore:// trouvé dans le presse-papiers." #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "Secret de chaîne invalide dans le code QR." #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "Secret du canal doit être de 16 octets, reçu {}." #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "Clé publique invalide dans le code QR." #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "La clé publique doit faire 32 octets, j'ai obtenu {}." #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Impossible d'analyser les données du code QR:\n" "{}" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "Code QR scanné" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "Clé publique: {}...{}" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "Se connecter à {}" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "Connexion réussie" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "Échec de la connexion — mot de passe incorrect" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "La connexion a expiré" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "Nouvelle tentative via Flood..." #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "Connexion en cours..." #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "Recherche..." #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, fuzzy, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d sauts" msgstr[1] "%d sauts" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "Chemin trouvé" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "Chargement..." #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "Pas de chemin en cache" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "Non pris en charge" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "Ping n'est pas supporté avec les hachages de chemin de 3 octets" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: délai expiré" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Demander..." #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "Aucune réponse (délai expiré)" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Aucune donnée de télémétrie" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "Télémétrie reçue" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "Télémétrie — {}" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "Chemin vers {}" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "Répéteur inconnu" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "Répétitions Entendues" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "Renvoyer" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "Supprimer" #: src/views/channels_view.py:151 msgid "Reply" msgstr "Répondre" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "Copier le Texte" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "Afficher Chemins des Messages" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "Partager la Localisation" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "Partager la Localisation Depuis la Carte" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "Vous avez partagé un contact:" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} a partagé un contact avec vous:" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "Déjà ajouté" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "Vous avez partagé une localisation:" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} a partagé une localisation:" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "Nouveaux Messages" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" "Aucune information de chemin disponible. Les données de chemin sont " "uniquement capturées pour les messages reçus lors de la connexion." #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "Message direct" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "Aucun répéteur n'a été utilisé" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "Aucun détail de répéteur" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" "Les données de chemin sont uniquement capturées via LOG_DATA lors de la " "connexion" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "Chemin du Message" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "Aucune répétition entendue pour le moment." #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "Public" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "{visible}/{total} canaux" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "{total} canaux" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "{total} canal" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "Informations du Canal" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 #, fuzzy msgid "Message Retention" msgstr "Liste de messages" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "1 Jour" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "1 Semaine" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "1 Mois" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "3 Mois" #: src/views/channels_view.py:1754 #, fuzzy msgid "Forever" msgstr "Jamais" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 #, fuzzy msgid "Clear Messages" msgstr "Tous les messages" #: src/views/channels_view.py:1860 #, fuzzy msgid "Clear Messages?" msgstr "Tous les messages" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 #, fuzzy msgid "Messages cleared" msgstr "Liste de messages" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "Supprimer le Canal" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "Supprimer le Canal?" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "Bloqué" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "Bloquer" #: src/views/channels_view.py:2077 #, fuzzy, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} messages" msgstr[1] "{} messages" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "Bloquer {}?" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" "Les messages du canal n'incluent pas l'identité de l'expéditeur. Ce bloc " "correspond uniquement par nom — l'expéditeur peut modifier son nom pour le " "contourner." #: src/views/channels_view.py:2147 #, fuzzy msgid "Block and Delete Messages" msgstr "Derniers Messages" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "{} Bloqué" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "Tous les 8 emplacements de canal sont utilisés." #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "Entrez le nom du canal et la clé privée qui vous a été partagée." #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 #, fuzzy msgid "Already Joined" msgstr "Déjà Inscrit" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "Vous êtes déjà sur le canal public." #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "Clé publique de l'appareil non disponible" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "Bibliothèque de code QR non disponible" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "Réinitialisation d'Usine?" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" "Cela effacera définitivement TOUTES les données de l'appareil compagnon, y " "compris les contacts, les messages, les canaux, les paramètres et l'identité " "de l'appareil (clés). Cette action est irréversible." #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "Redémarrer l'Appareil?" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "L'appareil se déconnectera et redémarrera." #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "Redémarrer" #: src/views/device_view.py:327 msgid "Not set" msgstr "Non configuré" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "Position (définie manuellement)" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "Position (GPS)" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "Position (GPS activé, pas de fix)" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "Position (GPS désactivé)" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "Demande en cours…" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "{days}j" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "{} messages" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "{count} erreurs" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "Connecté" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" "Traçage de Chemin n'est pas supporté avec les hachages de chemin de 3 octets" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "p. ex. aa,bb,cc" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "p. ex. aabb,ccdd" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "p. ex. aabbcc,ddeeff" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "Aucun répéteur avec position connue" #: src/views/device_view.py:683 #, fuzzy, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trace terminée: {} sauts, {:.1f}s" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "La trace a expiré" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "Hex invalide dans le chemin" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "Traçage en cours..." #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Analyse en cours... {} s restant" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "Ajouter aux Contacts" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "Ajouté {}" #: src/views/device_view.py:1015 msgid "Node" msgstr "Nœud" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "Terminé. {} nœuds trouvés." #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "Terminé. {} nœud trouvé." #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "Taille" #: src/views/device_view.py:1301 msgid "bytes" msgstr "octets" #: src/views/device_view.py:1306 msgid "hops" msgstr "sauts" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "Hachages de Chemin" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "octet par saut" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "Mise à jour disponible: {version}" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "Autoriser par Contact" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "Tout Autoriser" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "Paramètres appliqués sur l'appareil" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "Erreur Paramètres: {}" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Puissance TX (dBm, max {})" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "Position indisponible" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "Position remplie, cliquer sur Appliquer pour sauvegarder" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "Choisir Position" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "Définir Position" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 #, fuzzy msgid "Discovering repeaters in range..." msgstr "Gestion des appareils et des répéteurs" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 #, fuzzy msgid "No repeaters in range" msgstr "Aucun répéteur dans les contacts" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "(déjà ajouté)" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "{} régions ajoutées" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "{} région ajoutée" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "Sauvegarde exportée avec succès" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 #, fuzzy msgid "Pick Snapshot" msgstr "Indiquer sur une carte" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "{} contacts" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "{} canaux" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "Ajouter à Contrôle d'Accès" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "Autoriser Flood" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "Modifications non sauvegardées" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "Vous avez des modifications de région non sauvegardées." #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "Ajouter Région" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "Mot de passe administrateur modifié — reconnexion requise" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "Paramètres envoyés à {}" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, fuzzy, python-brace-format msgid "Logged in to {}" msgstr "Se connecter à {}" #: src/views/sensor_view.py:237 #, fuzzy msgid "Login failed" msgstr "La connexion a expiré" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "Localisation Partagée" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "Partagée" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "Partager" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "Localisation non disponible" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "Votre compagnon n'a pas de localisation GPS." #: src/views/chat_view.py:140 #, fuzzy msgid "Send Automatically" msgstr "Démarrage automatique à la connexion" #: src/views/chat_view.py:141 #, fuzzy msgid "Send Direct" msgstr "Direct" #: src/views/chat_view.py:142 #, fuzzy msgid "Send Flood" msgstr "Flood" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} de {} contacts sur la carte" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" #, fuzzy #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Ajouter Région (ex. Paris)" #~ msgid "Reboot Repeater" #~ msgstr "Redémarrer le répéteur" #~ msgid "Login and access CLI, status, and settings" #~ msgstr "Se connecter et accéder à CLI, statut et paramètres" #~ msgid "{} — Management" #~ msgstr "{} — Gestion" #~ msgid "" #~ "The repeater clock is ahead of the current time.\n" #~ "Firmware does not allow setting time backwards.\n" #~ "\n" #~ "This will reset the clock and reboot the repeater." #~ msgstr "" #~ "L'horloge du répéteur est en avance sur l'heure actuelle.\n" #~ "Le firmware n'autorise pas de régler l'heure en arrière.\n" #~ "\n" #~ "Cela réinitialisera l'horloge et redémarrera le répéteur." #~ msgid "Select a contact and role." #~ msgstr "Sélectionnez un contact et un rôle." #~ msgid "SNR: {:.1f} dB" #~ msgstr "SNR: {:.1f} dB" #, python-brace-format #~ msgid "{}ms (late)" #~ msgstr "{}ms (en retard)" #, python-format #~ msgid "%d ms" #~ msgstr "%d ms" #, fuzzy #~ msgid "New features:" #~ msgstr "Caractéristiques:" #, fuzzy #~ msgid "Map view for routing paths in contact details" #~ msgstr "Vue cartographique affichant les positions des contacts" #, fuzzy #~ msgid "" #~ "Code refactoring and performance optimizations across the application" #~ msgstr "Configurer le comportement et l'apparence de l'application" #~ msgid "Add from Contacts" #~ msgstr "Ajouter depuis Contacts" #~ msgid "No repeaters or rooms in contacts" #~ msgstr "Aucun répéteur ou salle dans les contacts" #~ msgid "Save configuration and messages to a file" #~ msgstr "Sauvegarder la configuration et les messages dans un fichier." #~ msgid "Device settings: name, radio, location" #~ msgstr "Paramètres Appareil: nom, radio, position" #, python-brace-format #~ msgid "Contacts: {} ({} new)" #~ msgstr "Contacts: {} ({} nouveaux)" #, python-brace-format #~ msgid "Channels: {} ({} new)" #~ msgstr "Canaux: {} ({} nouveaux)" #, python-brace-format #~ msgid "Channel messages: {}" #~ msgstr "Messages de canal : {}" #, python-brace-format #~ msgid "Saved passwords: {}" #~ msgstr "Mots de passe sauvegardés: {}" #~ msgid "Contacts & Channels Only" #~ msgstr "Contacts & Canaux Seulement" #~ msgid "Comma-separated hex hops (e.g. 9a,74 or 9a3b,744c for 2-byte)" #~ msgstr "" #~ "Sauts hexadécimaux séparés par des virgules (par exemple 9a,74 ou " #~ "9a3b,744c pour 2 octets)" #~ msgid "Automatically pick the best route from history" #~ msgstr "Choisir automatiquement le meilleur itinéraire d'après l'historique" #~ msgid "Save to a file" #~ msgstr "Sauvegarder dans un fichier" #~ msgid "Contacts view with messaging" #~ msgstr "Vue des contacts avec messagerie" #~ msgid "Channel list with different channel types" #~ msgstr "Liste des canaux avec différents types de canaux" #~ msgid "Device information and settings" #~ msgstr "Informations et paramètres appareil" #, python-brace-format #~ msgid "Syncing channels: {index}/{total}" #~ msgstr "Synchronisation des canaux : {index}/{total}" #, python-brace-format #~ msgid "Syncing contacts: {current}/{total}" #~ msgstr "Synchronisation des contacts : {current}/{total}" meshy/po/hu.po000066400000000000000000002220071523412555500136070ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: hu\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/it.po000066400000000000000000002220071523412555500136070ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/lt.po000066400000000000000000002223401523412555500136120ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: lt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n % 10 == 1 && (n % 100 < 11 || n % 100 > " "19)) ? 0 : ((n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? " "1 : 2);\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/lv.po000066400000000000000000002222751523412555500136230ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: lv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n % 10 == 0 || n % 100 >= 11 && n % 100 <= " "19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2);\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/meshy.pot000066400000000000000000002220521523412555500145040ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/meson.build000066400000000000000000000002431523412555500147710ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later i18n.gettext('meshy', preset: 'glib') meshy/po/nl.po000066400000000000000000002774731523412555500136250ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: 2026-07-25 10:07+0000\n" "Last-Translator: LievenBlancke \n" "Language-Team: Dutch \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 2026.6.1\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "Meshy" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "MeshCore mesh netwerk cliënt" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "mesh;lora;radio;chat;" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" "Meshy is een cliënt voor MeshCore LoRa mesh netwerk apparaten. Verbind jouw " "companion apparaat over Bluetooth om geëncrypteerde berichten te zenden en " "ontvangen, beheer contacten, configureer kanalen, en monitor jouw mesh " "netwerk." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "Kenmerken:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "Bluetooth LE en USB seriële verbinding" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "Eind-tot-eind versleutelde berichtgeving" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "Openbare, privé- en hashtagkanalen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "Contactbeheer met locatievolgen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "Interactieve kaartweergave met routeopvolging" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "Apparaat- en repeaterbeheer" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "QR-code scannen voor snel contact uitwisselen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "Kanaalchats" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "Repeaterbeheer" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "Instellingen - Donkere Stijl" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "Kaartweergave met contactlocaties" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "Algemeen" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "Verbinding verbreken" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "Afsluiten" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "Help" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "Sneltoetsen" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "Navigatie" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "Apparaat" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "Contacten" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "Kanalen" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "Kaart" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "Instellingen" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "Berichten" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "Contacten Zoeken" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "Focus berichtinvoer" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "Vorig Contact/Kanaal" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "Volgend Contact/Kanaal" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "Vorig Ongelezen" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "Volgend Ongelezen" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "Over Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "Verbinden" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "Menu" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "Verbinding maken…" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "Navigatie tonen" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "Niet verbonden" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "Niet verbonden" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Status" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "Apparaatinformatie" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "Knooppuntnaam" #: data/ui/device-view.ui:54 msgid "Board" msgstr "Bord" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:63 msgid "Update" msgstr "Update" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "Open firmware flasher" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "Openbare sleutel" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "Telemetrie" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "Telemetrie aanvragen" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "Synchroniseren…" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "Locatie" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "Op kaart tonen" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "Batterij & Opslag" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "Batterij" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "Opslag" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "Radioconfiguratie" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "Frequentie" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "Bandbreedte" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "Spreidingsfactor" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "Coderingssnelheid" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "TX Vermogen" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Standaard pad-hashgrootte" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "Statistieken" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "Statistieken vernieuwen" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "Ruisvloer" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "Laatste RSSI" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "Laatste SNR" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "Zendduur" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Pakketten" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "Direct" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Uptime" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Berichtenwachtrij" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "Toepassing" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "Configureer het gedrag en uiterlijk van de toepassing" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "Stijl" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "Kies het uiterlijk van de toepassing" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Volg Systeem" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "Licht" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "Donker" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "Palletthema" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "MeshCore Donker" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "Kanaalmeldingen" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "Standaard meldingsniveau voor alle kanalen" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "Alle berichten" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "Alleen Vermeldingen" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "Geen" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "Op de achtergrond uitvoeren" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "Blijf berichten ontvangen nadat het venster is gesloten" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "Automatisch starten" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "Automatisch starten bij aanmelden" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "Radio" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "LoRa-radioparameters en regionale presets" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Regionale Preset" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "Presetconfiguratie" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "Frequentie, bandbreedte, spreidingsfactor, coderingssnelheid" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "Frequentie (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "Bandbreedte (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "Repeat mode" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "Als een draagbare repeater werken op een off-grid frequentie" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "TX Vermogen (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "Aankondiging" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "Configureer hoe jouw knooppunt zichzelf aankondigt op het mesh" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "Apparaatnaam" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "Locatie opnemen in Aankondiging" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "Zend je positie uit naar andere knooppunten" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "Routering en Berichten" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "Instellingen voor padroutering en berichtbezorging" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "Standaardroutering" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "Vast path of automatische routeselectie" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "Klassiek" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "Slim" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "Meer informatie over routeringsmodi" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "Grotere hashes verminderen botsingen, maar laten minder hop toe" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "1-byte (max 64 hops)" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "2-byte (max 32 hops)" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "3-byte (max 21 hops)" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "Directe Berichtbevestigingen" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "Aantal verzonden ACK's bij ontvangst van een direct bericht" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "Stel de positie van je companion in via GPS of handmatig" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "Companion GPS" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" "Schakel GPS in op de companion, je positie wordt automatisch bijgewerkt" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "Locatie handmatig instellen" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "Kies een andere methode om de locatie in te stellen; deze blijft vast" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "Breedtegraad" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "Lengtegraad" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Gebruik Mijn Locatie" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "Instellen vanaf de locatie van deze computer" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "Kiezen op Kaart" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "Kies een locatie op de kaart" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "Contacten Automatisch Toevoegen" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "Voeg automatisch knooppunten toe die via aankondigingen worden ontdekt" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "Instellingen Automatisch Toevoegen" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "Aapparaattype, overschrijfbeleid, hoplimiet" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "Chat Knooppunten" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "Repeaters" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "Kamerservers" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "Sensoren" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "Oudste overschrijven wanneer vol" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "Hoplimiet" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "0–63, laat leeg voor geen limiet" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "Telemetrieprivacy" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "Bepaal wat anderen van jouw apparaat mogen opvragen" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "Batterij & Sensoren" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "Batterij, spanning, temperatuur, stroom" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "GPS-coördinaten" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "Omgeving" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "Vochtigheid, druk, luchtkwaliteit" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "Regio's" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "Beperk het bereik van kanaalberichten tot specifieke regio's" #: data/ui/settings-view.ui:421 #, fuzzy msgid "Add region…" msgstr "Regio Toevoegen" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "Ontdek regio's van repeaters" #: data/ui/settings-view.ui:430 #, fuzzy msgid "Add region" msgstr "Regio Toevoegen" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "Standaardbereik" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "Alle flood-pakketten worden aan deze regio gebonden als ingesteld" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" "Na het wijzigen van de pincode: herstart het apparaat, vervolgens ontkoppel " "en koppel opnieuw. Instellen op 0 zet de pincode terug naar standaard." #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "Koppelingspincode" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "Maak verbinding met een companion apparaat om Meshy te gaan gebruiken." #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "Koppel met een Nieuwe Companion" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "USB Serieel" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "WiFi / TCP" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "TCP Companion Toevoegen" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Zoeken..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "Geen contacten" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "Contact Toevoegen" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "Selecteer een Contact" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "Kies een contact uit de lijst om te beginnen met chatten" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "↑ Nieuwe Berichten" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "Typ een bericht..." #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "Privékanaal Maken" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "Deelnemen aan een Privékanaal" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Deelnemen aan het Openbaar Kanaal" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "Deelnemen aan een Hashtagkanaal" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "Geen Kanalen" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "Kanaal Toevoegen" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "Selecteer een Kanaal" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "Kies een kanaal uit de lijst" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "Berichtkanaal..." #: data/ui/repeater-view.ui:37 msgid "System" msgstr "Systeem" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "Klok bij Aanmelding" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "Nu Synchroniseren" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "Resetten en Opnieuw Opstarten" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "Foutgebeurtenissen" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "TX Zendduur" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "RX Zendduur" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "Kanaalgebruik" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "Arbeidscyclus" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "Verzonden" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "Ontvangen" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "Ontvangstfouten" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "Duplicaten" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "Vernieuwen" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "Telemetrie Aanvragen" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "CLI-opdracht..." #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "Buren" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "Meer Laden" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "Weergeven op Kaart" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "Toegangscontrole" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "Toevoegen" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "Regio's worden geladen..." #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "Opnieuw proberen" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "Standaard Regiobereik" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" "Flood-pakketten worden aan de opgegeven regio gebonden. Alleen repeaters die " "de regio toestaan, zullen gebonden pakketten doorsturen. Laat leeg voor niet-" "gebonden." #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "Toepassen" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "Basis" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "Ophalen" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "Opslaan" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "Naam" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "Pakketherhaling" #: data/ui/repeater-view.ui:633 #, fuzzy msgid "Guest Access" msgstr "Gastwachtwoord" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "Wachtwoord" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "Beheerderswachtwoord" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "Gastwachtwoord" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "Aankondiging" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "Lokale interval (min)" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "Flood Interval (uren)" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "Gevarenzone" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "Apparaat Opnieuw Opstarten" #: data/ui/repeater-view.ui:890 #, fuzzy msgid "Select a device" msgstr "Selecteer een Repeater" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "Afmelden" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "Selecteer een Sensor" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "Telemetrie Vernieuwen" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "Inloggen voor Beheerderstoegang" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "Aankondiging Verzenden" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "Zero Hop" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "Lokaal uitzenden" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "Flood Gerouteerd" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "Uitzenden door het mesh" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "Naar Klembord" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "Kopieer eigen contactgegevens" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "QR-code Weergeven" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "Zodat anderen je kunnen scannen en toevoegen" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "Pad Traceren" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "Traceer een route en toon signaalkwaliteit per hop" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "Ontdek Nabijgelegen Knooppunten" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "Scan het netwerk op nabijgelegen knooppunten" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "Rx Log" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "Bekijk ontvangen radiopakketten" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "Log Wissen" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "Geen Pakketten" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "Ontvangen radiopakketten verschijnen hier" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "dBm gedurende de afgelopen 2 minuten" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "Back-up" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "Handmatig" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "Kies waarvan een back-up wordt opgeslagen in een bestand" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "Automatisch" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "Dagelijkse back-ups, 7 dagen bewaard" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "Terugzetten" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "Van Bestand" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "Terugzetten vanaf een handmatig opgeslagen back-up" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "Van Automatische Back-up" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "Terugzetten vanaf een automatische momentopname" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "Fabrieksinstellingen Terugzetten" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "Wis alle gegevens op de companion" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "Manuele Back-up" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "Apparaatidentiteit" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "Apparaatinstellingen" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "Lokale Instellingen" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "Contactenlijst" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "Gevonden Contacten" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "Berichten en Routering" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "Berichten" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "Slimme Routegeschiedenis" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "Exporteren" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "Automatische Back-up" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "Automatische Back-ups Inschakelen" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "Dagelijkse back-ups na sync, 7 dagen bewaard" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "Back-up Importeren" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "Ontdek contacten" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "Zoeken" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "Filter op type" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "Sorteren op" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "Contact Delen" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Kies Thema" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "Contactinformatie" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "Favoriet" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "Vastzetten bovenaan de lijst met contacten" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "Type" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "Kopiëren naar klembord" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "Laatst Gezien" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "Routering" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "Uitgaand Pad" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "Pad Opnieuw Instellen" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "Maak het gevestigde pad vrij en schakel over naar flood" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "Beschikbare Paden" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "Pad Automatisch Opnieuw Instellen" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "Schakel over naar Flood na mislukte bezorgpogingen" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "Forceer Flood" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "Zend altijd uit, gebruik nooit een vastgesteld pad" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "Vraag de telemetriegegevens van dit contact op" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "Contact verwijderen" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "Pad" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "Kies route op de kaart" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "Trace Uitvoeren" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "Voer pad-hashes in en druk op Trace Uitvoeren" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "Contacttype" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "Openbare sleutel (64 hexadecimale tekens)" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "Openen in Maps App" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "Deelnemers" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "Filter" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "Kanaalnaam" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "Aangepaste Kaarttegels" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "URL-Sjabloon" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "Terugzetten naar Standaard" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "Luisteren..." #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "Wachten tot nabijgelegen nodes reageren" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "Ontdek Regio's" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "Scannen..." #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "Luisteren naar repeaters in de buurt" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "Geselecteerde Toevoegen" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (bv. #algemeen)" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "Privésleutel (32 hexadecimale tekens)" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "Apparaatlocatie" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "Gebruikers" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Kamers" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "Ontdekte Knooppunten" #: data/ui/map-view.ui:30 msgid "User" msgstr "Gebruiker" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "Repeater" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "Kamer" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "Sensor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "Berichtpaden" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "Zoeken naar MeshCore-apparaten in de buurt" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "Nieuwe Companion Koppelen" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "Scannen naar MeshCore apparaten..." #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "PIN" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "Uw Contact-QR-Code" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "Scan deze QR-code om dit contact toe te voegen" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "QR-Code Scannen" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "Statuslabel" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "Regionaam" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "Ouder" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "Wachtwoord" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Wachtwoord opslaan" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "Login" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "Hostnaam / IP-Adres" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "Poort" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "Ongedaan Maken" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "Wissen" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "Klaar" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "Chat" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "Onbekend" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" "Naam: {name}\n" "Type: {type}" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" "Type: {type}\n" "Sleutel: {key}…" #: src/application.py:132 msgid "Import Contact" msgstr "Contact Importeren" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "Contact {}" #: src/application.py:136 msgid "imported" msgstr "geïmporteerd" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "Ongeldige meshcore:/- link" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "Ongeldig kanaalgeheim in link" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "Ongeldige kanaalgeheimlengte" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" "Naam: {name}\n" "Type: Privé" #: src/application.py:161 msgid "Unnamed" msgstr "Naamloos" #: src/application.py:177 msgid "Invalid public key in link" msgstr "Ongeldige openbare sleutel in link" #: src/application.py:180 msgid "Invalid public key length" msgstr "Ongeldige lengte van de openbare sleutel" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "{} is al in jouw lijst" #: src/application.py:184 msgid "Contact" msgstr "Contact" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "Contact {} Toevoegen" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" "Naam: {name}\n" "Openbare Sleutel: {key}…" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "Contact {} toegevoegd" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "Niet-herkende meshcore://-link" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "Annuleren" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "Berichten ontvangen op de achtergrond" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" "gallegonovato — Spaans\n" "lebeno — Frans, Nederlands\n" "bjawebos — Duits\n" "Manuel Traut — Duits\n" "lejun — Frans\n" "LievenBlancke — Nederlands\n" "sesivany — Tsjechisch" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "Een GTK-client voor MeshCore LoRa mesh-netwerkapparaten" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "Adwaita Symbolische Iconen" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "Verbinden..." #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "Scannen Naar Apparaten" #: src/connection_controller.py:160 msgid "Scan" msgstr "Scan" #: src/connection_controller.py:277 msgid "Stop" msgstr "Stop" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Opnieuw verbinding maken ({current}/{total})..." #: src/connection_controller.py:301 msgid "Syncing..." msgstr "Synchroniseren..." #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" "Apparaat reageerde niet. Het is mogelijk dat dit verbindingstype niet wordt " "ondersteund." #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "Kanalen synchroniseren" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "Je hebt {} bericht ontvangen terwijl de verbinding was verbroken." msgstr[1] "Je hebt {} berichten ontvangen terwijl de verbinding was verbroken." #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "Apparaatfout: {}" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "Contacten synchroniseren" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "{} (later)" #: src/frame_handler.py:506 msgid "late" msgstr "later" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "Apparaatklok liep {}min achter — gesynchroniseerd" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Apparaatklok loopt {}min voor op het systeem" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "Kanaal {}" #: src/frame_handler.py:1208 msgid "Someone" msgstr "Iemand" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "Ik" #: src/message_controller.py:218 msgid "flood" msgstr "flood" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "direct" #: src/message_controller.py:222 msgid "path" msgstr "pad" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "wachten {} (radio bezet)" #: src/window.py:497 msgid "Raster" msgstr "Raster" #: src/window.py:497 msgid "Vector" msgstr "Vector" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" "Gebruik {z}, {x}, {y} als tijdelijke aanduidingen..\n" "Voorbeeld: https://tile.openstreetmap.org/{z}/{x}/{y}.png" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" "URL naar een MapLibre/Mapbox JSON stijl.\n" "Voorbeeld: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" #: src/window.py:515 msgid "Style JSON URL" msgstr "JSON URL Stijl" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "Aangepaste kaarttegels toegepast" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "Aangepaste kaarttegels laden mislukt" #: src/window.py:576 msgid "Sort" msgstr "Sorteren" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "Acties" #: src/window.py:661 msgid "Channel" msgstr "Kanaal" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "Back-up & Terugzetten" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:859 msgid "Management" msgstr "Beheer" #: src/window.py:859 msgid "Guest" msgstr "Gast" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:961 msgid "Excellent" msgstr "Uitstekend" #: src/window.py:962 msgid "Good" msgstr "Goed" #: src/window.py:963 msgid "Fair" msgstr "Redelijk" #: src/window.py:964 msgid "Poor" msgstr "Zwak" #: src/window.py:965 msgid "Very poor" msgstr "Zeer zwak" #: src/window.py:1191 msgid "flood routed" msgstr "flood gerouteerd" #: src/window.py:1191 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "Aankondiging verzonden ({})" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Kanaal \"{}\" toegevoegd" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "Geen lege kanaalslots beschikbaar" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "Kan geen verbinding maken met sessiebus: {}" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "Geen camera gedetecteerd op dit systeem." #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "Kon geen toegang krijgen tot de camera: {}" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "Toegang tot de camera is geweigerd." #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "Kan camerastream niet openen." #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "Geen camerabestandsbeschrijving ontvangen." #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "Kon camera niet openen: {}" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Fout in camerapijplijn: {}" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" "Nog geen QR-code gedetecteerd. \n" "Zorg ervoor dat de code goed belicht en scherp is en het grootste deel van " "het frame vult." #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" "Kon QR-code niet detecteren. \n" "Probeer in plaats daarvan het contact via het klembord te importeren." #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "QR-code gevonden!" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "Camerafout: {}" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "Camerafout" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "OK" #: src/views/__init__.py:52 msgid "Today" msgstr "Vandaag" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "Gisteren" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "%A %-d %B" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "%A %-d %B %Y" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "{total} contacten" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contacten" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "Verwijder" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "Bluetooth niet beschikbaar" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "BLE vereist uitvoeren vanuit een app-bundel op macOS" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "Bluetooth is uitgeschakeld" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "Schakel Bluetooth in de systeeminstellingen in om verbinding te maken" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "Geen gekoppelde apparaten" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "Gebruik de onderstaande knop om een nieuwe companion te koppelen" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "Geen USB-apparaten gedetecteerd" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "Verbind een MeshCore companion via USB" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "Verwijder Companion" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" "{} Verwijderen ({})? Je zal opnieuw moeten koppelen om verbinding te maken." #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "Verwijder opgeslagen gegevens" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "USB-Verbinding Mislukt" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "USB-Toestemming Geweigerd" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" "Geen toegang tot {}. \n" "\n" "Er is een udev-regel nodig om toegang te verlenen tot seriële USB-apparaten. " "Klik op \"Installeer Regel\" om deze te installeren (beheerderswachtwoord " "vereist)." #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "Installeer Regel" #: src/views/connection_view.py:274 #, fuzzy, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" "Geen toegang tot {}. \n" "\n" "Er is een udev-regel nodig om toegang te verlenen tot seriële USB-apparaten. " "Voer het volgende commando uit in een terminal." #: src/views/connection_view.py:280 msgid "Close" msgstr "Sluiten" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "Kopieer Opdracht" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "Opdracht gekopieerd naar klembord" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "Installatie Mislukt" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Kon udev-regel niet installeren: \n" "{}" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "Geen opgeslagen companions" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "Gebruik de onderstaande knop om er een toe te voegen" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "Adres Wijzigen" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "TCP-companion {} verwijderen?" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "Geef het nieuwe adres voor deze companion." #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "Voer het IP-adres en de poort van uw MeshCore-apparaat in." #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "Koppelen" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "Koppelingspincode Invoeren" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "Voer de pincode in die wordt weergegeven op uw MeshCore-apparaat." #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "Nooit" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "Toon Pad op Kaart" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "Visualiseer de berichtenroute op de kaart" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "Telemetrieverzoeken Toestaan" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "Laat dit contact de batterij, spanning en temperatuur opvragen" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "Inclusief Locatie" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "Neem GPS-coördinaten op in telemetriereacties" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "Inclusief Omgevingssensoren" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "Voeg gegevens over vochtigheid, druk en luchtkwaliteit toe" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "Kamerbeheer" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "Repeaterbeheer" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "Log in en krijg toegang tot status, CLI, buren, en instellingen" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "Meet de retourtijd naar dit knooppunt" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "Ontdek Paden" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "Vind routes naar deze node via flood" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "Vraag regio's van deze repeater op" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "Opvragen..." #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "Geen reactie" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "Openbare sleutel gekopieerd" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "%d paden beschikbaar" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "Nog geen paden" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "Slimme Routes — %s" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "Beschikbare Paden" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "Score: %.2f" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "Gewicht: %.1f" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "%d hops" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "Nog geen paden geregistreerd." #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "Paden worden ontdekt terwijl berichten worden uitgewisseld" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "Geschiedenis Wissen" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "Geen locatiegegevens beschikbaar" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "Routes naar %s" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "Mijn Apparaat" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "Contact Verwijderen?" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "{} en alle berichten verwijderen?" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "Handmatig Toevoegen" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "Importeren vanaf Klembord" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "Alle" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "Favorieten" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "Onlangs Gehoord" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "Laatste Berichten" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "Favorieten Eerst" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "{total} contact" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" "Er zijn nog geen nieuwe contacten vernomen. Laat de app actief en de " "contacten verschijnen zodra hun aankondigingen worden ontvangen." #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "{total} ontdekt" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} ontdekt" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "Aankomsttijd" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "Afstand" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "Voer de contactgegevens in." #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "Kamerserver" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "Dit contact staat al in uw lijst." #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "Importeren Mislukt" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "Geen meshcore://-link gevonden op het klembord." #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "Ongeldig kanaalgeheim in QR-code." #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "Kanaalgeheim moet 16 bytes zijn, kreeg {}." #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "Ongeldige openbare sleutel in QR-code." #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "Openbare sleutel moet 32 bytes zijn, kreeg {}." #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Kon QR-codegegevens niet parseren: \n" "{}" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "QR-Code Gescand" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "Openbare sleutel: {}...{}" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "Inloggen op {}" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "Inloggen succesvol" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "Inloggen mislukt - verkeerd wachtwoord" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "Er is een time-out voor inloggen opgetreden" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "Opnieuw proberen via flood..." #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "Inloggen..." #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "Zoeken..." #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d hop" msgstr[1] "%d hops" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "Gevonden pad" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "Laden..." #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "Er is geen pad in de cache opgeslagen" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "Niet ondersteund" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "Ping wordt niet ondersteund met pad-hashes van 3 bytes" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: timed-out" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Aanvragen..." #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "Geen reactie (time-out)" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Geen telemetriegegevens" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "Telemetrie ontvangen" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "Pad naar {}" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "Onbekende repeater" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "Gehoorde Herhalingen" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "Opnieuw Verzenden" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "Verwijderen" #: src/views/channels_view.py:151 msgid "Reply" msgstr "Antwoord" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "Tekst Kopiëren" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "Berichtpaden Bekijken" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "Locatie Delen" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "Locatie Delen van Kaart" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "Je deelde een contact:" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} deelde een contact met jou:" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "Al toegevoegd" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "Je deelde een locatie:" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} deelde een locatie:" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "Nieuwe Berichten" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d herhaling" msgstr[1] "· %d herhalingen" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" "Geen padinformatie beschikbaar. Padgegevens worden alleen vastgelegd voor " "berichten die worden ontvangen terwijl er verbinding is." #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "Direct bericht" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "Er zijn geen repeaters gebruikt" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "Geen repeatergegevens" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "Padgegevens worden alleen vastgelegd via LOG_DATA als er verbinding is" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "Berichtpad" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "Nog geen herhalingen gehoord." #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "Openbaar" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "Hashtag" #: src/views/channels_view.py:1465 msgid "Private" msgstr "Privé" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "{visible}/{total} kanalen" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "{total} kanalen" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "{total} kanaal" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "Kanaalinformatie" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "Privésleutel" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} deelnemer" msgstr[1] "{} deelnemers" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "Berichtbehoud" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "1 Dag" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "1 Week" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "1 Maand" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "3 Maanden" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "Altijd" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "Meldingsniveau" #: src/views/channels_view.py:1775 msgid "Default" msgstr "Standaard" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "Regio" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "Beperk flood-berichten tot een specifieke regio" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "Geen regio's gedefinieerd" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "Voeg regio's toe in Instellingen om ze aan kanalen toe te wijzen" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "Wis Berichten" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "Berichten Wissen?" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "Verwijder alle berichten in \"{}\"?" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "Berichten gewist" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "Kanaal verwijderen" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "Kanaal Verwijderen?" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "'{}' en alle bijbehorende berichten verwijderen?" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "Geblokkeerd" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} geblokkeerd" msgstr[1] "{} geblokkeerd" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "Deblokkeren" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "Blokkeren" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} bericht" msgstr[1] "{} berichten" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "{} Gedeblokkeerd" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "Blokkeer {}?" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" "Kanaalberichten bevatten geen identiteit van de afzender. Deze blokkering " "komt alleen overeen met de naam. De afzender kan hun naam wijzigen om deze " "te omzeilen." #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "Blokkeer en Verwijder Berichten" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "{} Geblokkeerd" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "Geen Slots Beschikbaar" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "Alle 8 kanaalslots zijn in gebruik." #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" "Er wordt een willekeurige PSK gegenereerd. Deel die met anderen, zodat zij " "lid kunnen worden." #: src/views/channels_view.py:2233 msgid "Create" msgstr "Aanmaken" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "Voer de kanaalnaam en de privésleutel in die met u zijn gedeeld." #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "Lid Worden" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "Al Lid" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "Je bent al op het openbare kanaal." #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Voer een hashtagnaam in. Iedereen die dezelfde hashtag gebruikt, kan " "communiceren." #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "Openbare sleutel van apparaat niet beschikbaar" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "QR-codebibliotheek niet beschikbaar" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "Fabrieksreset?" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" "Hierdoor worden ALLE gegevens op het companion apparaat permanent gewist, " "inclusief contacten, berichten, kanalen, instellingen en de " "apparaatidentiteit (sleutels). Deze actie kan niet ongedaan worden gemaakt." #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "Apparaat Opnieuw Opstarten?" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "Het apparaat wordt losgekoppeld en opnieuw opgestart." #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "Opnieuw Opstarten" #: src/views/device_view.py:327 msgid "Not set" msgstr "Niet ingesteld" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "Locatie (handmatig ingesteld)" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "Locatie (GPS)" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "Locatie (GPS aan, geen fix)" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "Locatie (GPS uit)" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "Aanvragen…" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "{days}d" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "{} berichten" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "{count} fouten" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "Verbonden" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "Traceer Pad wordt niet ondersteund met pad-hashes van 3 bytes" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "bv. aa,bb,cc" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "bv. aabb,ccdd" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "bv. aabbcc,ddeeff" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "Geen repeaters met bekende locatie" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trace voltooid: {} hops, {}" #: src/views/device_view.py:689 msgid "Result" msgstr "Resultaat" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "Er is een time-out opgetreden bij het traceren" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "Ongeldige hex in pad" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "Traceren..." #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Bezig met scannen... {}s resterend" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "Toevoegen aan Contacten" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "{} toegevoegd" #: src/views/device_view.py:1015 msgid "Node" msgstr "Node" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "Klaar. {} nodes gevonden." #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "Klaar. {} node gevonden." #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} in de afgelopen 2 minuten" #: src/views/device_view.py:1301 msgid "Size" msgstr "Grootte" #: src/views/device_view.py:1301 msgid "bytes" msgstr "bytes" #: src/views/device_view.py:1306 msgid "hops" msgstr "hops" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "Pad-hashes" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "byte per hop" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "Update beschikbaar: {version}" #: src/views/settings_view.py:143 msgid "Custom" msgstr "Aangepast" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "Alles Weigeren" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "Toestaan per Contact" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "Iedereen toestaan" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "Instellingen toegepast op apparaat" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "Onbekende fout" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "Instellingenfout: {}" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "TX-vermogen (dBm, max. {})" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "Locatie niet beschikbaar" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "Locatie ingevuld, klik op Toepassen om op te slaan" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "Kies Locatie" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "Locatie Instellen" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "Maak eerst verbinding met een apparaat" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "Repeaters binnen bereik ontdekken..." #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "{} regio's gevonden" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "{} regio gevonden" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "Geen regio's gevonden" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "Geen repeaters binnen bereik" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "van {}" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "(al toegevoegd)" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "{} repeaters opvragen..." #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "{} repeater opvragen..." #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "{} regio's toegevoegd" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "{} regio toegevoegd" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "Niet Verbonden" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "Maak eerst verbinding met een apparaat om een back-up te exporteren." #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "JSON bestanden" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "Back-up succesvol geëxporteerd" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "Exporteren mislukt: {}" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "Apparaat ondersteunt geen sleutelexport" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "Maak eerst verbinding met een apparaat om een back-up te importeren." #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "Maak eerst verbinding met een apparaat om een back-up terug te zetten." #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "Geen Automatische Back-ups" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "Geen automatische back-ups gevonden voor dit apparaat." #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "Kies Momentopname" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Kon back-upbestand niet lezen: {}" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "Bron: {}" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "{} contacten" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "{} kanalen" #: src/views/settings_view.py:1495 msgid "messages" msgstr "berichten" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "apparaatidentiteit" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "Geïmporteerd: {}" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "Er is niets nieuws om te importeren" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "Klok Resetten en Opnieuw Opstarten?" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "Opnieuw opstarten..." #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "Verzoek is verlopen" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} van {} buren" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "Burenkaart" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "{} vermelding" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "{} vermeldingen" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "Uit ACL Verwijderen?" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "{} verwijderen uit de toegangscontrolelijst?" #: src/views/repeater_view.py:744 msgid "Role" msgstr "Rol" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "Beheerder" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "Voeg toe aan Toegangscontrole" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "Globaal (wildcard)" #: src/views/repeater_view.py:875 msgid "Home" msgstr "Thuis" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "Flood toegestaan" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "Flood geweigerd" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "Flood Weigeren" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "Flood Toelaten" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "Instellen als Thuis" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "Verwijder eerst onderliggende regio's" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "Niet-opgeslagen wijzigingen" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "U heeft niet-opgeslagen regiowijzigingen." #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "Weggooien" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "Regio Toevoegen" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "Voer een naam in en kies optioneel een bovenliggende regio." #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "Beheerderswachtwoord gewijzigd — opnieuw inloggen vereist" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "Instellingen verzonden naar {}" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "{} opnieuw opstarten? Die zal kort offline zijn." #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "Tijd wijkt af" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "Ingelogd op {}" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "Inloggen mislukt" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "Gedeelde Locatie" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "Gedeeld" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "Delen" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "Locatie Niet Beschikbaar" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "Jouw companion heeft geen GPS locatie." #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "Automatisch Verzenden" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "Direct Verzenden" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "Flood Zenden" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "Verzenden" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} van {} contacten op kaart" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "{} van {} gelegen op de kaart" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "and {} meer" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "Positie is illustratief — echte locatie onbekend" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "Kies Traceroute" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "Tik op repeaters om de traceroute op te bouwen" #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Regio toevoegen (bijv. nl)" #, python-brace-format #~ msgid "{d}d" #~ msgstr "{d}d" #, python-brace-format #~ msgid "{h}h" #~ msgstr "{h}h" #, python-brace-format #~ msgid "{m}m" #~ msgstr "{m}m" #, python-brace-format #~ msgid "{secs}s" #~ msgstr "{secs}s" #, python-brace-format #~ msgid "{} min ago" #~ msgid_plural "{} min ago" #~ msgstr[0] "{} minuut geleden" #~ msgstr[1] "{} minuten geleden" #, python-brace-format #~ msgid "{} hour ago" #~ msgid_plural "{} hours ago" #~ msgstr[0] "{} uur geleden" #~ msgstr[1] "{} uren geleden" #, python-brace-format #~ msgid "{} day ago" #~ msgid_plural "{} days ago" #~ msgstr[0] "{} dag geleden" #~ msgstr[1] "{} dagen geleden" #~ msgid "Reboot Repeater" #~ msgstr "Herstart Repeater" #~ msgid "Login and access CLI, status, and settings" #~ msgstr "Log in en krijg toegang tot CLI, status en instellingen" #~ msgid "{} — Management" #~ msgstr "{} — Beheer" #~ msgid "Just now" #~ msgstr "Zojuist" #~ msgid "{}s ago" #~ msgstr "{}s geleden" #~ msgid "{}m ago" #~ msgstr "{}m geleden" #~ msgid "{}h ago" #~ msgstr "{}h geleden" #~ msgid "{}d ago" #~ msgstr "{}d geleden" #~ msgid "Time sync sent to repeater" #~ msgstr "Tijdsynchronisatie verzonden naar repeater" #~ msgid "" #~ "The repeater clock is ahead of the current time.\n" #~ "Firmware does not allow setting time backwards.\n" #~ "\n" #~ "This will reset the clock and reboot the repeater." #~ msgstr "" #~ "De repeaterklok loopt voor op de huidige tijd. \n" #~ "Firmware staat niet toe dat de tijd achteruit wordt gezet. \n" #~ "\n" #~ "Hierdoor wordt de klok opnieuw ingesteld en wordt de repeater opnieuw " #~ "opgestart." #~ msgid "Clock reset & reboot sent to repeater" #~ msgstr "Klokreset en herstart naar repeater verzonden" #~ msgid "Select a contact and role." #~ msgstr "Selecteer een contact en een rol." #~ msgid "Reboot Repeater?" #~ msgstr "Repeater opnieuw opstarten?" #~ msgid "SNR: {:.1f} dB" #~ msgstr "SNR: {:.1f} dB" #, python-brace-format #~ msgid "{}ms (late)" #~ msgstr "{}ms (later)" #, python-format #~ msgid "%d ms" #~ msgstr "%d ms" #, fuzzy #~ msgid "New features:" #~ msgstr "Kenmerken:" #, fuzzy #~ msgid "Map view for routing paths in contact details" #~ msgstr "Kaartweergave met contactlocaties" #, fuzzy #~ msgid "" #~ "Code refactoring and performance optimizations across the application" #~ msgstr "Configureer het gedrag en uiterlijk van de toepassing" #~ msgid "First stable release of Meshy." #~ msgstr "Eerste stabiele release van Meshy." #~ msgid "Add from Contacts" #~ msgstr "Toevoegen vanuit Contacten" #~ msgid "No repeaters or rooms in contacts" #~ msgstr "Geen repeaters of kamers in contacten" #~ msgid "Notifications" #~ msgstr "Meldingen" #~ msgid "Save configuration and messages to a file" #~ msgstr "Sla configuratie en berichten op in een bestand" #~ msgid "Device settings: name, radio, location" #~ msgstr "Apparaatinstellingen: naam, radio, locatie" #, python-brace-format #~ msgid "Contacts: {} ({} new)" #~ msgstr "Contacten: {} ({} nieuw)" #, python-brace-format #~ msgid "Channels: {} ({} new)" #~ msgstr "Kanalen: {} ({} nieuw)" #, python-brace-format #~ msgid "Messages: {}" #~ msgstr "Berichten: {}" #, python-brace-format #~ msgid "Channel messages: {}" #~ msgstr "Kanaalberichten: {}" #, python-brace-format #~ msgid "Saved passwords: {}" #~ msgstr "Opgeslagen wachtwoorden: {}" #~ msgid "Contacts & Channels Only" #~ msgstr "Alleen Contacten en Kanalen" #~ msgid "Everything" #~ msgstr "Alles" #~ msgid "Comma-separated hex hops (e.g. 9a,74 or 9a3b,744c for 2-byte)" #~ msgstr "" #~ "Door komma's gescheiden hex-hops (bijvoorbeeld 9a,74 of 9a3b,744c voor 2 " #~ "bytes)" #~ msgid "Routing Settings" #~ msgstr "Routeringsinstellingen" #~ msgid "Automatically pick the best route from history" #~ msgstr "Kies automatisch de beste route uit de geschiedenis" #~ msgid "Save to a file" #~ msgstr "Opslaan in een bestand" #~ msgid "Waiting..." #~ msgstr "Wachten..." #~ msgid "Querying repeaters for regions" #~ msgstr "Regio's van repeaters opvragen" #~ msgid "Contacts view with messaging" #~ msgstr "Contactenoverzicht met berichtgeving" #~ msgid "Channel list with different channel types" #~ msgstr "Kanalenlijst met verschillende kanaalsoorten" #~ msgid "Device information and settings" #~ msgstr "Apparaatinformatie en instellingen" #, python-brace-format #~ msgid "Syncing channels: {index}/{total}" #~ msgstr "Kanalen synchroniseren: {index}/{total}" #, python-brace-format #~ msgid "Syncing contacts: {current}/{total}" #~ msgstr "Contacten synchroniseren: {current}/{total}" meshy/po/nl@formal.po000066400000000000000000002642471523412555500151210ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-21 13:23+0200\n" "PO-Revision-Date: 2026-05-13 19:41+0000\n" "Last-Translator: lebeno \n" "Language-Team: Dutch (formal) \n" "Language: nl@formal\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 5.17.1\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "Meshy" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "MeshCore mesh netwerk cliënt" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "mesh;lora;radio;chat;" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" "Meshy is een cliënt voor MeshCore LoRa mesh netwerk apparaten. Verbind jouw " "companion apparaat over Bluetooth om geëncrypteerde berichten te zenden en " "ontvangen, beheer contacten, configureer kanalen, en monitor jouw mesh " "netwerk." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "Kenmerken:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "Bluetooth LE en USB seriële verbinding" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "Eind-tot-eind versleutelde berichtgeving" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "Openbare, privé- en hashtagkanalen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "Contactbeheer met locatievolgen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "Interactieve kaartweergave met routeopvolging" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "Apparaat- en repeaterbeheer" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "QR-code scannen voor snel contact uitwisselen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 #, fuzzy msgid "Channel chats" msgstr "Kanalen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 #, fuzzy msgid "Repeater management" msgstr "Repeaterbeheer" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "Kaartweergave met contactlocaties" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "Verbinding verbreken" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "Afsluiten" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "Sneltoetsen" #: data/gtk/help-overlay.ui:39 #, fuzzy msgid "Navigation" msgstr "Navigatie tonen" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "Apparaat" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:683 msgid "Contacts" msgstr "Contacten" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:695 msgid "Channels" msgstr "Kanalen" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:709 #: src/window.py:712 src/views/map_view.py:928 msgid "Map" msgstr "Kaart" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:731 src/window.py:732 msgid "Settings" msgstr "Instellingen" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 #, fuzzy msgid "Search Contacts" msgstr "Selecteer een Contact" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 #, fuzzy msgid "Next Contact/Channel" msgstr "Alleen Contacten en Kanalen" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "Over Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:287 #: src/connection_controller.py:525 src/window.py:1101 src/window.py:1114 #: src/views/connection_view.py:118 src/views/connection_view.py:164 #: src/views/connection_view.py:396 src/views/connection_view.py:543 msgid "Connect" msgstr "Verbinden" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "Menu" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "Verbinding maken…" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "Navigatie tonen" #: data/ui/window.ui:328 src/connection_controller.py:286 #: src/connection_controller.py:524 src/window.py:1100 src/window.py:1113 #: src/views/device_view.py:388 msgid "Not connected" msgstr "Niet verbonden" #: data/ui/device-view.ui:29 src/views/device_view.py:537 msgid "Disconnected" msgstr "Niet verbonden" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Status" #: data/ui/device-view.ui:44 src/window.py:672 src/window.py:673 msgid "Device Information" msgstr "Apparaatinformatie" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "Knooppuntnaam" #: data/ui/device-view.ui:54 msgid "Board" msgstr "Bord" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:63 msgid "Update" msgstr "Update" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "Open firmware flasher" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "Openbare sleutel" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "Telemetrie" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "Telemetrie aanvragen" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "Synchroniseren…" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:256 #: data/ui/settings-view.ui:397 data/ui/repeater-view.ui:672 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:359 #: src/views/settings_view.py:129 msgid "Location" msgstr "Locatie" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "Op kaart tonen" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "Batterij & Opslag" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1262 src/views/repeater_view.py:476 #: src/views/sensor_view.py:143 msgid "Battery" msgstr "Batterij" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "Opslag" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "Radioconfiguratie" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "Frequentie" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "Bandbreedte" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:758 msgid "Spreading Factor" msgstr "Spreidingsfactor" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:770 msgid "Coding Rate" msgstr "Coderingssnelheid" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "TX Vermogen" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:221 msgid "Default Path Hash Size" msgstr "Standaard pad-hashgrootte" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "Statistieken" #: data/ui/device-view.ui:220 #, fuzzy msgid "Refresh statistics" msgstr "Statistieken vernieuwen" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "Ruisvloer" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "Laatste RSSI" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "Laatste SNR" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "Zendduur" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Pakketten" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:1118 msgid "Flood" msgstr "Vloed" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:497 #: src/views/contact_detail_dialog.py:700 src/views/contacts_view.py:1068 #: src/views/contacts_view.py:1120 msgid "Direct" msgstr "Direct" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Uptime" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Berichtenwachtrij" #: data/ui/settings-view.ui:16 src/views/settings_view.py:126 msgid "Application" msgstr "Toepassing" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "Configureer het gedrag en uiterlijk van de toepassing" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "Stijl" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "Kies het uiterlijk van de toepassing" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Volg Systeem" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "Licht" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "Donker" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "Palletthema" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "MeshCore Donker" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "Kanaalmeldingen" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "Standaard meldingsniveau voor alle kanalen" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1958 msgid "All Messages" msgstr "Alle berichten" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1958 msgid "Mentions Only" msgstr "Alleen Vermeldingen" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:254 #: src/views/channels_view.py:1958 src/views/channels_view.py:1995 #: src/views/settings_view.py:154 src/views/repeater_view.py:1170 msgid "None" msgstr "Geen" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "Op de achtergrond uitvoeren" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "Blijf berichten ontvangen nadat het venster is gesloten" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "Automatisch starten" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "Automatisch starten bij aanmelden" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:717 src/views/settings_view.py:130 msgid "Radio" msgstr "Radio" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "LoRa-radioparameters en regionale presets" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Regionale Preset" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "Presetconfiguratie" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "Frequentie, bandbreedte, spreidingsfactor, coderingssnelheid" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:748 msgid "Frequency (MHz)" msgstr "Frequentie (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:753 msgid "Bandwidth (kHz)" msgstr "Bandbreedte (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "Repeat mode" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "Als een draagbare repeater werken op een off-grid frequentie" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:782 msgid "TX Power (dBm)" msgstr "TX Vermogen (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:127 msgid "Advert" msgstr "Aankondiging" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "Configureer hoe jouw knooppunt zichzelf aankondigt op het mesh" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "Apparaatnaam" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "Locatie opnemen in Aankondiging" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "Zend je positie uit naar andere knooppunten" #: data/ui/settings-view.ui:174 src/views/settings_view.py:128 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:188 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:203 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:209 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:222 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:227 src/views/device_view.py:315 msgid "1-byte (max 64 hops)" msgstr "1-byte (max 64 hops)" #: data/ui/settings-view.ui:228 src/views/device_view.py:316 msgid "2-byte (max 32 hops)" msgstr "2-byte (max 32 hops)" #: data/ui/settings-view.ui:229 src/views/device_view.py:317 msgid "3-byte (max 21 hops)" msgstr "3-byte (max 21 hops)" #: data/ui/settings-view.ui:237 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:238 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:257 msgid "Set your companion's position via GPS or manually" msgstr "Stel de positie van je companion in via GPS of handmatig" #: data/ui/settings-view.ui:260 msgid "Companion GPS" msgstr "Companion GPS" #: data/ui/settings-view.ui:261 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" "Schakel GPS in op de companion, je positie wordt automatisch bijgewerkt" #: data/ui/settings-view.ui:267 msgid "Set Location Manually" msgstr "Locatie handmatig instellen" #: data/ui/settings-view.ui:268 msgid "Choose another method to set the location, it will be fixed" msgstr "Kies een andere methode om de locatie in te stellen; deze blijft vast" #: data/ui/settings-view.ui:271 data/ui/repeater-view.ui:703 msgid "Latitude" msgstr "Breedtegraad" #: data/ui/settings-view.ui:276 data/ui/repeater-view.ui:708 msgid "Longitude" msgstr "Lengtegraad" #: data/ui/settings-view.ui:281 msgid "Use My Location" msgstr "Gebruik Mijn Locatie" #: data/ui/settings-view.ui:282 msgid "Set from this computer's location" msgstr "Instellen vanaf de locatie van deze computer" #: data/ui/settings-view.ui:302 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "Kiezen op Kaart" #: data/ui/settings-view.ui:303 msgid "Choose a location on the map" msgstr "Kies een locatie op de kaart" #: data/ui/settings-view.ui:329 data/ui/settings-view.ui:333 #: src/views/settings_view.py:131 msgid "Auto-Add Contacts" msgstr "Contacten Automatisch Toevoegen" #: data/ui/settings-view.ui:330 msgid "Automatically add nodes discovered via adverts" msgstr "Voeg automatisch knooppunten toe die via aankondigingen worden ontdekt" #: data/ui/settings-view.ui:338 msgid "Auto-Add Settings" msgstr "Instellingen Automatisch Toevoegen" #: data/ui/settings-view.ui:339 msgid "Device types, overwrite policy, hop limit" msgstr "Aapparaattype, overschrijfbeleid, hoplimiet" #: data/ui/settings-view.ui:342 msgid "Chat Nodes" msgstr "Chat Knooppunten" #: data/ui/settings-view.ui:347 src/views/contacts_view.py:165 #: src/views/contacts_view.py:301 src/views/map_view.py:243 msgid "Repeaters" msgstr "Repeaters" #: data/ui/settings-view.ui:352 src/views/contacts_view.py:166 #: src/views/contacts_view.py:302 msgid "Room Servers" msgstr "Kamerservers" #: data/ui/settings-view.ui:357 src/views/contacts_view.py:167 #: src/views/contacts_view.py:303 src/views/map_view.py:245 msgid "Sensors" msgstr "Sensoren" #: data/ui/settings-view.ui:362 msgid "Overwrite oldest when full" msgstr "Oudste overschrijven wanneer vol" #: data/ui/settings-view.ui:367 msgid "Hop limit" msgstr "Hoplimiet" #: data/ui/settings-view.ui:368 msgid "0–63, leave empty for no limit" msgstr "0–63, laat leeg voor geen limiet" #: data/ui/settings-view.ui:387 src/views/settings_view.py:132 msgid "Telemetry Privacy" msgstr "Telemetrieprivacy" #: data/ui/settings-view.ui:388 msgid "Control what others can query from your device" msgstr "Bepaal wat anderen van jouw apparaat mogen opvragen" #: data/ui/settings-view.ui:391 msgid "Battery & Sensors" msgstr "Batterij & Sensoren" #: data/ui/settings-view.ui:392 msgid "Battery, voltage, temperature, current" msgstr "Batterij, spanning, temperatuur, stroom" #: data/ui/settings-view.ui:398 msgid "GPS coordinates" msgstr "GPS-coördinaten" #: data/ui/settings-view.ui:403 msgid "Environment" msgstr "Omgeving" #: data/ui/settings-view.ui:404 msgid "Humidity, pressure, air quality" msgstr "Vochtigheid, druk, luchtkwaliteit" #: data/ui/settings-view.ui:413 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:232 #: src/views/settings_view.py:133 msgid "Regions" msgstr "Regio's" #: data/ui/settings-view.ui:414 msgid "Limit flood scope of channel messages to specific regions" msgstr "Beperk het bereik van kanaalberichten tot specifieke regio's" #: data/ui/settings-view.ui:417 msgid "Add Region (e.g. Prague)" msgstr "Regio toevoegen (bijv. nl)" #: data/ui/settings-view.ui:423 msgid "Default Scope" msgstr "Standaardbereik" #: data/ui/settings-view.ui:424 msgid "All flood packets will be scoped to this region if set" msgstr "Alle flood-pakketten worden aan deze regio gebonden als ingesteld" #: data/ui/settings-view.ui:434 data/ui/connection-view.ui:35 #: src/views/settings_view.py:134 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:435 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" "Na het wijzigen van de pincode: herstart het apparaat, vervolgens ontkoppel " "en koppel opnieuw. Instellen op 0 zet de pincode terug naar standaard." #: data/ui/settings-view.ui:438 msgid "Pairing PIN" msgstr "Koppelingspincode" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "Maak verbinding met een companion apparaat om Meshy te gaan gebruiken." #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "Koppel met een Nieuwe Companion" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "USB Serieel" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "WiFi / TCP" #: data/ui/connection-view.ui:84 src/views/connection_view.py:525 msgid "Add TCP Companion" msgstr "TCP Companion Toevoegen" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: src/views/channels_view.py:2156 msgid "Search..." msgstr "Zoeken..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/contacts_view.py:517 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "Geen contacten" #: data/ui/contacts-view.ui:54 src/views/contacts_view.py:525 #: src/views/contacts_view.py:713 src/views/channels_view.py:316 #: src/views/channels_view.py:501 src/views/chat_view.py:265 #: src/views/chat_view.py:427 msgid "Add Contact" msgstr "Contact Toevoegen" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "Selecteer een Contact" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "Kies een contact uit de lijst om te beginnen met chatten" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "↑ Nieuwe Berichten" #: data/ui/chat-view.ui:134 msgid "Type a message..." msgstr "Typ een bericht..." #: data/ui/channels-view.ui:10 src/views/channels_view.py:2465 msgid "Create a Private Channel" msgstr "Privékanaal Maken" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2499 msgid "Join a Private Channel" msgstr "Deelnemen aan een Privékanaal" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Deelnemen aan het Openbaar Kanaal" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2554 msgid "Join a Hashtag Channel" msgstr "Deelnemen aan een Hashtagkanaal" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "Geen kanalen" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "Kanaal Toevoegen" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "Selecteer een Kanaal" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "Kies een kanaal uit de lijst" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "Berichtkanaal..." #: data/ui/repeater-view.ui:37 msgid "System" msgstr "Systeem" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "Klok bij Aanmelding" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "Nu Synchroniseren" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:330 msgid "Reset & Reboot" msgstr "Resetten en Opnieuw Opstarten" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "Foutgebeurtenissen" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "TX Zendduur" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "RX Zendduur" #: data/ui/repeater-view.ui:137 #, fuzzy msgid "Channel Utilization" msgstr "Kanaalmeldingen" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "Verzonden" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "Ontvangen" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "Ontvangstfouten" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "Duplicaten" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "Vernieuwen" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:406 msgid "Request Telemetry" msgstr "Telemetrie Aanvragen" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "CLI-opdracht..." #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "Buren" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "Meer Laden" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:467 src/views/channels_view.py:1186 msgid "Show on Map" msgstr "Weergeven op Kaart" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "Toegangscontrole" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/contacts_view.py:404 #: src/views/contacts_view.py:549 src/views/repeater_view.py:790 #: src/views/repeater_view.py:1157 msgid "Add" msgstr "Toevoegen" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:885 msgid "Loading regions..." msgstr "Regio's worden geladen..." #: data/ui/repeater-view.ui:495 src/views/settings_view.py:995 msgid "Retry" msgstr "Opnieuw proberen" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "Standaard Regiobereik" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" "Flood-pakketten worden aan de opgegeven regio gebonden. Alleen repeaters die " "de regio toestaan, zullen gebonden pakketten doorsturen. Laat leeg voor niet-" "gebonden." #: data/ui/repeater-view.ui:550 src/window.py:553 #: src/views/settings_view.py:309 src/views/repeater_view.py:1126 msgid "Apply" msgstr "Toepassen" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "Basis" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:683 #: data/ui/repeater-view.ui:728 data/ui/repeater-view.ui:809 msgid "Fetch" msgstr "Ophalen" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:692 #: data/ui/repeater-view.ui:737 data/ui/repeater-view.ui:818 #: src/views/connection_view.py:499 msgid "Save" msgstr "Opslaan" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: src/views/contacts_view.py:319 src/views/contacts_view.py:537 #: src/views/contacts_view.py:703 src/views/channels_view.py:1859 #: src/views/channels_view.py:1873 src/views/channels_view.py:2190 msgid "Name" msgstr "Naam" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "Pakketherhaling" #: data/ui/repeater-view.ui:637 msgid "Passwords" msgstr "Wachtwoord" #: data/ui/repeater-view.ui:640 msgid "Admin Password" msgstr "Beheerderswachtwoord" #: data/ui/repeater-view.ui:654 msgid "Guest Password" msgstr "Gastwachtwoord" #: data/ui/repeater-view.ui:798 msgid "Advertisement" msgstr "Aankondiging" #: data/ui/repeater-view.ui:829 msgid "Local Interval (min)" msgstr "Lokale interval (min)" #: data/ui/repeater-view.ui:841 msgid "Flood Interval (hrs)" msgstr "Flood Interval (uren)" #: data/ui/repeater-view.ui:857 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "Gevarenzone" #: data/ui/repeater-view.ui:860 msgid "Reboot Repeater" msgstr "Herstart Repeater" #: data/ui/repeater-view.ui:884 msgid "Select a repeater" msgstr "Selecteer een Repeater" #: data/ui/repeater-view.ui:913 msgid "Log Out" msgstr "Afmelden" #: data/ui/sensor-view.ui:14 #, fuzzy msgid "Select a Sensor" msgstr "Selecteer een Repeater" #: data/ui/sensor-view.ui:76 #, fuzzy msgid "Refresh Telemetry" msgstr "Telemetrie Aanvragen" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "Aankondiging Verzenden" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "Zero Hop" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "Lokaal uitzenden" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "Flood Gerouteerd" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "Uitzenden door het mesh" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "Naar Klembord" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "Kopieer eigen contactgegevens" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "QR-code Weergeven" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "Zodat anderen je kunnen scannen en toevoegen" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:875 msgid "Trace Path" msgstr "Pad Traceren" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "Traceer een route en toon signaalkwaliteit per hop" #: data/ui/device-actions.ui:91 src/views/device_view.py:968 msgid "Discover Nearby Nodes" msgstr "Ontdek Nabijgelegen Knooppunten" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "Scan het netwerk op nabijgelegen knooppunten" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "Rx Log" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "Bekijk ontvangen radiopakketten" #: data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "Apparaat Opnieuw Opstarten" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "Log Wissen" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "Geen Pakketten" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "Ontvangen radiopakketten verschijnen hier" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "Back-up" #: data/ui/settings-sidebar.ui:23 #, fuzzy msgid "Manual" msgstr "Handmatig Toevoegen" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 #, fuzzy msgid "Automatic" msgstr "Automatisch starten" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1439 msgid "Restore" msgstr "Terugzetten" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 #, fuzzy msgid "Restore from a manually saved backup" msgstr "Terugzetten vanuit een back-upbestand" #: data/ui/settings-sidebar.ui:72 #, fuzzy msgid "From Auto Backup" msgstr "Back-up Exporteren" #: data/ui/settings-sidebar.ui:73 #, fuzzy msgid "Restore from an automatic snapshot" msgstr "Terugzetten vanuit een bestand" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:251 msgid "Factory Reset" msgstr "Fabrieksinstellingen Terugzetten" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "Wis alle gegevens op de companion" #: data/ui/manual-backup-dialog.ui:8 #, fuzzy msgid "Manual Backup" msgstr "Back-up" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 #, fuzzy msgid "Device Identity" msgstr "Apparaatinformatie" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 #, fuzzy msgid "Device Settings" msgstr "Instellingen" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 #, fuzzy msgid "Local Settings" msgstr "Instellingen" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 #, fuzzy msgid "Contact List" msgstr "Contacten" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 #, fuzzy msgid "Discovered Contacts" msgstr "Ontdek contacten" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 #, fuzzy msgid "Messages and Routing" msgstr "Berichtpad" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:2192 #, fuzzy msgid "Messages" msgstr "berichten" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 #, fuzzy msgid "Export" msgstr "Back-up Exporteren" #: data/ui/auto-backup-dialog.ui:8 #, fuzzy msgid "Automatic Backup" msgstr "Back-up Exporteren" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "Back-up Importeren" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:107 #: src/views/contacts_view.py:268 msgid "Discover Contacts" msgstr "Ontdek contacten" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: src/window.py:605 src/views/channels_view.py:2132 msgid "Search" msgstr "Zoeken" #: data/ui/discover-dialog.ui:27 #, fuzzy msgid "Filter by type" msgstr "Filter" #: data/ui/discover-dialog.ui:36 src/views/channels_view.py:2146 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:195 #: src/views/chat_view.py:163 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Kies Thema" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "Contactinformatie" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "Favoriet" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "Vastzetten bovenaan de lijst met contacten" #: data/ui/contact-detail-dialog.ui:56 src/window.py:506 #: src/views/channels_view.py:1880 msgid "Type" msgstr "Type" #: data/ui/contact-detail-dialog.ui:68 #, fuzzy msgid "Copy to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:320 #: src/views/channels_view.py:2191 msgid "Last Seen" msgstr "Laatst Gezien" #: data/ui/contact-detail-dialog.ui:89 #, fuzzy msgid "Routing" msgstr "Opnieuw opstarten..." #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "Uitgaand Pad" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "Pad Opnieuw Instellen" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "Maak het gevestigde pad vrij en schakel over naar flood" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 #, fuzzy msgid "Automatically Reset Path" msgstr "Automatisch starten bij aanmelden" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "Forceer Flood" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "Zend altijd uit, gebruik nooit een vastgesteld pad" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "Vraag de telemetriegegevens van dit contact op" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "Contact verwijderen" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1391 msgid "Path" msgstr "Pad invoer" #: data/ui/trace-path-dialog.ui:40 #, fuzzy msgid "Choose route on the map" msgstr "Kies een locatie op de kaart" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "Trace Uitvoeren" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "Voer pad-hashes in en druk op Trace Uitvoeren" #: src/application.py:116 src/application.py:187 src/window.py:684 #: src/views/contacts_view.py:532 src/views/contacts_view.py:698 #: src/views/channels_view.py:1913 src/views/device_view.py:1031 msgid "Chat" msgstr "Chat" #: src/application.py:116 src/views/contacts_view.py:532 #: src/views/contacts_view.py:698 src/views/device_view.py:1032 #: src/views/map_view.py:143 msgid "Repeater" msgstr "Repeater" #: src/application.py:116 src/views/map_view.py:144 msgid "Room" msgstr "Kamer" #: src/application.py:116 src/views/contacts_view.py:532 #: src/views/contacts_view.py:698 src/views/device_view.py:1034 #: src/views/map_view.py:145 msgid "Sensor" msgstr "Sensor" #: src/application.py:124 src/application.py:191 src/views/__init__.py:481 #: src/views/contact_detail_dialog.py:659 src/views/contacts_view.py:1494 #: src/views/channels_view.py:1284 src/views/device_view.py:901 #: src/views/device_view.py:1169 src/views/device_view.py:1177 #: src/views/settings_view.py:1468 msgid "Unknown" msgstr "Onbekend" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" "Naam: {name}\n" "Type: {type}" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" "Type: {type}\n" "Sleutel: {key}…" #: src/application.py:132 msgid "Import Contact" msgstr "Contact Importeren" #: src/application.py:136 msgid "Contact {}" msgstr "Contact {}" #: src/application.py:136 msgid "imported" msgstr "geïmporteerd" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "Ongeldige meshcore:/- link" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "Ongeldig kanaalgeheim in link" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "Ongeldige kanaalgeheimlengte" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" "Naam: {name}\n" "Type: Privé" #: src/application.py:161 msgid "Unnamed" msgstr "Naamloos" #: src/application.py:177 msgid "Invalid public key in link" msgstr "Ongeldige openbare sleutel in link" #: src/application.py:180 msgid "Invalid public key length" msgstr "Ongeldige lengte van de openbare sleutel" #: src/application.py:184 msgid "{} is already in your list" msgstr "{} is al in jouw lijst" #: src/application.py:184 src/views/repeater_view.py:806 msgid "Contact" msgstr "Contact" #: src/application.py:189 msgid "Add {} Contact" msgstr "{} Contact toevoegen" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" "Naam: {name}\n" "Openbare Sleutel: {key}…" #: src/application.py:195 msgid "Contact {} added" msgstr "Contact {} toegevoegd" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "Niet-herkende meshcore://-link" #: src/application.py:243 src/connection_controller.py:165 #: src/connection_controller.py:304 src/connection_controller.py:497 #: src/views/connection_view.py:183 src/views/connection_view.py:254 #: src/views/connection_view.py:458 src/views/connection_view.py:498 #: src/views/connection_view.py:542 src/views/connection_view.py:716 #: src/views/contact_detail_dialog.py:718 src/views/contacts_view.py:548 #: src/views/contacts_view.py:712 src/views/channels_view.py:2048 #: src/views/channels_view.py:2078 src/views/channels_view.py:2386 #: src/views/channels_view.py:2475 src/views/channels_view.py:2511 #: src/views/channels_view.py:2564 src/views/device_view.py:250 #: src/views/device_view.py:266 src/views/settings_view.py:1413 #: src/views/repeater_view.py:329 src/views/repeater_view.py:767 #: src/views/repeater_view.py:789 src/views/repeater_view.py:1156 #: src/views/repeater_view.py:1430 msgid "Cancel" msgstr "Annuleren" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "Berichten ontvangen op de achtergrond" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "Een GTK-client voor MeshCore LoRa mesh-netwerkapparaten" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "Adwaita Symbolische Iconen" #: src/connection_controller.py:156 src/connection_controller.py:307 #: src/window.py:1123 msgid "Connecting..." msgstr "Verbinden..." #: src/connection_controller.py:162 msgid "Scan for Devices" msgstr "Scannen Naar Apparaten" #: src/connection_controller.py:163 src/views/connection_view.py:614 msgid "Scanning for MeshCore devices..." msgstr "Scannen naar MeshCore apparaten..." #: src/connection_controller.py:166 msgid "Scan" msgstr "Scan" #: src/connection_controller.py:293 src/views/connection_view.py:634 #: src/views/settings_view.py:944 msgid "Scanning..." msgstr "Scannen..." #: src/connection_controller.py:294 msgid "Stop" msgstr "Stop" #: src/connection_controller.py:300 src/connection_controller.py:493 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Opnieuw verbinding maken ({current}/{total})..." #: src/connection_controller.py:318 msgid "Syncing..." msgstr "Synchroniseren..." #: src/connection_controller.py:371 msgid "Device did not respond. It may not support this connection type." msgstr "" "Apparaat reageerde niet. Het is mogelijk dat dit verbindingstype niet wordt " "ondersteund." #: src/connection_controller.py:571 #, fuzzy msgid "Syncing channels" msgstr "Geen kanalen" #: src/connection_controller.py:656 #, fuzzy msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "Je hebt {} bericht ontvangen terwijl de verbinding was verbroken." msgstr[1] "Je hebt {} berichten ontvangen terwijl de verbinding was verbroken." #: src/frame_handler.py:161 msgid "Device error: {}" msgstr "Apparaatfout: {}" #: src/frame_handler.py:291 src/frame_handler.py:309 #, fuzzy msgid "Syncing contacts" msgstr "Geen contacten" #: src/frame_handler.py:497 #, fuzzy msgid "{} (late)" msgstr "{}s (later)" #: src/frame_handler.py:497 msgid "late" msgstr "later" #: src/frame_handler.py:596 msgid "Device clock was {}min behind — synced" msgstr "Apparaatklok liep {}min achter — gesynchroniseerd" #: src/frame_handler.py:600 msgid "Device clock is {}min ahead of system" msgstr "Apparaatklok loopt {}min voor op het systeem" #: src/frame_handler.py:1205 src/window.py:831 src/views/contacts_view.py:1247 #: src/views/channels_view.py:1734 src/views/channels_view.py:1836 #: src/views/channels_view.py:1874 src/views/channels_view.py:2045 #: src/views/channels_view.py:2075 src/views/repeater_view.py:461 #: src/views/sensor_view.py:122 msgid "Channel {}" msgstr "Kanaal {}" #: src/frame_handler.py:1206 msgid "Someone" msgstr "Iemand" #: src/frame_handler.py:1209 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:175 src/views/repeater_view.py:725 #: src/views/repeater_view.py:756 msgid "Me" msgstr "Ik" #: src/message_controller.py:218 msgid "flood" msgstr "flood" #: src/message_controller.py:220 src/views/channels_view.py:635 msgid "direct" msgstr "direct" #: src/message_controller.py:222 msgid "path" msgstr "pad" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:285 #, fuzzy msgid "waiting {} (radio busy)" msgstr "wachten {:.1f}s (radio bezet)" #: src/window.py:486 msgid "Custom Map Tiles" msgstr "" #: src/window.py:505 msgid "Raster" msgstr "" #: src/window.py:505 msgid "Vector" msgstr "" #: src/window.py:511 src/window.py:533 msgid "URL Template" msgstr "" #: src/window.py:517 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:521 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:533 msgid "Style JSON URL" msgstr "" #: src/window.py:541 #, fuzzy msgid "Reset to Default" msgstr "Pad Opnieuw Instellen" #: src/window.py:564 msgid "Custom map tiles applied" msgstr "" #: src/window.py:566 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:608 src/window.py:708 src/views/channels_view.py:2139 msgid "Filter" msgstr "Filter" #: src/window.py:611 msgid "Sort" msgstr "Sorteren" #: src/window.py:671 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:2035 src/views/repeater_view.py:1016 msgid "Actions" msgstr "Acties" #: src/window.py:696 msgid "Channel" msgstr "Kanaal" #: src/window.py:730 msgid "Backup & Restore" msgstr "Back-up & Terugzetten" #: src/window.py:812 src/window.py:876 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:833 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:898 msgid "Management" msgstr "Beheer" #: src/window.py:898 src/views/repeater_view.py:730 #: src/views/repeater_view.py:798 msgid "Guest" msgstr "Gast" #: src/window.py:899 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1000 msgid "Excellent" msgstr "" #: src/window.py:1001 msgid "Good" msgstr "" #: src/window.py:1002 msgid "Fair" msgstr "" #: src/window.py:1003 msgid "Poor" msgstr "" #: src/window.py:1004 msgid "Very poor" msgstr "" #: src/window.py:1228 msgid "flood routed" msgstr "flood gerouteerd" #: src/window.py:1228 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1229 msgid "Advert sent ({})" msgstr "Aankondiging verzonden ({})" #: src/window.py:1711 msgid "Channel \"{}\" added" msgstr "Kanaal \"{}\" toegevoegd" #: src/window.py:1715 msgid "No empty channel slots available" msgstr "Geen lege kanaalslots beschikbaar" #: src/qr_scanner.py:47 msgid "Cannot connect to session bus: {}" msgstr "Kan geen verbinding maken met sessiebus: {}" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "Geen camera gedetecteerd op dit systeem." #: src/qr_scanner.py:121 msgid "Could not access camera: {}" msgstr "Kon geen toegang krijgen tot de camera: {}" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "Toegang tot de camera is geweigerd." #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "Kan camerastream niet openen." #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "Geen camerabestandsbeschrijving ontvangen." #: src/qr_scanner.py:179 src/qr_scanner.py:182 msgid "Could not open camera: {}" msgstr "Kon camera niet openen: {}" #: src/qr_scanner.py:187 src/views/contacts_view.py:113 msgid "Scan QR Code" msgstr "QR-Code Scannen" #: src/qr_scanner.py:212 msgid "Point the camera at a QR code..." msgstr "Statuslabel" #: src/qr_scanner.py:235 msgid "Camera pipeline error: {}" msgstr "Fout in camerapijplijn: {}" #: src/qr_scanner.py:312 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" "Nog geen QR-code gedetecteerd. \n" "Zorg ervoor dat de code goed belicht en scherp is en het grootste deel van " "het frame vult." #: src/qr_scanner.py:323 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" "Kon QR-code niet detecteren. \n" "Probeer in plaats daarvan het contact via het klembord te importeren." #: src/qr_scanner.py:329 msgid "QR code found!" msgstr "QR-code gevonden!" #: src/qr_scanner.py:339 msgid "Camera error: {}" msgstr "Camerafout: {}" #: src/qr_scanner.py:357 msgid "Camera Error" msgstr "Camerafout" #: src/qr_scanner.py:358 src/views/connection_view.py:216 #: src/views/connection_view.py:348 src/views/connection_view.py:355 #: src/views/contacts_view.py:275 src/views/contacts_view.py:588 #: src/views/contacts_view.py:685 src/views/channels_view.py:1111 #: src/views/channels_view.py:1349 src/views/channels_view.py:1371 #: src/views/channels_view.py:2455 src/views/channels_view.py:2535 #: src/views/settings_view.py:1258 src/views/settings_view.py:1368 #: src/views/settings_view.py:1397 src/views/settings_view.py:1408 #: src/views/settings_view.py:1463 src/views/chat_helpers.py:161 msgid "OK" msgstr "OK" #: src/views/__init__.py:51 msgid "Today" msgstr "" #: src/views/__init__.py:53 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:58 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:61 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/connection_view.py:43 src/views/connection_view.py:184 #: src/views/connection_view.py:407 src/views/connection_view.py:459 #: src/views/contact_detail_dialog.py:719 src/views/channels_view.py:2079 #: src/views/repeater_view.py:768 src/views/repeater_view.py:1033 msgid "Remove" msgstr "Verwijder" #: src/views/connection_view.py:79 #, fuzzy msgid "Bluetooth not available" msgstr "Bluetooth is uitgeschakeld" #: src/views/connection_view.py:80 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:93 msgid "Bluetooth is disabled" msgstr "Bluetooth is uitgeschakeld" #: src/views/connection_view.py:94 msgid "Enable Bluetooth in system settings to connect" msgstr "Schakel Bluetooth in de systeeminstellingen in om verbinding te maken" #: src/views/connection_view.py:104 msgid "No paired devices" msgstr "Geen gekoppelde apparaten" #: src/views/connection_view.py:105 msgid "Use the button below to pair a new companion" msgstr "Gebruik de onderstaande knop om een nieuwe companion te koppelen" #: src/views/connection_view.py:150 msgid "No USB devices detected" msgstr "Geen USB-apparaten gedetecteerd" #: src/views/connection_view.py:151 msgid "Connect a MeshCore companion via USB" msgstr "Verbind een MeshCore companion via USB" #: src/views/connection_view.py:178 src/views/connection_view.py:455 msgid "Remove Companion" msgstr "Verwijder Companion" #: src/views/connection_view.py:179 msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" "{} verwijderen ({})? Je zal opnieuw moeten koppelen om verbinding te maken." #: src/views/connection_view.py:187 src/views/connection_view.py:462 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:213 msgid "USB Connection Failed" msgstr "USB-Verbinding Mislukt" #: src/views/connection_view.py:246 src/views/connection_view.py:275 msgid "USB Permission Denied" msgstr "USB-Toestemming Geweigerd" #: src/views/connection_view.py:248 msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" "Geen toegang tot {}. \n" "\n" "Er is een udev-regel nodig om toegang te verlenen tot seriële USB-apparaten. " "Klik op \"Installeer Regel\" om deze te installeren (beheerderswachtwoord " "vereist)." #: src/views/connection_view.py:255 msgid "Install Rule" msgstr "Installeer Regel" #: src/views/connection_view.py:277 #, fuzzy msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" "Geen toegang tot {}. \n" "\n" "Er is een udev-regel nodig om toegang te verlenen tot seriële USB-apparaten. " "Klik op \"Installeer Regel\" om deze te installeren (beheerderswachtwoord " "vereist)." #: src/views/connection_view.py:283 msgid "Close" msgstr "" #: src/views/connection_view.py:284 #, fuzzy msgid "Copy Command" msgstr "CLI-opdracht..." #: src/views/connection_view.py:292 #, fuzzy msgid "Command copied to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: src/views/connection_view.py:345 src/views/connection_view.py:352 msgid "Installation Failed" msgstr "Installatie Mislukt" #: src/views/connection_view.py:346 msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Kon udev-regel niet installeren: \n" "{}" #: src/views/connection_view.py:382 msgid "No saved companions" msgstr "Geen opgeslagen companions" #: src/views/connection_view.py:383 msgid "Use the button below to add one" msgstr "Gebruik de onderstaande knop om er een toe te voegen" #: src/views/connection_view.py:406 src/views/connection_view.py:480 #, fuzzy msgid "Change Address" msgstr "IP-adres" #: src/views/connection_view.py:456 msgid "Remove TCP companion {}?" msgstr "TCP-companion {} verwijderen?" #: src/views/connection_view.py:481 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:485 src/views/connection_view.py:530 msgid "Hostname / IP Address" msgstr "" #: src/views/connection_view.py:488 src/views/connection_view.py:532 msgid "Port" msgstr "Poort" #: src/views/connection_view.py:526 msgid "Enter the IP address and port of your MeshCore device." msgstr "Voer het IP-adres en de poort van uw MeshCore-apparaat in." #: src/views/connection_view.py:594 msgid "Pair New Companion" msgstr "Nieuwe Companion Koppelen" #: src/views/connection_view.py:635 msgid "Looking for nearby MeshCore devices" msgstr "Zoeken naar MeshCore-apparaten in de buurt" #: src/views/connection_view.py:663 src/views/connection_view.py:717 msgid "Pair" msgstr "Koppelen" #: src/views/connection_view.py:703 msgid "Enter Pairing PIN" msgstr "Koppelingspincode Invoeren" #: src/views/connection_view.py:704 msgid "Enter the PIN shown on your MeshCore device." msgstr "Voer de pincode in die wordt weergegeven op uw MeshCore-apparaat." #: src/views/connection_view.py:708 msgid "PIN" msgstr "PIN" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2256 msgid "Never" msgstr "Nooit" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "Toon Pad op Kaart" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "Visualiseer de berichtenroute op de kaart" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "Telemetrieverzoeken Toestaan" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "Laat dit contact de batterij, spanning en temperatuur opvragen" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "Inclusief Locatie" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "Neem GPS-coördinaten op in telemetriereacties" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "Inclusief Omgevingssensoren" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "Voeg gegevens over vochtigheid, druk en luchtkwaliteit toe" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "Kamerbeheer" #: src/views/contact_detail_dialog.py:162 msgid "Login and access CLI, status, and settings" msgstr "Log in en krijg toegang tot CLI, status en instellingen" #: src/views/contact_detail_dialog.py:179 msgid "Repeater Management" msgstr "Repeaterbeheer" #: src/views/contact_detail_dialog.py:180 msgid "Login and access status, CLI, neighbors, and settings" msgstr "Log in en krijg toegang tot status, CLI, buren, en instellingen" #: src/views/contact_detail_dialog.py:198 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:199 msgid "Measure round-trip time to this node" msgstr "Meet de retourtijd naar dit knooppunt" #: src/views/contact_detail_dialog.py:214 msgid "Discover Paths" msgstr "Ontdek Paden" #: src/views/contact_detail_dialog.py:215 msgid "Find routes to this node via flood" msgstr "Vind routes naar dit knooppunt via flood" #: src/views/contact_detail_dialog.py:233 #, fuzzy msgid "Query regions from this repeater" msgstr "{} repeater opvragen..." #: src/views/contact_detail_dialog.py:243 #, fuzzy msgid "Querying..." msgstr "Zoeken..." #: src/views/contact_detail_dialog.py:256 src/views/contacts_view.py:1094 msgid "No response" msgstr "Geen reactie" #: src/views/contact_detail_dialog.py:342 src/views/device_view.py:165 msgid "Public key copied" msgstr "Openbare sleutel gekopieerd" #: src/views/contact_detail_dialog.py:402 #: src/views/contact_detail_dialog.py:446 #, fuzzy, python-format msgid "%d paths available" msgstr "Geen Slots Beschikbaar" #: src/views/contact_detail_dialog.py:402 #: src/views/contact_detail_dialog.py:448 #: src/views/contact_detail_dialog.py:552 #, fuzzy msgid "No paths yet" msgstr "Er is geen pad in de cache opgeslagen" #: src/views/contact_detail_dialog.py:460 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:488 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:499 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:500 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:502 #: src/views/contact_detail_dialog.py:698 #, fuzzy, python-format msgid "%d hops" msgstr "hops" #: src/views/contact_detail_dialog.py:538 #, fuzzy msgid "No paths recorded yet" msgstr "Nog geen herhalingen gehoord." #: src/views/contact_detail_dialog.py:539 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:546 #, fuzzy msgid "Clear History" msgstr "Log Wissen" #: src/views/contact_detail_dialog.py:620 #, fuzzy msgid "No location data available" msgstr "Locatie niet beschikbaar" #: src/views/contact_detail_dialog.py:624 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:655 src/views/contacts_view.py:1295 #: src/views/contacts_view.py:1488 src/views/channels_view.py:1277 #: src/views/device_view.py:895 src/views/sensor_view.py:178 #: src/views/chat_helpers.py:75 src/views/map_view.py:513 #: src/views/map_view.py:809 src/views/map_view.py:1183 msgid "My Device" msgstr "Mijn Apparaat" #: src/views/contact_detail_dialog.py:715 msgid "Remove Contact?" msgstr "Contact Verwijderen?" #: src/views/contact_detail_dialog.py:716 msgid "Remove {} and all messages?" msgstr "{} en alle berichten verwijderen?" #: src/views/contacts_view.py:108 msgid "Add Manually" msgstr "Handmatig Toevoegen" #: src/views/contacts_view.py:109 msgid "Import from Clipboard" msgstr "Importeren vanaf Klembord" #: src/views/contacts_view.py:162 src/views/contacts_view.py:299 #: src/views/channels_view.py:1628 src/views/channels_view.py:2183 msgid "All" msgstr "Alle" #: src/views/contacts_view.py:163 msgid "Favourites" msgstr "Favorieten" #: src/views/contacts_view.py:164 src/views/contacts_view.py:300 #: src/views/map_view.py:242 msgid "Users" msgstr "Gebruikers" #: src/views/contacts_view.py:178 src/views/channels_view.py:1641 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:179 msgid "Heard Recently" msgstr "Onlangs Gehoord" #: src/views/contacts_view.py:180 src/views/channels_view.py:1642 msgid "Latest Messages" msgstr "Laatste Berichten" #: src/views/contacts_view.py:187 msgid "Favorites First" msgstr "Favorieten Eerst" #: src/views/contacts_view.py:223 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contacten" #: src/views/contacts_view.py:227 src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "{total} contacten" #: src/views/contacts_view.py:229 #, python-brace-format msgid "{total} contact" msgstr "{total} contact" #: src/views/contacts_view.py:270 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" "Er zijn nog geen nieuwe contacten vernomen. Laat de app actief en de " "contacten verschijnen zodra hun aankondigingen worden ontvangen." #: src/views/contacts_view.py:321 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:325 msgid "Distance" msgstr "" #: src/views/contacts_view.py:519 #, fuzzy, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} contacten" #: src/views/contacts_view.py:521 #, fuzzy, python-brace-format msgid "{total} discovered" msgstr "{total} contact" #: src/views/contacts_view.py:526 msgid "Enter the contact details." msgstr "Voer de contactgegevens in." #: src/views/contacts_view.py:530 src/views/contacts_view.py:696 msgid "Contact Type" msgstr "Contacttype" #: src/views/contacts_view.py:532 src/views/contacts_view.py:698 #: src/views/device_view.py:1033 msgid "Room Server" msgstr "Kamerserver" #: src/views/contacts_view.py:538 msgid "Public Key (64 hex characters)" msgstr "Openbare sleutel (64 hexadecimale tekens)" #: src/views/contacts_view.py:561 src/views/contacts_view.py:649 #: src/views/contacts_view.py:674 msgid "This contact is already in your list." msgstr "Dit contact staat al in uw lijst." #: src/views/contacts_view.py:585 src/views/contacts_view.py:684 #: src/views/settings_view.py:1460 msgid "Import Failed" msgstr "Importeren Mislukt" #: src/views/contacts_view.py:586 msgid "No meshcore:// link found in clipboard." msgstr "Geen meshcore://-link gevonden op het klembord." #: src/views/contacts_view.py:618 msgid "Invalid channel secret in QR code." msgstr "Ongeldig kanaalgeheim in QR-code." #: src/views/contacts_view.py:622 msgid "Channel secret must be 16 bytes, got {}." msgstr "Kanaalgeheim moet 16 bytes zijn, kreeg {}." #: src/views/contacts_view.py:639 msgid "Invalid public key in QR code." msgstr "Ongeldige openbare sleutel in QR-code." #: src/views/contacts_view.py:644 msgid "Public key must be 32 bytes, got {}." msgstr "Openbare sleutel moet 32 bytes zijn, kreeg {}." #: src/views/contacts_view.py:681 msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Kon QR-codegegevens niet parseren: \n" "{}" #: src/views/contacts_view.py:691 msgid "QR Code Scanned" msgstr "QR-Code Gescand" #: src/views/contacts_view.py:692 msgid "Public key: {}...{}" msgstr "Openbare sleutel: {}...{}" #: src/views/contacts_view.py:766 src/views/sensor_view.py:209 msgid "Login to {}" msgstr "Inloggen op {}" #: src/views/contacts_view.py:783 src/views/sensor_view.py:226 msgid "Password" msgstr "Wachtwoord" #: src/views/contacts_view.py:784 msgid "Save password" msgstr "Wachtwoord opslaan" #: src/views/contacts_view.py:807 src/views/sensor_view.py:232 msgid "Login" msgstr "Login" #: src/views/contacts_view.py:823 msgid "Login successful" msgstr "Inloggen succesvol" #: src/views/contacts_view.py:843 msgid "Login failed — wrong password" msgstr "Inloggen mislukt - verkeerd wachtwoord" #: src/views/contacts_view.py:850 msgid "Login timed out" msgstr "Er is een time-out voor inloggen opgetreden" #: src/views/contacts_view.py:857 msgid "Retrying via flood..." msgstr "Opnieuw proberen via flood..." #: src/views/contacts_view.py:864 src/views/sensor_view.py:252 msgid "Logging in..." msgstr "Inloggen..." #: src/views/contacts_view.py:1035 msgid "Searching..." msgstr "Zoeken..." #: src/views/contacts_view.py:1088 msgid "Path found" msgstr "Gevonden pad" #: src/views/contacts_view.py:1103 msgid "Loading..." msgstr "Laden..." #: src/views/contacts_view.py:1113 msgid "No path cached" msgstr "Er is geen pad in de cache opgeslagen" #: src/views/contacts_view.py:1126 msgid "Not supported" msgstr "Niet ondersteund" #: src/views/contacts_view.py:1137 msgid "Ping is not supported with 3-byte path hashes" msgstr "Ping wordt niet ondersteund met pad-hashes van 3 bytes" #: src/views/contacts_view.py:1160 msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:1171 msgid "Ping {}: timed out" msgstr "Ping {}: timed-out" #: src/views/contacts_view.py:1184 src/views/repeater_view.py:550 #: src/views/repeater_view.py:704 src/views/sensor_view.py:54 msgid "Requesting..." msgstr "Aanvragen..." #: src/views/contacts_view.py:1189 src/views/device_view.py:398 #: src/views/sensor_view.py:275 msgid "No response (timed out)" msgstr "Geen reactie (time-out)" #: src/views/contacts_view.py:1198 src/views/contacts_view.py:1202 #: src/views/device_view.py:408 src/views/device_view.py:412 #: src/views/sensor_view.py:96 src/views/sensor_view.py:103 msgid "No telemetry data" msgstr "Geen telemetriegegevens" #: src/views/contacts_view.py:1205 src/views/device_view.py:427 msgid "Telemetry received" msgstr "Telemetrie ontvangen" #: src/views/contacts_view.py:1224 src/views/repeater_view.py:438 msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1344 msgid "{} — Management" msgstr "{} — Beheer" #: src/views/contacts_view.py:1467 msgid "Path to {}" msgstr "Pad naar {}" #: src/views/contacts_view.py:1519 src/views/channels_view.py:1173 #: src/views/channels_view.py:1285 src/views/device_view.py:902 #: src/views/map_view.py:844 msgid "Unknown repeater" msgstr "Onbekende repeater" #: src/views/channels_view.py:138 src/views/channels_view.py:1346 #: src/views/channels_view.py:1353 msgid "Heard Repeats" msgstr "Gehoorde Herhalingen" #: src/views/channels_view.py:139 src/views/chat_view.py:97 msgid "Send Again" msgstr "Opnieuw Verzenden" #: src/views/channels_view.py:140 src/views/channels_view.py:146 #: src/views/chat_view.py:98 msgid "Delete" msgstr "Verwijderen" #: src/views/channels_view.py:143 msgid "Reply" msgstr "Antwoord" #: src/views/channels_view.py:144 msgid "Copy Text" msgstr "Tekst Kopiëren" #: src/views/channels_view.py:145 msgid "View Message Paths" msgstr "Berichtpaden Bekijken" #: src/views/channels_view.py:196 src/views/chat_helpers.py:176 #: src/views/chat_view.py:164 msgid "Share Location" msgstr "" #: src/views/channels_view.py:197 src/views/chat_view.py:165 #, fuzzy msgid "Share Location from Map" msgstr "Locatie handmatig instellen" #: src/views/channels_view.py:350 src/views/chat_view.py:299 msgid "Open in Maps App" msgstr "" #: src/views/channels_view.py:483 src/views/chat_view.py:409 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:487 src/views/chat_view.py:413 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:501 src/views/channels_view.py:617 #: src/views/chat_view.py:427 src/views/chat_view.py:539 msgid "Already added" msgstr "" #: src/views/channels_view.py:518 src/views/chat_view.py:444 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:522 src/views/chat_view.py:448 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:585 src/views/chat_view.py:507 msgid "New Messages" msgstr "Nieuwe Berichten" #: src/views/channels_view.py:626 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:639 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1105 src/views/channels_view.py:1116 msgid "Message Paths" msgstr "Berichtpaden" #: src/views/channels_view.py:1107 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" "Geen padinformatie beschikbaar. Padgegevens worden alleen vastgelegd voor " "berichten die worden ontvangen terwijl er verbinding is." #: src/views/channels_view.py:1194 msgid "Direct message" msgstr "Direct bericht" #: src/views/channels_view.py:1195 msgid "No repeaters were used" msgstr "Er zijn geen repeaters gebruikt" #: src/views/channels_view.py:1202 msgid "No repeater details" msgstr "Geen repeatergegevens" #: src/views/channels_view.py:1203 msgid "Path data is only captured via LOG_DATA while connected" msgstr "Padgegevens worden alleen vastgelegd via LOG_DATA als er verbinding is" #: src/views/channels_view.py:1255 msgid "Message Path" msgstr "Berichtpad" #: src/views/channels_view.py:1347 msgid "No repeats heard yet." msgstr "Nog geen herhalingen gehoord." #: src/views/channels_view.py:1629 src/views/channels_view.py:2545 msgid "Public" msgstr "Openbaar" #: src/views/channels_view.py:1630 msgid "Hashtag" msgstr "Hashtag" #: src/views/channels_view.py:1631 msgid "Private" msgstr "Privé" #: src/views/channels_view.py:1676 #, python-brace-format msgid "{visible}/{total} channels" msgstr "{visible}/{total} kanalen" #: src/views/channels_view.py:1680 #, python-brace-format msgid "{total} channels" msgstr "{total} kanalen" #: src/views/channels_view.py:1682 #, python-brace-format msgid "{total} channel" msgstr "{total} kanaal" #: src/views/channels_view.py:1856 msgid "Channel Info" msgstr "Kanaalinformatie" #: src/views/channels_view.py:1888 msgid "Private Key" msgstr "Privésleutel" #: src/views/channels_view.py:1899 msgid "Private key copied to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: src/views/channels_view.py:1916 src/views/channels_view.py:2123 msgid "Participants" msgstr "" #: src/views/channels_view.py:1917 src/views/channels_view.py:2275 #: src/views/channels_view.py:2281 msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1930 #, fuzzy msgid "Message Retention" msgstr "Berichtenwachtrij" #: src/views/channels_view.py:1933 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1934 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1935 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1936 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1937 #, fuzzy msgid "Forever" msgstr "Nooit" #: src/views/channels_view.py:1956 msgid "Notification Level" msgstr "Meldingsniveau" #: src/views/channels_view.py:1958 msgid "Default" msgstr "Standaard" #: src/views/channels_view.py:1988 src/views/channels_view.py:1993 msgid "Region" msgstr "Regio" #: src/views/channels_view.py:1989 msgid "Limit flood messages to a specific region" msgstr "Beperk flood-berichten tot een specifieke regio" #: src/views/channels_view.py:2017 #, fuzzy msgid "No regions defined" msgstr "Geen regio's gevonden" #: src/views/channels_view.py:2018 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2037 #, fuzzy msgid "Clear Messages" msgstr "Alle berichten" #: src/views/channels_view.py:2043 #, fuzzy msgid "Clear Messages?" msgstr "Alle berichten" #: src/views/channels_view.py:2044 msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:2049 src/views/map_view.py:1232 #, fuzzy msgid "Clear" msgstr "Log Wissen" #: src/views/channels_view.py:2057 #, fuzzy msgid "Messages cleared" msgstr "Berichtenwachtrij" #: src/views/channels_view.py:2067 msgid "Remove Channel" msgstr "Kanaal verwijderen" #: src/views/channels_view.py:2073 msgid "Remove Channel?" msgstr "Kanaal verwijderen?" #: src/views/channels_view.py:2074 msgid "Remove \"{}\" and all its messages?" msgstr "'{}' en alle bijbehorende berichten verwijderen?" #: src/views/channels_view.py:2183 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2259 msgid "Just now" msgstr "" #: src/views/channels_view.py:2262 #, fuzzy msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "{}m geleden" msgstr[1] "{}m geleden" #: src/views/channels_view.py:2265 #, fuzzy msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{}h geleden" msgstr[1] "{}h geleden" #: src/views/channels_view.py:2267 #, fuzzy msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{}d geleden" msgstr[1] "{}d geleden" #: src/views/channels_view.py:2277 msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2307 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2313 src/views/channels_view.py:2387 msgid "Block" msgstr "" #: src/views/channels_view.py:2322 #, fuzzy msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} berichten" msgstr[1] "{} berichten" #: src/views/channels_view.py:2334 msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2379 msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2381 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2388 #, fuzzy msgid "Block and Delete Messages" msgstr "Laatste Berichten" #: src/views/channels_view.py:2405 msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2452 msgid "No Slots Available" msgstr "Geen Slots Beschikbaar" #: src/views/channels_view.py:2453 msgid "All 8 channel slots are in use." msgstr "Alle 8 kanaalslots zijn in gebruik." #: src/views/channels_view.py:2466 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" "Er wordt een willekeurige PSK gegenereerd. Deel die met anderen, zodat zij " "lid kunnen worden." #: src/views/channels_view.py:2469 src/views/channels_view.py:2503 msgid "Channel Name" msgstr "Kanaalnaam" #: src/views/channels_view.py:2476 msgid "Create" msgstr "Aanmaken" #: src/views/channels_view.py:2500 msgid "Enter the channel name and private key shared with you." msgstr "Voer de kanaalnaam en de privésleutel in die met u zijn gedeeld." #: src/views/channels_view.py:2504 msgid "Private Key (32 hex characters)" msgstr "Privésleutel (32 hexadecimale tekens)" #: src/views/channels_view.py:2512 src/views/channels_view.py:2565 msgid "Join" msgstr "Lid Worden" #: src/views/channels_view.py:2533 msgid "Already Joined" msgstr "Al Lid" #: src/views/channels_view.py:2533 msgid "You are already on the public channel." msgstr "Je bent al op het openbare kanaal." #: src/views/channels_view.py:2555 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Voer een hashtagnaam in. Iedereen die dezelfde hashtag gebruikt, kan " "communiceren." #: src/views/channels_view.py:2558 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (bv. #algemeen)" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "Openbare sleutel van apparaat niet beschikbaar" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "QR-codebibliotheek niet beschikbaar" #: src/views/device_view.py:190 msgid "Your Contact QR Code" msgstr "Uw Contact-QR-Code" #: src/views/device_view.py:232 msgid "Scan this QR code to add this contact" msgstr "Scan deze QR-code om dit contact toe te voegen" #: src/views/device_view.py:242 msgid "Factory Reset?" msgstr "Fabrieksreset?" #: src/views/device_view.py:244 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" "Hierdoor worden ALLE gegevens op het companion apparaat permanent gewist, " "inclusief contacten, berichten, kanalen, instellingen en de " "apparaatidentiteit (sleutels). Deze actie kan niet ongedaan worden gemaakt." #: src/views/device_view.py:263 msgid "Reboot Device?" msgstr "Apparaat Opnieuw Opstarten?" #: src/views/device_view.py:264 msgid "The device will disconnect and restart." msgstr "Het apparaat wordt losgekoppeld en opnieuw opgestart." #: src/views/device_view.py:267 src/views/repeater_view.py:1431 msgid "Reboot" msgstr "Opnieuw Opstarten" #: src/views/device_view.py:350 msgid "Not set" msgstr "Niet ingesteld" #: src/views/device_view.py:359 src/views/device_view.py:367 msgid "Location (manually set)" msgstr "Locatie (handmatig ingesteld)" #: src/views/device_view.py:363 msgid "Location (GPS)" msgstr "Locatie (GPS)" #: src/views/device_view.py:363 msgid "Location (GPS on, no fix)" msgstr "Locatie (GPS aan, geen fix)" #: src/views/device_view.py:367 msgid "Location (GPS off)" msgstr "Locatie (GPS uit)" #: src/views/device_view.py:392 src/views/repeater_view.py:400 msgid "Requesting…" msgstr "Aanvragen…" #: src/views/device_view.py:439 msgid "Device Location" msgstr "Apparaatlocatie" #: src/views/device_view.py:491 #, python-brace-format msgid "{days}d" msgstr "{days}d" #: src/views/device_view.py:493 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:494 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:496 msgid "{} messages" msgstr "{} berichten" #: src/views/device_view.py:517 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:532 src/views/device_view.py:534 msgid "Connected" msgstr "Verbonden" #: src/views/device_view.py:546 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "Traceer Pad wordt niet ondersteund met pad-hashes van 3 bytes" #: src/views/device_view.py:568 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:568 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:568 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:661 #, fuzzy msgid "No repeaters with known location" msgstr "Geen repeaters in Contacten" #: src/views/device_view.py:727 #, fuzzy msgid "Trace complete: {} hops, {}" msgstr "Trace voltooid: {} hops, {:.1f}s" #: src/views/device_view.py:733 msgid "Result" msgstr "" #: src/views/device_view.py:781 msgid "Trace timed out" msgstr "Er is een time-out opgetreden bij het traceren" #: src/views/device_view.py:797 msgid "Invalid hex in path" msgstr "Ongeldige hex in pad" #: src/views/device_view.py:808 msgid "Tracing..." msgstr "Traceren..." #: src/views/device_view.py:988 src/views/device_view.py:1188 msgid "Scanning... {}s remaining" msgstr "Bezig met scannen... {}s resterend" #: src/views/device_view.py:1008 msgid "Listening..." msgstr "Luisteren..." #: src/views/device_view.py:1009 msgid "Waiting for nearby nodes to respond" msgstr "Wachten tot nabijgelegen knooppunten reageren" #: src/views/device_view.py:1064 msgid "Add to Contacts" msgstr "Toevoegen aan Contacten" #: src/views/device_view.py:1071 msgid "Added {}" msgstr "{} toegevoegd" #: src/views/device_view.py:1100 msgid "Node" msgstr "Knooppunt" #: src/views/device_view.py:1197 msgid "Done. {} nodes found." msgstr "Klaar. {} knoppunten gevonden." #: src/views/device_view.py:1199 msgid "Done. {} node found." msgstr "Klaar. {} knoppunt gevonden." #: src/views/device_view.py:1237 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1386 msgid "Size" msgstr "Grootte" #: src/views/device_view.py:1386 msgid "bytes" msgstr "bytes" #: src/views/device_view.py:1391 msgid "hops" msgstr "hops" #: src/views/device_view.py:1392 msgid "Path Hashes" msgstr "Pad-hashes" #: src/views/device_view.py:1392 msgid "byte per hop" msgstr "byte per hop" #: src/views/device_view.py:1467 #, python-brace-format msgid "Update available: {version}" msgstr "Update beschikbaar: {version}" #: src/views/settings_view.py:139 msgid "Custom" msgstr "Aangepast" #: src/views/settings_view.py:147 msgid "Deny All" msgstr "Alles Weigeren" #: src/views/settings_view.py:147 msgid "Allow per Contact" msgstr "Toestaan per Contact" #: src/views/settings_view.py:147 msgid "Allow All" msgstr "Iedereen toestaan" #: src/views/settings_view.py:166 #, fuzzy msgid "Discover regions from repeaters" msgstr "Ontdek Regio's" #: src/views/settings_view.py:646 msgid "Settings applied on device" msgstr "Instellingen toegepast op apparaat" #: src/views/settings_view.py:659 msgid "Unknown error" msgstr "Onbekende fout" #: src/views/settings_view.py:660 msgid "Settings error: {}" msgstr "Instellingenfout: {}" #: src/views/settings_view.py:682 msgid "TX Power (dBm, max {})" msgstr "TX-vermogen (dBm, max. {})" #: src/views/settings_view.py:777 msgid "Location unavailable" msgstr "Locatie niet beschikbaar" #: src/views/settings_view.py:781 msgid "Location filled, click Apply to save" msgstr "Locatie ingevuld, klik op Toepassen om op te slaan" #: src/views/settings_view.py:804 msgid "Pick Location" msgstr "Kies Locatie" #: src/views/settings_view.py:804 msgid "Set Location" msgstr "Locatie Instellen" #: src/views/settings_view.py:909 msgid "Connect to a device first" msgstr "Maak eerst verbinding met een apparaat" #: src/views/settings_view.py:913 msgid "Discover Regions" msgstr "Ontdek Regio's" #: src/views/settings_view.py:932 src/views/settings_view.py:1140 #, fuzzy msgid "Discovering repeaters in range..." msgstr "{} repeaters opvragen..." #: src/views/settings_view.py:945 #, fuzzy msgid "Listening for nearby repeaters" msgstr "Zoeken naar MeshCore-apparaten in de buurt" #: src/views/settings_view.py:959 src/views/settings_view.py:988 #: src/views/settings_view.py:1141 msgid "Add Selected" msgstr "Geselecteerde Toevoegen" #: src/views/settings_view.py:984 msgid "Found {} regions" msgstr "{} regio's gevonden" #: src/views/settings_view.py:986 msgid "Found {} region" msgstr "{} regio gevonden" #: src/views/settings_view.py:992 msgid "No regions found" msgstr "Geen regio's gevonden" #: src/views/settings_view.py:994 #, fuzzy msgid "No repeaters in range" msgstr "Geen repeaters in Contacten" #: src/views/settings_view.py:1010 src/views/settings_view.py:1019 msgid "from {}" msgstr "van {}" #: src/views/settings_view.py:1012 src/views/settings_view.py:1021 msgid "(already added)" msgstr "(al toegevoegd)" #: src/views/settings_view.py:1089 msgid "Querying {} repeaters..." msgstr "{} repeaters opvragen..." #: src/views/settings_view.py:1091 msgid "Querying {} repeater..." msgstr "{} repeater opvragen..." #: src/views/settings_view.py:1185 msgid "Added {} regions" msgstr "{} regio's toegevoegd" #: src/views/settings_view.py:1187 msgid "Added {} region" msgstr "{} regio toegevoegd" #: src/views/settings_view.py:1255 src/views/settings_view.py:1365 #: src/views/settings_view.py:1394 msgid "Not Connected" msgstr "Niet Verbonden" #: src/views/settings_view.py:1256 msgid "Connect to a device first to export a backup." msgstr "Maak eerst verbinding met een apparaat om een back-up te exporteren." #: src/views/settings_view.py:1294 src/views/settings_view.py:1375 msgid "JSON files" msgstr "JSON bestanden" #: src/views/settings_view.py:1307 msgid "Backup exported successfully" msgstr "Back-up succesvol geëxporteerd" #: src/views/settings_view.py:1311 msgid "Export failed: {}" msgstr "Exporteren mislukt: {}" #: src/views/settings_view.py:1321 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1366 msgid "Connect to a device first to import a backup." msgstr "Maak eerst verbinding met een apparaat om een back-up te importeren." #: src/views/settings_view.py:1395 #, fuzzy msgid "Connect to a device first to restore a backup." msgstr "Maak eerst verbinding met een apparaat om een back-up te exporteren." #: src/views/settings_view.py:1405 #, fuzzy msgid "No Auto Backups" msgstr "Back-up Exporteren" #: src/views/settings_view.py:1406 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1412 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1461 msgid "Could not read backup file: {}" msgstr "Kon back-upbestand niet lezen: {}" #: src/views/settings_view.py:1477 msgid "Source: {}" msgstr "Bron: {}" #: src/views/settings_view.py:1508 msgid "{} contacts" msgstr "{} contacten" #: src/views/settings_view.py:1510 msgid "{} channels" msgstr "{} kanalen" #: src/views/settings_view.py:1514 msgid "messages" msgstr "berichten" #: src/views/settings_view.py:1516 msgid "device identity" msgstr "" #: src/views/settings_view.py:1518 msgid "Imported: {}" msgstr "Geïmporteerd: {}" #: src/views/settings_view.py:1520 msgid "Nothing new to import" msgstr "Er is niets nieuws om te importeren" #: src/views/repeater_view.py:26 #, python-brace-format msgid "{d}d" msgstr "{d}d" #: src/views/repeater_view.py:28 #, python-brace-format msgid "{h}h" msgstr "{h}h" #: src/views/repeater_view.py:30 #, python-brace-format msgid "{m}m" msgstr "{m}m" #: src/views/repeater_view.py:31 #, python-brace-format msgid "{secs}s" msgstr "{secs}s" #: src/views/repeater_view.py:37 msgid "{}s ago" msgstr "{}s geleden" #: src/views/repeater_view.py:39 msgid "{}m ago" msgstr "{}m geleden" #: src/views/repeater_view.py:41 msgid "{}h ago" msgstr "{}h geleden" #: src/views/repeater_view.py:42 msgid "{}d ago" msgstr "{}d geleden" #: src/views/repeater_view.py:315 msgid "Time sync sent to repeater" msgstr "Tijdsynchronisatie verzonden naar repeater" #: src/views/repeater_view.py:322 msgid "Reset Clock & Reboot?" msgstr "Klok Resetten en Opnieuw Opstarten?" #: src/views/repeater_view.py:324 msgid "" "The repeater clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the repeater." msgstr "" "De repeaterklok loopt voor op de huidige tijd. \n" "Firmware staat niet toe dat de tijd achteruit wordt gezet. \n" "\n" "Hierdoor wordt de klok opnieuw ingesteld en wordt de repeater opnieuw " "opgestart." #: src/views/repeater_view.py:339 msgid "Rebooting..." msgstr "Opnieuw opstarten..." #: src/views/repeater_view.py:340 msgid "Clock reset & reboot sent to repeater" msgstr "Klokreset en herstart naar repeater verzonden" #: src/views/repeater_view.py:352 src/views/repeater_view.py:559 #: src/views/repeater_view.py:710 src/views/repeater_view.py:931 msgid "Request timed out" msgstr "Verzoek is verlopen" #: src/views/repeater_view.py:583 msgid "{} of {} neighbors" msgstr "{} van {} buren" #: src/views/repeater_view.py:600 msgid "Neighbor Map" msgstr "Burenkaart" #: src/views/repeater_view.py:730 src/views/repeater_view.py:798 msgid "Admin" msgstr "Beheerder" #: src/views/repeater_view.py:745 msgid "{} entry" msgstr "{} vermelding" #: src/views/repeater_view.py:745 msgid "{} entries" msgstr "{} vermeldingen" #: src/views/repeater_view.py:764 msgid "Remove from ACL?" msgstr "Uit ACL Verwijderen?" #: src/views/repeater_view.py:765 msgid "Remove {} from the access control list?" msgstr "{} verwijderen uit de toegangscontrolelijst?" #: src/views/repeater_view.py:786 msgid "Add to Access Control" msgstr "Voeg toe aan Toegangscontrole" #: src/views/repeater_view.py:787 msgid "Select a contact and role." msgstr "Selecteer een contact en een rol." #: src/views/repeater_view.py:797 msgid "Role" msgstr "Rol" #: src/views/repeater_view.py:991 msgid "Global (wildcard)" msgstr "Globaal (wildcard)" #: src/views/repeater_view.py:994 msgid "Home" msgstr "Thuis" #: src/views/repeater_view.py:995 msgid "Flood allowed" msgstr "Flood toegestaan" #: src/views/repeater_view.py:995 msgid "Flood denied" msgstr "Flood geweigerd" #: src/views/repeater_view.py:1028 msgid "Deny Flood" msgstr "Flood Weigeren" #: src/views/repeater_view.py:1028 msgid "Allow Flood" msgstr "Flood Toelaten" #: src/views/repeater_view.py:1032 msgid "Set as Home" msgstr "Instellen als Thuis" #: src/views/repeater_view.py:1084 msgid "Remove child regions first" msgstr "Verwijder eerst onderliggende regio's" #: src/views/repeater_view.py:1122 msgid "Unsaved Changes" msgstr "Niet-opgeslagen wijzigingen" #: src/views/repeater_view.py:1123 msgid "You have unsaved region changes." msgstr "U heeft niet-opgeslagen regiowijzigingen." #: src/views/repeater_view.py:1125 msgid "Discard" msgstr "Weggooien" #: src/views/repeater_view.py:1153 msgid "Add Region" msgstr "Regio Toevoegen" #: src/views/repeater_view.py:1154 msgid "Enter a name and optionally choose a parent region." msgstr "Voer een naam in en kies optioneel een bovenliggende regio." #: src/views/repeater_view.py:1163 msgid "Region Name" msgstr "Regionaam" #: src/views/repeater_view.py:1172 msgid "Parent" msgstr "Ouder" #: src/views/repeater_view.py:1359 msgid "Admin password changed — re-login required" msgstr "Beheerderswachtwoord gewijzigd — opnieuw inloggen vereist" #: src/views/repeater_view.py:1423 msgid "Settings sent to {}" msgstr "Instellingen verzonden naar {}" #: src/views/repeater_view.py:1427 msgid "Reboot Repeater?" msgstr "Repeater opnieuw opstarten?" #: src/views/repeater_view.py:1428 msgid "Reboot {}? It will be offline briefly." msgstr "{} opnieuw opstarten? Die zal kort offline zijn." #: src/views/repeater_view.py:1559 msgid "Time is off" msgstr "Tijd wijkt af" #: src/views/sensor_view.py:258 #, fuzzy msgid "Logged in to {}" msgstr "Inloggen op {}" #: src/views/sensor_view.py:261 #, fuzzy msgid "Login failed" msgstr "Er is een time-out voor inloggen opgetreden" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 #, fuzzy msgid "Send Direct" msgstr "Direct" #: src/views/chat_view.py:142 #, fuzzy msgid "Send Flood" msgstr "Flood Weigeren" #: src/views/chat_view.py:546 src/views/chat_view.py:547 msgid "Sending" msgstr "Verzenden" #: src/views/map_view.py:142 msgid "User" msgstr "Gebruiker" #: src/views/map_view.py:221 msgid "{} of {} contacts on map" msgstr "{} van {} contacten op kaart" #: src/views/map_view.py:223 msgid "{} of {} located on map" msgstr "{} van {} gelegen op de kaart" #: src/views/map_view.py:244 msgid "Rooms" msgstr "Kamers" #: src/views/map_view.py:273 #, fuzzy msgid "Discovered Nodes" msgstr "Ontdek Nabijgelegen Knooppunten" #: src/views/map_view.py:469 msgid "and {} more" msgstr "en {} meer" #: src/views/map_view.py:672 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:680 msgid "SNR: {:.1f} dB" msgstr "SNR: {:.1f} dB" #: src/views/map_view.py:1037 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1221 msgid "Undo" msgstr "" #: src/views/map_view.py:1245 msgid "Done" msgstr "" #: src/views/map_view.py:1264 msgid "Tap repeaters to build the trace route" msgstr "" #, python-brace-format #~ msgid "{}ms (late)" #~ msgstr "{}ms (later)" #, fuzzy #~ msgid "New features:" #~ msgstr "Kenmerken:" #, fuzzy #~ msgid "Map view for routing paths in contact details" #~ msgstr "Kaartweergave met contactlocaties" #, fuzzy #~ msgid "" #~ "Code refactoring and performance optimizations across the application" #~ msgstr "Configureer het gedrag en uiterlijk van de toepassing" #~ msgid "Contacts view with messaging" #~ msgstr "Contactenoverzicht met berichtgeving" #~ msgid "Channel list with different channel types" #~ msgstr "Kanalenlijst met verschillende kanaalsoorten" #~ msgid "Device information and settings" #~ msgstr "Apparaatinformatie en instellingen" #~ msgid "Save configuration and messages to a file" #~ msgstr "Sla configuratie en berichten op in een bestand" #, python-brace-format #~ msgid "Syncing channels: {index}/{total}" #~ msgstr "Kanalen synchroniseren: {index}/{total}" #, python-brace-format #~ msgid "Syncing contacts: {current}/{total}" #~ msgstr "Contacten synchroniseren: {current}/{total}" #~ msgid "Comma-separated hex hops (e.g. 9a,74 or 9a3b,744c for 2-byte)" #~ msgstr "" #~ "Door komma's gescheiden hex-hops (bijvoorbeeld 9a,74 of 9a3b,744c voor 2 " #~ "bytes)" #~ msgid "Notifications" #~ msgstr "Meldingen" #~ msgid "Add from Contacts" #~ msgstr "Toevoegen vanuit Contacten" #~ msgid "No repeaters or rooms in contacts" #~ msgstr "Geen repeaters of kamers in contacten" #~ msgid "Waiting..." #~ msgstr "Wachten..." #~ msgid "Querying repeaters for regions" #~ msgstr "Regio's van repeaters opvragen" #~ msgid "Save to a file" #~ msgstr "Opslaan in een bestand" #~ msgid "Device settings: name, radio, location" #~ msgstr "Apparaatinstellingen: naam, radio, locatie" #, python-brace-format #~ msgid "Contacts: {} ({} new)" #~ msgstr "Contacten: {} ({} nieuw)" #, python-brace-format #~ msgid "Channels: {} ({} new)" #~ msgstr "Kanalen: {} ({} nieuw)" #, python-brace-format #~ msgid "Messages: {}" #~ msgstr "Berichten: {}" #, python-brace-format #~ msgid "Channel messages: {}" #~ msgstr "Kanaalberichten: {}" #, python-brace-format #~ msgid "Saved passwords: {}" #~ msgstr "Opgeslagen wachtwoorden: {}" #~ msgid "Contacts & Channels Only" #~ msgstr "Alleen Contacten en Kanalen" #~ msgid "Everything" #~ msgstr "Alles" meshy/po/pl.po000066400000000000000000002222371523412555500136130ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: pl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " "|| n%100>=20) ? 1 : 2;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/pt.po000066400000000000000000002220061523412555500136150ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/sk.po000066400000000000000000002221771523412555500136200ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: sk\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/sv.po000066400000000000000000002220071523412555500136230ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the meshy package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: meshy\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-31 13:39+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" #: data/page.codeberg.sesivany.Meshy.desktop.in:4 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:8 data/ui/window.ui:123 msgid "Meshy" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:5 #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:9 msgid "MeshCore mesh network client" msgstr "" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 msgid "" "Meshy is a client for MeshCore LoRa mesh networking devices. Connect to your " "companion device over Bluetooth to send and receive encrypted messages, " "manage contacts, configure channels, and monitor your mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:17 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "Bluetooth LE and USB serial connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:25 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:30 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:42 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:46 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:50 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:54 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "" #: data/gtk/help-overlay.ui:39 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:42 data/ui/window.ui:181 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:197 #: data/ui/manual-backup-dialog.ui:106 data/ui/auto-backup-dialog.ui:120 #: data/ui/import-backup-dialog.ui:113 src/window.py:648 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:147 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:154 src/window.py:660 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:674 #: src/window.py:677 src/views/map_view.py:907 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:66 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:574 data/ui/manual-backup-dialog.ui:53 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:60 #: src/window.py:696 src/window.py:697 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:74 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:77 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: src/views/connection_view.py:119 src/views/connection_view.py:161 #: src/views/connection_view.py:393 src/views/connection_view.py:529 msgid "Connect" msgstr "" #: data/ui/window.ui:85 data/ui/window.ui:130 data/ui/window.ui:318 msgid "Menu" msgstr "" #: data/ui/window.ui:141 msgid "Connecting…" msgstr "" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:269 #: src/connection_controller.py:477 src/window.py:1063 src/window.py:1076 #: src/views/device_view.py:365 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:29 src/views/device_view.py:497 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:30 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:44 src/window.py:637 src/window.py:638 msgid "Device Information" msgstr "" #: data/ui/device-view.ui:48 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:54 msgid "Board" msgstr "" #: data/ui/device-view.ui:60 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:63 msgid "Update" msgstr "" #: data/ui/device-view.ui:66 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:78 data/ui/contact-detail-dialog.ui:62 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:91 data/ui/contact-detail-dialog.ui:179 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:96 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:106 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:107 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:380 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:133 msgid "Location" msgstr "" #: data/ui/device-view.ui:112 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:127 msgid "Battery & Storage" msgstr "" #: data/ui/device-view.ui:131 data/ui/repeater-view.ui:76 #: src/views/contacts_view.py:1122 src/views/repeater_view.py:457 #: src/views/sensor_view.py:146 msgid "Battery" msgstr "" #: data/ui/device-view.ui:150 msgid "Storage" msgstr "" #: data/ui/device-view.ui:172 msgid "Radio Configuration" msgstr "" #: data/ui/device-view.ui:176 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:182 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:188 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:764 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:194 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:776 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:200 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:206 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:215 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:220 msgid "Refresh statistics" msgstr "" #: data/ui/device-view.ui:229 data/ui/repeater-view.ui:119 #: src/views/device_view.py:107 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:245 data/ui/repeater-view.ui:107 #: src/views/device_view.py:108 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:261 data/ui/repeater-view.ui:113 #: src/views/device_view.py:109 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:277 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:283 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:289 data/ui/repeater-view.ui:175 #: src/views/contacts_view.py:987 msgid "Flood" msgstr "" #: data/ui/device-view.ui:295 data/ui/repeater-view.ui:181 #: src/views/contact_detail_dialog.py:472 #: src/views/contact_detail_dialog.py:671 src/views/contacts_view.py:937 #: src/views/contacts_view.py:989 msgid "Direct" msgstr "" #: data/ui/device-view.ui:301 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:307 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:130 msgid "Application" msgstr "" #: data/ui/settings-view.ui:17 msgid "Configure the behavior and appearance of the application" msgstr "" #: data/ui/settings-view.ui:20 msgid "Style" msgstr "" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "" #: data/ui/settings-view.ui:54 src/views/channels_view.py:1775 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1775 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1775 src/views/channels_view.py:1812 #: src/views/settings_view.py:158 src/views/repeater_view.py:1048 msgid "None" msgstr "" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: data/ui/settings-view.ui:70 msgid "Autostart" msgstr "" #: data/ui/settings-view.ui:71 msgid "Automatically start when you log in" msgstr "" #: data/ui/settings-view.ui:80 data/ui/repeater-view.ui:104 #: data/ui/repeater-view.ui:723 src/views/settings_view.py:134 msgid "Radio" msgstr "" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "" #: data/ui/settings-view.ui:89 msgid "Preset Configuration" msgstr "" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:754 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:759 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:94 msgid "Repeat Mode" msgstr "" #: data/ui/settings-view.ui:132 msgid "Act as a portable repeater on an off-grid frequency" msgstr "" #: data/ui/settings-view.ui:138 data/ui/repeater-view.ui:788 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:131 msgid "Advert" msgstr "" #: data/ui/settings-view.ui:156 msgid "Configure how your node advertises itself on the mesh" msgstr "" #: data/ui/settings-view.ui:159 msgid "Device Name" msgstr "" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "" #: data/ui/settings-view.ui:174 src/views/settings_view.py:132 msgid "Routing and Messaging" msgstr "" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:109 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:115 msgid "Smart" msgstr "" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" #: data/ui/settings-view.ui:210 src/views/device_view.py:292 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:293 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:294 msgid "3-byte (max 21 hops)" msgstr "" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:709 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:714 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this computer's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:39 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:312 data/ui/settings-view.ui:316 #: src/views/settings_view.py:135 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:313 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:321 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:322 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:325 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:330 data/ui/map-sidebar-filters.ui:28 #: src/views/contacts_view.py:162 src/views/contacts_view.py:314 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:335 src/views/contacts_view.py:163 #: src/views/contacts_view.py:315 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:340 data/ui/map-sidebar-filters.ui:50 #: src/views/contacts_view.py:164 src/views/contacts_view.py:316 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:345 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:350 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:351 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:370 src/views/settings_view.py:136 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:371 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:374 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:375 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:381 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:386 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:387 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:396 data/ui/repeater-view.ui:458 #: data/ui/manual-backup-dialog.ui:92 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:99 src/views/contact_detail_dialog.py:219 #: src/views/settings_view.py:137 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:397 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:421 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:424 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:430 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:444 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:445 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:455 data/ui/connection-view.ui:35 #: src/views/settings_view.py:138 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:456 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" #: data/ui/settings-view.ui:459 msgid "Pairing PIN" msgstr "" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "" #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "" #: data/ui/connection-view.ui:84 src/views/connection_view.py:517 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:35 #: data/ui/discover-dialog.ui:51 data/ui/share-contact-dialog.ui:32 #: data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: data/ui/share-contact-dialog.ui:53 src/views/__init__.py:548 #: src/views/chat_helpers.py:142 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/channel-message-item.ui:165 #: data/ui/chat-message-item.ui:167 src/views/contacts_view.py:447 #: src/views/contacts_view.py:620 src/views/channels_view.py:349 #: src/views/chat_view.py:303 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "Select a Contact" msgstr "" #: data/ui/chat-view.ui:23 msgid "Choose a contact from the list to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "" #: data/ui/chat-view.ui:134 src/views/chat_view.py:509 msgid "Type a message..." msgstr "" #: data/ui/channels-view.ui:10 src/views/channels_view.py:2224 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2256 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 src/views/channels_view.py:2308 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:59 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:71 src/application.py:160 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "Select a Channel" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Choose a channel from the list" msgstr "" #: data/ui/channel-chat-widget.ui:134 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:318 msgid "Reset & Reboot" msgstr "" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "" #: data/ui/repeater-view.ui:137 msgid "Channel Utilization" msgstr "" #: data/ui/repeater-view.ui:143 msgid "Duty Cycle" msgstr "" #: data/ui/repeater-view.ui:156 msgid "Sent" msgstr "" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:201 data/ui/repeater-view.ui:344 #: data/ui/repeater-view.ui:429 msgid "Refresh" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:183 #: src/views/repeater_view.py:396 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:272 msgid "CLI command..." msgstr "" #: data/ui/repeater-view.ui:297 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:352 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:361 data/ui/trace-path-dialog.ui:124 #: src/views/contact_detail_dialog.py:455 src/views/channels_view.py:1023 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:382 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:437 data/ui/repeater-view.ui:542 #: src/application.py:244 src/views/__init__.py:504 #: src/views/contacts_view.py:463 src/views/repeater_view.py:1038 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:799 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:495 src/views/settings_view.py:976 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:520 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:521 msgid "" "Flood packets will be scoped to the provided region. Only repeaters allowing " "the region will forward scoped packets. Leave blank for unscoped." msgstr "" #: data/ui/repeater-view.ui:550 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:303 src/views/repeater_view.py:1007 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:592 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:603 data/ui/repeater-view.ui:689 #: data/ui/repeater-view.ui:734 data/ui/repeater-view.ui:815 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:612 data/ui/repeater-view.ui:698 #: data/ui/repeater-view.ui:743 data/ui/repeater-view.ui:824 #: src/views/connection_view.py:491 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:623 data/ui/contact-detail-dialog.ui:42 #: data/ui/add-contact-content.ui:23 data/ui/qr-contact-content.ui:23 #: src/views/contacts_view.py:325 src/views/channels_view.py:1676 #: src/views/channels_view.py:1690 src/views/channels_view.py:1960 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:628 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:633 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:634 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:643 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:646 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:660 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:835 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:847 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:863 data/ui/contact-detail-dialog.ui:200 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:866 data/ui/device-actions.ui:121 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:890 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:919 msgid "Log Out" msgstr "" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "" #: data/ui/device-actions.ui:12 msgid "Send Advert" msgstr "" #: data/ui/device-actions.ui:20 msgid "Zero Hop" msgstr "" #: data/ui/device-actions.ui:21 msgid "Broadcast locally" msgstr "" #: data/ui/device-actions.ui:33 msgid "Flood Routed" msgstr "" #: data/ui/device-actions.ui:34 msgid "Broadcast through the mesh" msgstr "" #: data/ui/device-actions.ui:46 msgid "To Clipboard" msgstr "" #: data/ui/device-actions.ui:47 msgid "Copy self contact data" msgstr "" #: data/ui/device-actions.ui:59 msgid "Show QR Code" msgstr "" #: data/ui/device-actions.ui:60 msgid "For others to scan and add you" msgstr "" #: data/ui/device-actions.ui:76 data/ui/trace-path-dialog.ui:8 #: src/views/device_view.py:831 msgid "Trace Path" msgstr "" #: data/ui/device-actions.ui:77 msgid "Trace a route and show signal quality per hop" msgstr "" #: data/ui/device-actions.ui:91 data/ui/discover-nearby-dialog.ui:8 msgid "Discover Nearby Nodes" msgstr "" #: data/ui/device-actions.ui:92 msgid "Scan the network for nearby nodes" msgstr "" #: data/ui/device-actions.ui:106 data/ui/rx-log-dialog.ui:8 msgid "Rx Log" msgstr "" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/rx-log-dialog.ui:18 msgid "Clear log" msgstr "" #: data/ui/rx-log-dialog.ui:50 msgid "No Packets" msgstr "" #: data/ui/rx-log-dialog.ui:51 msgid "Received radio packets will appear here" msgstr "" #: data/ui/stats-chart-dialog.ui:43 msgid "dBm over last 2 minutes" msgstr "" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "" #: data/ui/settings-sidebar.ui:24 msgid "Choose what to back up and save to a file" msgstr "" #: data/ui/settings-sidebar.ui:36 msgid "Automatic" msgstr "" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "" #: data/ui/settings-sidebar.ui:51 data/ui/import-backup-dialog.ui:214 #: src/views/settings_view.py:1420 msgid "Restore" msgstr "" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "" #: data/ui/settings-sidebar.ui:99 src/views/device_view.py:228 msgid "Factory Reset" msgstr "" #: data/ui/settings-sidebar.ui:100 msgid "Erase all data on the companion" msgstr "" #: data/ui/manual-backup-dialog.ui:8 msgid "Manual Backup" msgstr "" #: data/ui/manual-backup-dialog.ui:35 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:43 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:68 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:75 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:80 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:87 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:121 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:128 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:133 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:140 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:171 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:180 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:186 src/views/channels_view.py:1962 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:192 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:198 msgid "Smart Route History" msgstr "" #: data/ui/manual-backup-dialog.ui:208 msgid "Export" msgstr "" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:104 #: src/views/contacts_view.py:265 src/views/contacts_view.py:300 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/share-contact-dialog.ui:18 #: data/ui/channel-participants-dialog.ui:18 src/window.py:570 msgid "Search" msgstr "" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "" #: data/ui/share-contact-dialog.ui:8 src/views/channels_view.py:203 #: src/views/chat_view.py:154 msgid "Share Contact" msgstr "" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:38 msgid "Contact Info" msgstr "" #: data/ui/contact-detail-dialog.ui:49 msgid "Favorite" msgstr "" #: data/ui/contact-detail-dialog.ui:50 msgid "Pin to top of contacts list" msgstr "" #: data/ui/contact-detail-dialog.ui:56 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1697 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:68 msgid "Copy to clipboard" msgstr "" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:326 #: src/views/channels_view.py:1961 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:89 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:128 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:135 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:136 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:148 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:161 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:162 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:168 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:169 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:184 msgid "Query this contact's telemetry data" msgstr "" #: data/ui/contact-detail-dialog.ui:204 msgid "Remove Contact" msgstr "" #: data/ui/trace-path-dialog.ui:31 data/ui/trace-path-dialog.ui:34 #: src/views/device_view.py:1306 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:40 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:59 msgid "Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:81 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:18 data/ui/qr-contact-content.ui:18 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:28 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:223 data/ui/chat-message-item.ui:225 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1733 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:573 #: src/window.py:673 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 msgid "Channel Name" msgstr "" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:515 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "" #: data/ui/discover-nearby-dialog.ui:70 msgid "Listening..." msgstr "" #: data/ui/discover-nearby-dialog.ui:71 msgid "Waiting for nearby nodes to respond" msgstr "" #: data/ui/discover-regions-dialog.ui:8 msgid "Discover Regions" msgstr "" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:276 msgid "Scanning..." msgstr "" #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:965 #: src/views/settings_view.py:1122 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:18 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:23 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "" #: data/ui/map-sidebar-filters.ui:17 src/views/contacts_view.py:161 #: src/views/contacts_view.py:313 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:65 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:947 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 src/application.py:116 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/application.py:116 src/views/contacts_view.py:456 #: src/views/contacts_view.py:614 src/views/device_view.py:949 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:955 msgid "Message Paths" msgstr "" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "" #: data/ui/pair-dialog.ui:41 src/connection_controller.py:157 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:18 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/qr-scanner-dialog.ui:8 src/views/contacts_view.py:110 msgid "Scan QR Code" msgstr "" #: data/ui/qr-scanner-dialog.ui:33 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:19 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:32 msgid "Parent" msgstr "" #: data/ui/room-login-dialog.ui:33 data/ui/sensor-login-dialog.ui:31 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 data/ui/sensor-login-dialog.ui:38 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "Port" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1866 msgid "Clear" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: src/application.py:116 src/application.py:187 src/window.py:649 #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/channels_view.py:1730 src/views/device_view.py:946 msgid "Chat" msgstr "" #: src/application.py:124 src/application.py:191 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1265 #: src/views/channels_view.py:1118 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1449 msgid "Unknown" msgstr "" #: src/application.py:126 #, python-brace-format msgid "" "Name: {name}\n" "Type: {type}" msgstr "" #: src/application.py:128 #, python-brace-format msgid "" "Type: {type}\n" "Key: {key}…" msgstr "" #: src/application.py:132 msgid "Import Contact" msgstr "" #: src/application.py:136 #, python-brace-format msgid "Contact {}" msgstr "" #: src/application.py:136 msgid "imported" msgstr "" #: src/application.py:140 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:154 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:157 msgid "Invalid channel secret length" msgstr "" #: src/application.py:161 #, python-brace-format msgid "" "Name: {name}\n" "Type: Private" msgstr "" #: src/application.py:161 msgid "Unnamed" msgstr "" #: src/application.py:177 msgid "Invalid public key in link" msgstr "" #: src/application.py:180 msgid "Invalid public key length" msgstr "" #: src/application.py:184 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:184 msgid "Contact" msgstr "" #: src/application.py:189 #, python-brace-format msgid "Add {} Contact" msgstr "" #: src/application.py:190 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" #: src/application.py:195 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:200 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:243 src/connection_controller.py:159 #: src/connection_controller.py:287 src/connection_controller.py:451 #: src/views/connection_view.py:180 src/views/connection_view.py:251 #: src/views/connection_view.py:455 src/views/connection_view.py:490 #: src/views/connection_view.py:528 src/views/connection_view.py:652 #: src/views/contact_detail_dialog.py:689 src/views/contacts_view.py:462 #: src/views/contacts_view.py:619 src/views/channels_view.py:1865 #: src/views/channels_view.py:1895 src/views/channels_view.py:2145 #: src/views/channels_view.py:2232 src/views/channels_view.py:2265 #: src/views/channels_view.py:2316 src/views/device_view.py:227 #: src/views/device_view.py:243 src/views/settings_view.py:1394 #: src/views/repeater_view.py:317 src/views/repeater_view.py:724 #: src/views/repeater_view.py:1037 src/views/repeater_view.py:1310 msgid "Cancel" msgstr "" #: src/application.py:316 src/application.py:337 src/application.py:361 msgid "Receiving messages in the background" msgstr "" #: src/application.py:430 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" msgstr "" #: src/application.py:442 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:491 msgid "Adwaita Symbolic Icons" msgstr "" #: src/connection_controller.py:150 src/connection_controller.py:290 #: src/window.py:1086 msgid "Connecting..." msgstr "" #: src/connection_controller.py:156 msgid "Scan for Devices" msgstr "" #: src/connection_controller.py:160 msgid "Scan" msgstr "" #: src/connection_controller.py:277 msgid "Stop" msgstr "" #: src/connection_controller.py:283 src/connection_controller.py:447 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:301 msgid "Syncing..." msgstr "" #: src/connection_controller.py:336 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:522 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:594 #, python-brace-format msgid "You received {} message while disconnected." msgid_plural "You received {} messages while disconnected." msgstr[0] "" msgstr[1] "" #: src/frame_handler.py:168 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:298 src/frame_handler.py:316 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:506 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:506 msgid "late" msgstr "" #: src/frame_handler.py:605 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:615 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #: src/frame_handler.py:1207 src/window.py:792 src/views/contacts_view.py:1105 #: src/views/channels_view.py:1564 src/views/channels_view.py:1670 #: src/views/channels_view.py:1691 src/views/channels_view.py:1862 #: src/views/channels_view.py:1892 src/views/repeater_view.py:440 #: src/views/sensor_view.py:123 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1208 msgid "Someone" msgstr "" #: src/frame_handler.py:1211 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:175 src/views/repeater_view.py:682 #: src/views/repeater_view.py:713 msgid "Me" msgstr "" #: src/message_controller.py:218 msgid "flood" msgstr "" #: src/message_controller.py:220 src/views/channels_view.py:483 msgid "direct" msgstr "" #: src/message_controller.py:222 msgid "path" msgstr "" #: src/message_controller.py:225 src/message_controller.py:229 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:230 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:285 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/window.py:497 msgid "Raster" msgstr "" #: src/window.py:497 msgid "Vector" msgstr "" #: src/window.py:505 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:509 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" #: src/window.py:515 msgid "Style JSON URL" msgstr "" #: src/window.py:538 msgid "Custom map tiles applied" msgstr "" #: src/window.py:540 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:576 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:636 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1852 src/views/repeater_view.py:897 msgid "Actions" msgstr "" #: src/window.py:661 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:695 msgid "Backup & Restore" msgstr "" #: src/window.py:773 src/window.py:837 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:794 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:859 msgid "Management" msgstr "" #: src/window.py:859 msgid "Guest" msgstr "" #: src/window.py:860 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:961 msgid "Excellent" msgstr "" #: src/window.py:962 msgid "Good" msgstr "" #: src/window.py:963 msgid "Fair" msgstr "" #: src/window.py:964 msgid "Poor" msgstr "" #: src/window.py:965 msgid "Very poor" msgstr "" #: src/window.py:1191 msgid "flood routed" msgstr "" #: src/window.py:1191 msgid "zero-hop" msgstr "" #: src/window.py:1192 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1677 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:1681 msgid "No empty channel slots available" msgstr "" #: src/qr_scanner.py:47 #, python-brace-format msgid "Cannot connect to session bus: {}" msgstr "" #: src/qr_scanner.py:65 msgid "No camera detected on this system." msgstr "" #: src/qr_scanner.py:121 #, python-brace-format msgid "Could not access camera: {}" msgstr "" #: src/qr_scanner.py:130 msgid "Camera access was denied." msgstr "" #: src/qr_scanner.py:164 msgid "Could not open camera stream." msgstr "" #: src/qr_scanner.py:170 msgid "No camera file descriptor received." msgstr "" #: src/qr_scanner.py:179 src/qr_scanner.py:182 #, python-brace-format msgid "Could not open camera: {}" msgstr "" #: src/qr_scanner.py:211 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:288 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" #: src/qr_scanner.py:299 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:305 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:315 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:333 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:334 src/views/connection_view.py:213 #: src/views/connection_view.py:345 src/views/connection_view.py:352 #: src/views/contacts_view.py:272 src/views/contacts_view.py:502 #: src/views/contacts_view.py:599 src/views/channels_view.py:961 #: src/views/channels_view.py:1183 src/views/channels_view.py:1205 #: src/views/channels_view.py:2214 src/views/channels_view.py:2289 #: src/views/settings_view.py:1239 src/views/settings_view.py:1349 #: src/views/settings_view.py:1378 src/views/settings_view.py:1389 #: src/views/settings_view.py:1444 src/views/chat_helpers.py:161 msgid "OK" msgstr "" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:506 src/views/contacts_view.py:224 #: src/views/chat_helpers.py:142 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:508 src/views/contacts_view.py:220 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:181 #: src/views/connection_view.py:404 src/views/connection_view.py:456 #: src/views/contact_detail_dialog.py:690 src/views/channels_view.py:1896 #: src/views/repeater_view.py:725 src/views/repeater_view.py:914 msgid "Remove" msgstr "" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:94 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:95 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:105 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:106 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:147 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:148 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:175 src/views/connection_view.py:452 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:176 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:184 src/views/connection_view.py:459 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:210 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:243 src/views/connection_view.py:272 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:245 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Click \"Install " "Rule\" to install it (requires administrator password)." msgstr "" #: src/views/connection_view.py:252 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:274 #, python-brace-format msgid "" "Cannot access {}.\n" "\n" "A udev rule is needed to grant access to USB serial devices. Run the " "following command in a terminal:\n" "\n" "{}" msgstr "" #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:289 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:342 src/views/connection_view.py:349 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:343 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:379 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:380 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:403 src/views/connection_view.py:477 msgid "Change Address" msgstr "" #: src/views/connection_view.py:453 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:478 msgid "Enter the new address for this companion." msgstr "" #: src/views/connection_view.py:518 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: src/views/connection_view.py:602 src/views/connection_view.py:653 msgid "Pair" msgstr "" #: src/views/connection_view.py:642 msgid "Enter Pairing PIN" msgstr "" #: src/views/connection_view.py:643 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: src/views/contact_detail_dialog.py:60 src/views/channels_view.py:2082 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:100 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:101 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:126 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:127 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:131 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:132 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:137 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:161 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:163 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access status, CLI, neighbors, and settings" msgstr "" #: src/views/contact_detail_dialog.py:185 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:186 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:201 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:202 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:220 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:963 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:329 src/views/device_view.py:165 msgid "Public key copied" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:433 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:452 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:463 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:474 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:475 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:477 #: src/views/contact_detail_dialog.py:669 #, python-format msgid "%d hops" msgstr "" #: src/views/contact_detail_dialog.py:513 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:591 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:626 src/views/contacts_view.py:1155 #: src/views/contacts_view.py:1259 src/views/channels_view.py:1111 #: src/views/device_view.py:851 src/views/sensor_view.py:181 #: src/views/chat_helpers.py:75 src/views/map_view.py:491 #: src/views/map_view.py:787 src/views/map_view.py:1158 msgid "My Device" msgstr "" #: src/views/contact_detail_dialog.py:686 msgid "Remove Contact?" msgstr "" #: src/views/contact_detail_dialog.py:687 #, python-brace-format msgid "Remove {} and all messages?" msgstr "" #: src/views/contacts_view.py:105 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:106 msgid "Import from Clipboard" msgstr "" #: src/views/contacts_view.py:159 src/views/contacts_view.py:312 #: src/views/channels_view.py:1462 src/views/channels_view.py:1953 msgid "All" msgstr "" #: src/views/contacts_view.py:160 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:175 src/views/channels_view.py:1475 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:176 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:177 src/views/channels_view.py:1476 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:184 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:267 msgid "" "No new contacts have been heard yet. Leave the app running and contacts will " "appear as their adverts are received." msgstr "" #: src/views/contacts_view.py:304 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:305 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:327 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:331 msgid "Distance" msgstr "" #: src/views/contacts_view.py:448 msgid "Enter the contact details." msgstr "" #: src/views/contacts_view.py:456 src/views/contacts_view.py:614 #: src/views/device_view.py:948 msgid "Room Server" msgstr "" #: src/views/contacts_view.py:475 src/views/contacts_view.py:563 #: src/views/contacts_view.py:588 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:499 src/views/contacts_view.py:598 #: src/views/settings_view.py:1441 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:500 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:532 msgid "Invalid channel secret in QR code." msgstr "" #: src/views/contacts_view.py:536 #, python-brace-format msgid "Channel secret must be 16 bytes, got {}." msgstr "" #: src/views/contacts_view.py:553 msgid "Invalid public key in QR code." msgstr "" #: src/views/contacts_view.py:558 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "" #: src/views/contacts_view.py:595 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:605 msgid "QR Code Scanned" msgstr "" #: src/views/contacts_view.py:606 #, python-brace-format msgid "Public key: {}...{}" msgstr "" #: src/views/contacts_view.py:676 src/views/sensor_view.py:219 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/contacts_view.py:698 msgid "Login successful" msgstr "" #: src/views/contacts_view.py:716 msgid "Login failed — wrong password" msgstr "" #: src/views/contacts_view.py:723 msgid "Login timed out" msgstr "" #: src/views/contacts_view.py:730 msgid "Retrying via flood..." msgstr "" #: src/views/contacts_view.py:737 src/views/sensor_view.py:228 msgid "Logging in..." msgstr "" #: src/views/contacts_view.py:904 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:943 src/views/channels_view.py:487 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:957 msgid "Path found" msgstr "" #: src/views/contacts_view.py:972 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:982 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:995 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:1006 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:1029 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:1040 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:1053 src/views/repeater_view.py:526 #: src/views/repeater_view.py:661 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:1058 src/views/device_view.py:375 #: src/views/sensor_view.py:251 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:1067 src/views/contacts_view.py:1071 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:1074 src/views/device_view.py:404 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:1097 src/views/repeater_view.py:432 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1238 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1290 src/views/channels_view.py:1010 #: src/views/channels_view.py:1119 src/views/device_view.py:858 #: src/views/map_view.py:822 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:146 src/views/channels_view.py:1180 #: src/views/channels_view.py:1187 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:147 src/views/chat_view.py:97 msgid "Send Again" msgstr "" #: src/views/channels_view.py:148 src/views/channels_view.py:154 #: src/views/chat_view.py:98 msgid "Delete" msgstr "" #: src/views/channels_view.py:151 msgid "Reply" msgstr "" #: src/views/channels_view.py:152 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:153 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:204 src/views/chat_helpers.py:176 #: src/views/chat_view.py:155 msgid "Share Location" msgstr "" #: src/views/channels_view.py:205 src/views/chat_view.py:156 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:331 src/views/chat_view.py:285 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:335 src/views/chat_view.py:289 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:349 src/views/channels_view.py:465 #: src/views/chat_view.py:303 src/views/chat_view.py:415 msgid "Already added" msgstr "" #: src/views/channels_view.py:366 src/views/chat_view.py:320 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:370 src/views/chat_view.py:324 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:433 src/views/chat_view.py:383 msgid "New Messages" msgstr "" #: src/views/channels_view.py:474 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:496 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:957 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1031 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1032 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1039 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1040 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1089 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1181 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1463 src/views/channels_view.py:2299 msgid "Public" msgstr "" #: src/views/channels_view.py:1464 msgid "Hashtag" msgstr "" #: src/views/channels_view.py:1465 msgid "Private" msgstr "" #: src/views/channels_view.py:1510 #, python-brace-format msgid "{visible}/{total} channels" msgstr "" #: src/views/channels_view.py:1514 #, python-brace-format msgid "{total} channels" msgstr "" #: src/views/channels_view.py:1516 #, python-brace-format msgid "{total} channel" msgstr "" #. Info group #: src/views/channels_view.py:1673 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:1705 msgid "Private Key" msgstr "" #: src/views/channels_view.py:1716 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:1734 src/views/channels_view.py:2030 #: src/views/channels_view.py:2036 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1747 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:1750 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1751 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1752 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1753 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1754 msgid "Forever" msgstr "" #: src/views/channels_view.py:1773 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:1775 msgid "Default" msgstr "" #: src/views/channels_view.py:1805 src/views/channels_view.py:1810 msgid "Region" msgstr "" #: src/views/channels_view.py:1806 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:1834 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:1835 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1854 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:1860 msgid "Clear Messages?" msgstr "" #: src/views/channels_view.py:1861 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1874 msgid "Messages cleared" msgstr "" #: src/views/channels_view.py:1884 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:1890 msgid "Remove Channel?" msgstr "" #: src/views/channels_view.py:1891 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "" #: src/views/channels_view.py:1953 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2032 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2062 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2068 src/views/channels_view.py:2146 msgid "Block" msgstr "" #: src/views/channels_view.py:2077 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2093 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2138 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2140 msgid "" "Channel messages don't include sender identity. This block matches by name " "only — the sender could change their name to bypass it." msgstr "" #: src/views/channels_view.py:2147 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2164 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2211 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2212 msgid "All 8 channel slots are in use." msgstr "" #: src/views/channels_view.py:2225 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: src/views/channels_view.py:2233 msgid "Create" msgstr "" #: src/views/channels_view.py:2257 msgid "Enter the channel name and private key shared with you." msgstr "" #: src/views/channels_view.py:2266 src/views/channels_view.py:2317 msgid "Join" msgstr "" #: src/views/channels_view.py:2287 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2287 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2309 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: src/views/device_view.py:175 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:186 msgid "QR code library not available" msgstr "" #: src/views/device_view.py:219 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:221 msgid "" "This will permanently erase ALL data on the companion device, including " "contacts, messages, channels, settings, and the device identity (keys). This " "action cannot be undone." msgstr "" #: src/views/device_view.py:240 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:241 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:244 src/views/repeater_view.py:1311 msgid "Reboot" msgstr "" #: src/views/device_view.py:327 msgid "Not set" msgstr "" #: src/views/device_view.py:336 src/views/device_view.py:344 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:340 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:344 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:369 src/views/repeater_view.py:390 msgid "Requesting…" msgstr "" #: src/views/device_view.py:451 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:453 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:454 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:456 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:477 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:492 src/views/device_view.py:494 msgid "Connected" msgstr "" #: src/views/device_view.py:506 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:528 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:621 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:683 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: src/views/device_view.py:737 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:753 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:764 msgid "Tracing..." msgstr "" #: src/views/device_view.py:932 src/views/device_view.py:1103 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:979 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:986 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1015 msgid "Node" msgstr "" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1152 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1301 msgid "Size" msgstr "" #: src/views/device_view.py:1301 msgid "bytes" msgstr "" #: src/views/device_view.py:1306 msgid "hops" msgstr "" #: src/views/device_view.py:1307 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1307 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1378 #, python-brace-format msgid "Update available: {version}" msgstr "" #: src/views/settings_view.py:143 msgid "Custom" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:151 msgid "Deny All" msgstr "" #: src/views/settings_view.py:151 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:151 msgid "Allow All" msgstr "" #: src/views/settings_view.py:649 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:674 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:675 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:699 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:794 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:798 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:821 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:821 msgid "Set Location" msgstr "" #: src/views/settings_view.py:931 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:943 src/views/settings_view.py:1121 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:961 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:963 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:970 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:973 msgid "No regions found" msgstr "" #: src/views/settings_view.py:975 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:991 src/views/settings_view.py:1000 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:993 src/views/settings_view.py:1002 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1070 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1072 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1166 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1168 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1236 src/views/settings_view.py:1346 #: src/views/settings_view.py:1375 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1237 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1275 src/views/settings_view.py:1356 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1288 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1292 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1302 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1347 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1376 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1386 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1387 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1393 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1442 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1458 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1489 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1491 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1495 msgid "messages" msgstr "" #: src/views/settings_view.py:1497 msgid "device identity" msgstr "" #: src/views/settings_view.py:1499 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1501 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:149 msgid "room" msgstr "" #: src/views/repeater_view.py:150 msgid "repeater" msgstr "" #: src/views/repeater_view.py:303 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:310 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:312 msgid "" "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n" "\n" "This will reset the clock and reboot the device." msgstr "" #: src/views/repeater_view.py:327 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:329 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:342 src/views/repeater_view.py:535 #: src/views/repeater_view.py:667 src/views/repeater_view.py:844 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:559 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:576 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:702 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:721 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:722 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:744 msgid "Role" msgstr "" #: src/views/repeater_view.py:745 msgid "Admin" msgstr "" #: src/views/repeater_view.py:745 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:745 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:770 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:872 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:875 msgid "Home" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:876 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:909 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:909 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:913 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:965 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:1003 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:1004 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:1006 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1034 msgid "Add Region" msgstr "" #: src/views/repeater_view.py:1035 msgid "Enter a name and optionally choose a parent region." msgstr "" #: src/views/repeater_view.py:1232 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1299 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1303 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1306 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "" #. positive = behind, negative = ahead #. more than 5 minutes off #: src/views/repeater_view.py:1438 msgid "Time is off" msgstr "" #: src/views/sensor_view.py:234 #, python-brace-format msgid "Logged in to {}" msgstr "" #: src/views/sensor_view.py:237 msgid "Login failed" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:64 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:129 src/views/chat_helpers.py:177 msgid "Share" msgstr "" #: src/views/chat_helpers.py:158 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:159 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:140 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:141 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:142 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:422 src/views/chat_view.py:423 msgid "Sending" msgstr "" #: src/views/chat_view.py:507 msgid "Read-only room" msgstr "" #: src/views/map_view.py:220 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:222 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:447 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:650 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:658 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/map_view.py:1012 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1223 msgid "Tap repeaters to build the trace route" msgstr "" meshy/po/update-translations.sh000077500000000000000000000037761523412555500172050ustar00rootroot00000000000000#!/bin/bash # Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later # Regenerate .pot template and update .po files. # Also checks for source files with translatable strings missing from POTFILES. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" BUILD_DIR="$PROJECT_DIR/_build" POTFILES="$SCRIPT_DIR/POTFILES" # Ensure build directory exists if [ ! -f "$BUILD_DIR/build.ninja" ]; then echo "Setting up build directory..." meson setup "$BUILD_DIR" "$PROJECT_DIR" fi # Check for source files missing from POTFILES echo "Checking POTFILES completeness..." missing=() # Python files with _() calls while IFS= read -r f; do rel="${f#"$PROJECT_DIR/"}" if ! grep -qxF "$rel" "$POTFILES"; then missing+=("$rel") fi done < <(grep -rlnP '(?/dev/null | sort) # UI files with translatable="yes" while IFS= read -r f; do rel="${f#"$PROJECT_DIR/"}" if ! grep -qxF "$rel" "$POTFILES"; then missing+=("$rel") fi done < <(grep -rln 'translatable="yes"' "$PROJECT_DIR/data/" --include="*.ui" 2>/dev/null | sort) if [ ${#missing[@]} -gt 0 ]; then echo "WARNING: Files with translatable strings not listed in POTFILES:" for f in "${missing[@]}"; do echo " $f" done echo "" echo "Add them to po/POTFILES if they contain user-facing strings." echo "" fi # Regenerate .pot and update .po files echo "Regenerating .pot template..." meson compile -C "$BUILD_DIR" meshy-pot 2>/dev/null echo "Updating .po files..." meson compile -C "$BUILD_DIR" meshy-update-po 2>/dev/null # Print statistics echo "" echo "=== Translation statistics ===" pot_count=$(grep -c "^msgid " "$SCRIPT_DIR/meshy.pot") echo "Total strings in .pot: $pot_count" echo "" for po in "$SCRIPT_DIR"/*.po; do [ -f "$po" ] || continue lang=$(basename "$po" .po) stats=$(msgfmt --statistics "$po" 2>&1) echo "$lang: $stats" done meshy/pyproject.toml000066400000000000000000000040071523412555500151270ustar00rootroot00000000000000[project] name = "meshy" version = "26.06" description = "A GTK4/libadwaita client for MeshCore" readme = "README.md" requires-python = ">=3.13" dependencies = [] [dependency-groups] dev = [ "pre-commit>=4.6.0", "pycryptodome>=3.23.0", "pyserial>=3.5", "pytest>=8.0", "pyzbar>=0.1.9", "ruff>=0.15.15", "segno>=1.6.6", ] dev-linux = [ "pyzbar>=0.1.9", # Requires libzbar (Linux) ] dev-macos = [ "pyobjc-framework-CoreBluetooth>=10.0", "pyobjc-framework-CoreLocation>=10.0", "pyobjc-framework-AVFoundation>=10.0", "pyobjc-framework-Vision>=10.0", "pyobjc-framework-UserNotifications>=10.0", # Requires signed builds ] [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff] line-length = 100 target-version = "py313" [tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "PIE", # flake8-pie ] ignore = [ "E501", # line too long (handled by formatter) "E402", # module level import not at top (required for gi.require_version) "E701", # multiple statements on one line (colon) - compact code style "E702", # multiple statements on one line (semicolon) - index increment pattern "E731", # lambda assignments - valid for GTK signal handlers "F821", # undefined name '_' - gettext is injected by GTK builtins "F841", # unused variable - often intentional (unpacking, callbacks) "B007", # unused loop control variable - itertools pattern "SIM102", # nested if - more readable in complex conditionals "SIM103", # needless bool - explicit is better than implicit "SIM105", # suppressible exception - contextlib not always clearer "SIM108", # if-else-block - ternary not always more readable "SIM118", # in dict.keys() - explicit for readability ] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] # unused imports in __init__ files meshy/src/000077500000000000000000000000001523412555500130015ustar00rootroot00000000000000meshy/src/__init__.py000066400000000000000000000004441523412555500151140ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import os as _os pkgdatadir = _os.path.join(_os.path.dirname(_os.path.dirname(__file__)), "share", "meshy") QR_SCANNER_ENABLED = True SHORTCUTS_DIALOG_ENABLED = True meshy/src/application.py000066400000000000000000000444001523412555500156600ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import logging import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gio, GLib, Gtk from meshy.background_portal import BackgroundPortal log = logging.getLogger(__name__) class MeshyApplication(Adw.Application): def __init__(self, version="26.08", **kwargs): super().__init__( application_id="page.codeberg.sesivany.Meshy", flags=Gio.ApplicationFlags.HANDLES_OPEN, **kwargs, ) self.version = version self._window = None self._pending_uri = None # URI received before window is ready self._hidden_start = False self._background_held = False self._bg_portal = BackgroundPortal() self.add_main_option( "debug", ord("d"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, "Enable debug logging", None, ) self.add_main_option( "hidden", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, "Start without showing the window (used for autostart)", None, ) def do_handle_local_options(self, options): if options.contains("debug"): logging.basicConfig(level=logging.DEBUG, format="%(name)s %(levelname)s %(message)s") else: logging.basicConfig(level=logging.WARNING) # On macOS, also log to file for troubleshooting (console output hidden when launched via Finder) from meshy.platforms import is_macos if is_macos(): import os log_file = os.path.expanduser("~/Library/Logs/Meshy/debug.log") os.makedirs(os.path.dirname(log_file), exist_ok=True) file_handler = logging.FileHandler(log_file, mode="w") file_handler.setLevel(logging.DEBUG) file_handler.setFormatter( logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") ) logging.getLogger().addHandler(file_handler) logging.getLogger().setLevel(logging.DEBUG) self._hidden_start = options.contains("hidden") return -1 # continue normal startup def do_activate(self): if not self._window: from meshy.window import MeshyWindow self._window = MeshyWindow(application=self) settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") if settings.get_boolean("run-in-background"): self._enable_background_mode() if self._hidden_start: self._hidden_start = False return # Activate NSApplication on macOS before presenting window from meshy.platforms import is_macos if is_macos(): from meshy.platforms.macos.app_activation import activate_macos_app activate_macos_app() self._window.present() # Process any URI that arrived before the window was created if self._pending_uri: uri = self._pending_uri self._pending_uri = None GLib.idle_add(lambda: self._handle_meshcore_uri(uri) or False) def do_open(self, files, n_files, hint): """Handle meshcore:// URIs opened from the system.""" self.do_activate() for f in files: uri = f.get_uri() if uri and uri.startswith("meshcore://"): if self._window and self._window.is_connected: self._handle_meshcore_uri(uri) else: self._pending_uri = uri def _handle_meshcore_uri(self, uri): """Process a meshcore:// URI -- import contact or channel.""" if not self._window: return import re from urllib.parse import parse_qs, urlparse type_labels = {1: _("Chat"), 2: _("Repeater"), 3: _("Room"), 4: _("Sensor")} # Raw hex format: meshcore:// — no slashes, no query params hex_data = uri[len("meshcore://") :].strip("/") if re.fullmatch(r"[0-9a-fA-F]+", hex_data): try: contact_frame = bytes.fromhex(hex_data) name, contact_type = self._parse_advert_name(contact_frame) type_label = type_labels.get(contact_type, _("Unknown")) if name: body = _("Name: {name}\nType: {type}").format(name=name, type=type_label) else: body = _("Type: {type}\nKey: {key}…").format( type=type_label, key=hex_data[4:36] ) self._show_import_dialog( _("Import Contact"), body, lambda: ( self._window.import_contact(contact_frame), self._window.show_toast(_("Contact {}").format(name or _("imported"))), ), ) except ValueError: self._window.show_toast(_("Invalid meshcore:// link")) return parsed = urlparse(uri) params = parse_qs(parsed.query) # meshcore://channel/add?name=...&secret=... full_path = f"{parsed.netloc}{parsed.path}".rstrip("/") if full_path == "channel/add" and "secret" in params: ch_name = params.get("name", [""])[0] secret_hex = params["secret"][0].strip() try: secret = bytes.fromhex(secret_hex) except ValueError: self._window.show_toast(_("Invalid channel secret in link")) return if len(secret) != 16: self._window.show_toast(_("Invalid channel secret length")) return self._show_import_dialog( _("Add Channel"), _("Name: {name}\nType: Private").format(name=ch_name or _("Unnamed")), lambda: self._window.add_channel_from_qr(ch_name, secret), ) return # meshcore://contact/add?name=...&public_key=...&type=... if "public_key" in params: pub_key_hex = params["public_key"][0].strip() name = params.get("name", [""])[0] try: contact_type = int(params.get("type", ["1"])[0]) except ValueError: contact_type = 1 try: pub_key = bytes.fromhex(pub_key_hex) except ValueError: self._window.show_toast(_("Invalid public key in link")) return if len(pub_key) != 32: self._window.show_toast(_("Invalid public key length")) return if self._window.is_contact_known(pub_key_hex): self._window.show_toast( _("{} is already in your list").format(name or _("Contact")) ) return type_label = type_labels.get(contact_type, _("Chat")) self._show_import_dialog( _("Add {} Contact").format(type_label), _("Name: {name}\nPublic Key: {key}…").format( name=name or _("Unknown"), key=pub_key_hex[:16] ), lambda: ( self._window.add_contact(pub_key, contact_type, name or "Unknown"), self._window.show_toast(_("Contact {} added").format(name or pub_key_hex[:16])), ), ) return self._window.show_toast(_("Unrecognized meshcore:// link")) @staticmethod def _parse_advert_name(frame: bytes) -> tuple: """Extract name and type from a raw advert packet frame. Returns (name, adv_type) or ('', 0) if parsing fails. Frame layout: header(1) + path_len(1) + path(var) + pubkey(32) + timestamp(4) + signature(64) + appdata(flags + ...) """ try: i = 0 frame[i] i += 1 path_len_byte = frame[i] i += 1 hop_count = path_len_byte & 0x3F hash_size = ((path_len_byte >> 6) & 0x03) + 1 i += hop_count * hash_size # skip path i += 32 # pub key i += 4 # timestamp i += 64 # signature if i >= len(frame): return ("", 0) flags = frame[i] i += 1 adv_type = flags & 0x0F if flags & 0x10: # has latlon i += 8 if flags & 0x20: # has feat1 i += 2 if flags & 0x40: # has feat2 i += 2 if flags & 0x80: # has name name = frame[i:].decode("utf-8", errors="replace").rstrip("\x00") return (name, adv_type) return ("", adv_type) except (IndexError, ValueError): return ("", 0) def _show_import_dialog(self, heading, body, on_confirm): """Show a confirmation dialog before importing.""" dialog = Adw.AlertDialog(heading=heading, body=body) dialog.add_response("cancel", _("Cancel")) dialog.add_response("add", _("Add")) dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) dialog.connect("response", lambda d, r: on_confirm() if r == "add" else None) dialog.present(self._window) def do_startup(self): # Register GResource before chaining up so GTK can auto-discover # gtk/help-overlay.ui during Gtk.Application.do_startup(). self._load_gresource() Adw.Application.do_startup(self) self._setup_icon_theme() self._load_css() self._setup_actions() self._apply_saved_theme() def _load_gresource(self): """Load the bundled GResource file.""" import os import meshy as _meshy_pkg resource_path = os.path.join(_meshy_pkg.pkgdatadir, "meshy.gresource") if os.path.exists(resource_path): resource = Gio.Resource.load(resource_path) Gio.resources_register(resource) def _setup_icon_theme(self): """Add custom icon resource path (requires display, call after startup).""" icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) icon_theme.add_resource_path("/page/codeberg/sesivany/Meshy/icons") def _load_css(self): from meshy.platforms import is_macos # Load base CSS from resource provider = Gtk.CssProvider() provider.load_from_resource("/page/codeberg/sesivany/Meshy/style.css") Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) # Add macOS-specific CSS for traffic light buttons if is_macos(): macos_css = b""" /* macOS traffic light button spacing */ .macos-window .nav-rail { /* Add space at top for traffic lights (28px buttons + padding) */ padding-top: 40px; } .macos-window #sidebar_header { /* Add left padding to avoid traffic light overlap */ padding-left: 80px; } .macos-window #connection_header { /* Connection screen header also needs spacing */ padding-left: 80px; } """ macos_provider = Gtk.CssProvider() macos_provider.load_from_data(macos_css) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), macos_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) def _request_background_portal(self): settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") autostart = settings.get_boolean("autostart") self._bg_portal.request_background( _("Receiving messages in the background"), autostart, ["meshy", "--hidden"] if autostart else [], self._on_background_response, ) def _enable_background_mode(self): if not self._background_held: self.hold() self._background_held = True if self._window: self._window.set_background_mode(True) self._request_background_portal() def _disable_background_mode(self): if self._background_held: self.release() self._background_held = False if self._window: self._window.set_background_mode(False) self._bg_portal.request_background( _("Receiving messages in the background"), False, [], lambda bg, auto: None, ) self._bg_portal.clear_status() def _on_background_response(self, bg_granted, autostart_granted): log.info("Background portal response: bg=%s autostart=%s", bg_granted, autostart_granted) if not bg_granted and self._background_held: self.release() self._background_held = False if self._window: self._window.set_background_mode(False) def update_background_mode(self, enabled): if enabled: self._enable_background_mode() else: self._disable_background_mode() def update_autostart(self, enabled): if not enabled: self._bg_portal.request_background( _("Receiving messages in the background"), False, [], self._on_background_response, ) elif self._background_held: self._request_background_portal() def _do_full_quit(self, *_args): if self._window: self._window.shutdown() if self._background_held: self.release() self._background_held = False self._bg_portal.clear_status() self.quit() def _setup_actions(self): quit_action = Gio.SimpleAction.new("quit", None) quit_action.connect("activate", self._do_full_quit) self.add_action(quit_action) self.set_accels_for_action("win.quit", ["q"]) about_action = Gio.SimpleAction.new("about", None) about_action.connect("activate", self._on_about) self.add_action(about_action) import meshy as _meshy_pkg if _meshy_pkg.SHORTCUTS_DIALOG_ENABLED: self.set_accels_for_action("win.show-help-overlay", ["question"]) self.set_accels_for_action("win.disconnect", ["d"]) self.set_accels_for_action("win.help", ["F1"]) self.set_accels_for_action("win.navigate-device", ["1"]) self.set_accels_for_action("win.navigate-contacts", ["2"]) self.set_accels_for_action("win.navigate-channels", ["3"]) self.set_accels_for_action("win.navigate-map", ["4"]) self.set_accels_for_action("win.navigate-settings", ["5"]) self.set_accels_for_action("win.search", ["f"]) self.set_accels_for_action("win.new-message", ["n"]) self.set_accels_for_action("win.navigate-prev", ["Up"]) self.set_accels_for_action("win.navigate-next", ["Down"]) self.set_accels_for_action("win.navigate-prev-unread", ["Up"]) self.set_accels_for_action("win.navigate-next-unread", ["Down"]) self.set_accels_for_action("win.custom-tiles", ["m"]) def _apply_saved_theme(self): from meshy.theme_manager import ThemeManager ThemeManager.get_default().apply_from_settings() def _on_about(self, action, param): about = Adw.AboutDialog( application_name="Meshy", application_icon="page.codeberg.sesivany.Meshy", version=self.version, developer_name="Jiri Eischmann", developers=[ "Jiri Eischmann", "Jaroslav Řezník", "stereo", "vanous", "Hylke Bons", "Moritz Bitsch", "Petr Menšík", "Manuel Traut", "Lukáš Viktora", "clauwn", ], translator_credits=_( "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "sesivany — Czech" ), artists=["Hylke Bons https://planetpeanut.studio"], license_type=Gtk.License.GPL_3_0, comments=_("A GTK client for MeshCore LoRa mesh network devices"), website="https://meshy-app.org/", support_url="https://meshy-app.org/docs/", issue_url="https://codeberg.org/sesivany/meshy/issues", copyright="\u00a9 2026 Jiri Eischmann", ) about.add_legal_section( "libshumate", "\u00a9 libshumate contributors", Gtk.License.LGPL_2_1, None, ) about.add_legal_section( "zbar", "\u00a9 zbar contributors", Gtk.License.LGPL_2_1, None, ) about.add_legal_section( "protobuf-c", "\u00a9 protobuf-c contributors", Gtk.License.BSD, None, ) about.add_legal_section( "PyCryptodome", "\u00a9 PyCryptodome contributors", Gtk.License.BSD, None, ) about.add_legal_section( "pyzbar", "\u00a9 pyzbar contributors", Gtk.License.MIT_X11, None, ) about.add_legal_section( "pySerial", "\u00a9 pySerial contributors", Gtk.License.BSD, None, ) about.add_legal_section( "Segno", "\u00a9 Segno contributors", Gtk.License.BSD, None, ) about.add_legal_section( _("Adwaita Symbolic Icons"), "\u00a9 GNOME Project", Gtk.License.LGPL_3_0, None, ) about.present(self._window) meshy/src/background_portal.py000066400000000000000000000110161523412555500170520ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """XDG Background Portal integration for background mode and autostart.""" import logging import os from gi.repository import Gio, GLib log = logging.getLogger(__name__) PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" PORTAL_BACKGROUND_IFACE = "org.freedesktop.portal.Background" PORTAL_REQUEST_IFACE = "org.freedesktop.portal.Request" class BackgroundPortal: def __init__(self): self._bus = None self._signal_id = None def _ensure_bus(self): if self._bus is None: try: self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) except GLib.Error as e: log.warning("Cannot connect to session bus: %s", e.message) return False return True def request_background(self, reason, autostart, commandline, callback): """Request permission to run in background and optionally autostart. callback(bg_granted, autostart_granted) is called on the main thread. """ if not self._ensure_bus(): GLib.idle_add(lambda: callback(False, False) or False) return token = f"meshy_bg_{os.getpid()}" sender = self._bus.get_unique_name().replace(".", "_").lstrip(":") request_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}" self._cleanup_signal() self._signal_id = self._bus.signal_subscribe( PORTAL_BUS_NAME, PORTAL_REQUEST_IFACE, "Response", request_path, None, Gio.DBusSignalFlags.NO_MATCH_RULE, lambda *args: self._on_response(callback, *args), ) options = { "handle_token": GLib.Variant("s", token), "reason": GLib.Variant("s", reason), "autostart": GLib.Variant("b", autostart), } if commandline: options["commandline"] = GLib.Variant("as", commandline) params = GLib.Variant("(sa{sv})", ("", options)) self._bus.call( PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, PORTAL_BACKGROUND_IFACE, "RequestBackground", params, None, Gio.DBusCallFlags.NONE, 30000, None, self._on_call_done, ) def _on_call_done(self, bus, result): try: bus.call_finish(result) except GLib.Error as e: log.warning( "Could not request background permission via portal: %s", e.message, ) self._cleanup_signal() def _on_response(self, callback, bus, sender, path, iface, signal, params): self._cleanup_signal() response = params.get_child_value(0).get_uint32() if response != 0: log.info("Background permission denied (response=%d)", response) GLib.idle_add(lambda: callback(False, False) or False) return results = params.get_child_value(1) bg_val = results.lookup_value("background", GLib.VariantType.new("b")) autostart_val = results.lookup_value("autostart", GLib.VariantType.new("b")) bg_granted = bg_val.get_boolean() if bg_val else False autostart_granted = autostart_val.get_boolean() if autostart_val else False log.info( "Background portal: bg=%s autostart=%s", bg_granted, autostart_granted, ) GLib.idle_add(lambda: callback(bg_granted, autostart_granted) or False) def set_status(self, message): if not self._ensure_bus(): return try: options = {} if message: options["message"] = GLib.Variant("s", message) self._bus.call_sync( PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, PORTAL_BACKGROUND_IFACE, "SetStatus", GLib.Variant("(a{sv})", (options,)), None, Gio.DBusCallFlags.NONE, 5000, None, ) except GLib.Error as e: log.warning("Could not set background status via portal: %s", e.message) def clear_status(self): self.set_status("") def _cleanup_signal(self): if self._signal_id and self._bus: self._bus.signal_unsubscribe(self._signal_id) self._signal_id = None meshy/src/backup.py000066400000000000000000000637251523412555500146350ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Backup and restore for MeshCore device configuration and messages.""" import glob import json import logging import os import uuid from datetime import datetime from gi.repository import GLib from meshy.models import ( ChannelMessage, Contact, Message, MessageStatus, NotificationLevel, ) from meshy.protocol import ( build_add_update_contact, build_device_query, build_set_auto_add_config, build_set_channel, ) log = logging.getLogger(__name__) class BackupManager: """Stateless helper for building and applying backup dicts.""" ALL_SECTIONS = { "device_settings", "device_identity", "contacts", "channels", "messages", "regions", "discovered_contacts", "path_history", "local_settings", } def export_backup(self, win, sections: set[str] | None = None) -> dict: if sections is None: sections = self.ALL_SECTIONS si = win.self_info di = win.device_info backup = { "name": di.name or si.get("name", ""), "public_key": di.public_key or si.get("public_key", ""), } if "device_settings" in sections: backup["radio_settings"] = { "frequency": int(di.radio_freq * 1000) if di.radio_freq else si.get("radio_freq", 0) * 1000, "bandwidth": int(di.radio_bw * 1000) if di.radio_bw else si.get("radio_bw", 0) * 1000, "spreading_factor": di.radio_sf or si.get("radio_sf", 0), "coding_rate": di.radio_cr or si.get("radio_cr", 0), "tx_power": di.tx_power or si.get("tx_power", 0), } backup["position_settings"] = { "latitude": str(si.get("adv_lat", 0.0)), "longitude": str(si.get("adv_lon", 0.0)), } backup["other_settings"] = { "manual_add_contacts": 1 if si.get("manual_add_contacts") else 0, "advert_location_policy": si.get("adv_loc_policy", 0), } backup["auto_add_settings"] = win.get_auto_add_config() if "channels" in sections: channels_list = [] for ch in win.channels: if not ch.is_empty: channels_list.append( { "name": ch.name, "secret": ch.psk_hex, } ) if channels_list: backup["channels"] = channels_list retention = {} for ch in win.channels: if not ch.is_empty and ch.retention_days != 0: retention[str(ch.index)] = ch.retention_days if retention: backup["retention_levels"] = retention if "contacts" in sections: contacts_list = [] for c in win.contacts: contacts_list.append( { "type": c.type, "name": c.name, "public_key": c.public_key_hex, "flags": c.flags, "latitude": str(c.latitude) if c.latitude else "0.0", "longitude": str(c.longitude) if c.longitude else "0.0", "last_advert": int(c.last_seen.timestamp()) if c.last_seen else 0, "last_modified": int(c.device_lastmod.timestamp()) if c.device_lastmod else 0, "out_path": ",".join(c.path_hops) if c.path_hops else None, } ) if contacts_list: backup["contacts"] = contacts_list if win.storage: if "messages" in sections: dm_messages = {} for c in win.contacts: msgs = win.storage.get_messages(c.public_key_hex, limit=10000) if msgs: dm_messages[c.public_key_hex] = [ { "text": m.text, "timestamp": m.timestamp.timestamp(), "is_outgoing": m.is_outgoing, "sender_key_hex": m.sender_key_hex, "room_sender_name": m.room_sender_name, } for m in msgs ] if dm_messages: backup["messages"] = dm_messages ch_messages = {} for ch in win.channels: if ch.is_empty: continue msgs = win.storage.get_channel_messages(ch.index, limit=10000) if msgs: ch_messages[str(ch.index)] = [ { "sender_name": m.sender_name, "text": m.text, "timestamp": m.timestamp.timestamp(), "is_outgoing": m.is_outgoing, } for m in msgs ] if ch_messages: backup["channel_messages"] = ch_messages passwords = {} for c in win.contacts: pw = win.storage.get_room_password(c.public_key_hex) if pw: passwords[c.public_key_hex] = pw if passwords: backup["room_passwords"] = passwords notif = {} for ch in win.channels: if not ch.is_empty and ch.notification_level != NotificationLevel.DEFAULT: notif[str(ch.index)] = ch.notification_level.value if notif: backup["notification_levels"] = notif blocked = list(win.storage.get_blocked_channel_senders()) if blocked: backup["blocked_channel_senders"] = sorted(blocked) if "regions" in sections: regions = win.storage.get_regions() if regions: backup["regions"] = regions if "discovered_contacts" in sections: discovered = [ { "public_key": c.public_key_hex, "name": c.name, "type": c.type, "flags": c.flags, "latitude": c.latitude, "longitude": c.longitude, "last_seen": c.last_seen.timestamp() if c.last_seen else None, "received_at": c.received_at.timestamp() if c.received_at else None, } for c in win.storage.get_discovered_contacts() ] if discovered: backup["discovered_contacts"] = discovered if "path_history" in sections: all_history = {} for c in win.contacts: entries = win.storage.get_path_history(c.public_key_hex) if entries: all_history[c.public_key_hex] = [ { "path_bytes": e["path_bytes"], "path_hash_size": e["path_hash_size"], "hop_count": e["hop_count"], "success_count": e["success_count"], "failure_count": e["failure_count"], "last_trip_ms": e["last_trip_ms"], "route_weight": e["route_weight"], "last_success_at": e["last_success_at"], } for e in entries ] if all_history: backup["path_history"] = all_history if "local_settings" in sections: local = {} scope = win.storage.get_default_scope() if scope: local["default_scope"] = scope if local: backup["local_settings"] = local return backup _BACKUP_KEYS_FOR_SECTION = { "device_settings": [ "name", "radio_settings", "position_settings", "other_settings", "auto_add_settings", ], "device_identity": ["private_key"], "contacts": ["contacts"], "channels": ["channels", "retention_levels"], "messages": [ "messages", "channel_messages", "room_passwords", "notification_levels", "blocked_channel_senders", ], "regions": ["regions"], "discovered_contacts": ["discovered_contacts"], "path_history": ["path_history"], "local_settings": ["local_settings"], } @classmethod def available_sections(cls, backup: dict) -> set[str]: result = set() for section, keys in cls._BACKUP_KEYS_FOR_SECTION.items(): for key in keys: val = backup.get(key) if val: result.add(section) break return result def import_backup(self, win, backup: dict, sections: set[str] | None = None, on_complete=None): if sections is None: sections = self.ALL_SECTIONS key_hex = backup.get("private_key", "") if "device_identity" in sections and key_hex and len(key_hex) == 128: try: key_bytes = bytes.fromhex(key_hex) except ValueError: key_bytes = None if key_bytes: def _after_key(): result = self._do_import_data(win, backup, sections) if on_complete: on_complete(result) def _on_key_error(err): log.warning( "Private key import failed: %s", err.get("message", "") if err else "unknown", ) _after_key() win._frame_handler.expect_ok( on_ok=_after_key, on_error=_on_key_error, ) win.import_private_key(key_bytes) return 0, 0 result = self._do_import_data(win, backup, sections) if on_complete: on_complete(result) return result def _do_import_data(self, win, backup: dict, sections: set[str]): imported_contacts = 0 imported_channels = 0 settings_cmds = [] if "device_settings" in sections: name = backup.get("name", "") if name: settings_cmds.append(lambda: win.set_advert_name(name)) radio = backup.get("radio_settings", {}) if radio: freq = radio.get("frequency", 0) bw = radio.get("bandwidth", 0) sf = radio.get("spreading_factor", 0) cr = radio.get("coding_rate", 0) tx = radio.get("tx_power", 0) if freq and bw and sf and cr: settings_cmds.append(lambda: win.set_radio_params(freq, bw, sf, cr)) if tx: settings_cmds.append(lambda: win.set_tx_power(tx)) pos = backup.get("position_settings", {}) if pos: try: lat = float(pos.get("latitude", 0)) lon = float(pos.get("longitude", 0)) if abs(lat) > 1e-6 or abs(lon) > 1e-6: settings_cmds.append(lambda: win.set_advert_latlon(lat, lon)) except (ValueError, TypeError): pass other = backup.get("other_settings", {}) if other: loc_policy = other.get("advert_location_policy", None) if loc_policy is not None: settings_cmds.append(lambda: win.set_advert_loc_policy(loc_policy)) auto = backup.get("auto_add_settings", {}) if auto: frame = build_set_auto_add_config( chat=auto.get("auto_add_chat", False), repeater=auto.get("auto_add_repeater", False), room=auto.get("auto_add_room_server", False), sensor=auto.get("auto_add_sensor", False), overwrite_oldest=auto.get("overwrite_oldest", False), max_hops=auto.get("max_hops", 0), ) settings_cmds.append(lambda: win.send_frame(frame)) for i, cmd in enumerate(settings_cmds): GLib.timeout_add(i * 300, lambda c=cmd: c() or False) settings_delay = len(settings_cmds) * 300 if "device_settings" in sections: di = win._device_info name = backup.get("name", "") if name: di.name = name radio = backup.get("radio_settings", {}) if radio: freq = radio.get("frequency", 0) bw = radio.get("bandwidth", 0) sf = radio.get("spreading_factor", 0) cr = radio.get("coding_rate", 0) tx = radio.get("tx_power", 0) if freq and bw and sf and cr: di.radio_freq = freq di.radio_bw = bw di.radio_sf = sf di.radio_cr = cr if tx: di.tx_power = tx pos = backup.get("position_settings", {}) if pos: try: lat_v = float(pos.get("latitude", 0)) lon_v = float(pos.get("longitude", 0)) if abs(lat_v) > 1e-6 or abs(lon_v) > 1e-6: di.latitude = lat_v di.longitude = lon_v except (ValueError, TypeError): pass win._settings_view.update_device_info(di) device_cmds = [] if "channels" in sections: channels = backup.get("channels", []) used = {c.index for c in win.channels if not c.is_empty} for ch_data in channels: ch_name = ch_data.get("name", "") secret_hex = ch_data.get("secret", "") if ch_name and secret_hex and len(secret_hex) == 32: try: psk = bytes.fromhex(secret_hex) already = any( c.psk_hex == secret_hex for c in win.channels if not c.is_empty ) if already: continue slot = None for idx in range(win.device_info.max_channels or 8): if idx not in used: slot = idx break if slot is not None: device_cmds.append( lambda s=slot, n=ch_name, p=psk: win.send_frame( build_set_channel(s, n, p) ) ) imported_channels += 1 used.add(slot) except ValueError: pass if win.storage: for ch_idx_str, days in backup.get("retention_levels", {}).items(): win.storage.set_channel_retention_days(int(ch_idx_str), days) if "contacts" in sections: contacts = backup.get("contacts", []) contacts_by_key = {c.public_key_hex: c for c in win.contacts} for c_data in contacts: pub_key_hex = c_data.get("public_key", "") if not pub_key_hex or len(pub_key_hex) != 64: continue if pub_key_hex in contacts_by_key: continue try: pub_key = bytes.fromhex(pub_key_hex) c_name = c_data.get("name", "Unknown") contact_type = c_data.get("type", 1) flags = c_data.get("flags", 0) lat = float(c_data.get("latitude", 0)) lon = float(c_data.get("longitude", 0)) last_mod = c_data.get("last_modified") device_cmds.append( lambda pk=pub_key, ct=contact_type, fl=flags, n=c_name, la=lat, lo=lon, lm=last_mod: win.send_frame( build_add_update_contact( pub_key=pk, contact_type=ct, flags=fl, name=n, lat=la if abs(la) > 1e-6 else None, lon=lo if abs(lo) > 1e-6 else None, last_modified=lm, ) ) ) imported_contacts += 1 except (ValueError, TypeError): pass for i, cmd in enumerate(device_cmds): GLib.timeout_add(settings_delay + i * 300, lambda c=cmd: c() or False) if "messages" in sections and win.storage: with win.storage.batch(): for pub_key_hex, msgs in backup.get("messages", {}).items(): for m in msgs: ts = m.get("timestamp", 0) text = m.get("text", "") if win.storage.message_exists(pub_key_hex, ts, text): continue message = Message( sender_key=bytes.fromhex(m.get("sender_key_hex", pub_key_hex)), text=text, timestamp=datetime.fromtimestamp(ts), is_outgoing=m.get("is_outgoing", False), status=MessageStatus.DELIVERED, message_id=str(uuid.uuid4()), room_sender_name=m.get("room_sender_name", ""), ) win.storage.save_message(pub_key_hex, message) for ch_idx_str, msgs in backup.get("channel_messages", {}).items(): ch_idx = int(ch_idx_str) for m in msgs: msg = ChannelMessage( channel_index=ch_idx, sender_name=m.get("sender_name", ""), text=m.get("text", ""), timestamp=datetime.fromtimestamp(m.get("timestamp", 0)), is_outgoing=m.get("is_outgoing", False), ) win.storage.save_channel_message(msg) for pub_key_hex, pw in backup.get("room_passwords", {}).items(): win.storage.save_room_password(pub_key_hex, pw) for name in backup.get("blocked_channel_senders", []): win.storage.block_channel_sender(name) if win.storage: if "regions" in sections: for region_name in backup.get("regions", []): win.storage.add_region(region_name) if "discovered_contacts" in sections: for dc in backup.get("discovered_contacts", []): pub_key_hex = dc.get("public_key", "") if not pub_key_hex or len(pub_key_hex) != 64: continue contact = Contact( public_key=bytes.fromhex(pub_key_hex), name=dc.get("name", ""), type=dc.get("type", 1), flags=dc.get("flags", 0), latitude=dc.get("latitude"), longitude=dc.get("longitude"), last_seen=datetime.fromtimestamp(dc["last_seen"]) if dc.get("last_seen") else None, received_at=datetime.fromtimestamp(dc["received_at"]) if dc.get("received_at") else None, ) win.storage.save_discovered_contact(contact) if "path_history" in sections: for contact_key, entries in backup.get("path_history", {}).items(): for e in entries: win.storage.import_path_history_entry( contact_key_hex=contact_key, path_bytes=e["path_bytes"], path_hash_size=e.get("path_hash_size", 1), hop_count=e.get("hop_count", 0), success_count=e.get("success_count", 0), failure_count=e.get("failure_count", 0), last_trip_ms=e.get("last_trip_ms"), route_weight=e.get("route_weight", 2.5), last_success_at=e.get("last_success_at"), ) if "local_settings" in sections: local = backup.get("local_settings", {}) if "default_scope" in local: win.storage.set_default_scope(local["default_scope"]) if "messages" in sections and backup.get("blocked_channel_senders"): win._frame_handler.update_blocked_senders() total_delay = settings_delay + len(device_cmds) * 300 + 1000 GLib.timeout_add(total_delay, lambda: win._frame_handler.send_get_contacts() or False) GLib.timeout_add(total_delay + 500, lambda: win._fetch_channels_sequential(0) or False) GLib.timeout_add(total_delay + 1000, lambda: win.send_frame(build_device_query()) or False) return imported_contacts, imported_channels _DAY_MS = 24 * 60 * 60 * 1000 _MAX_AUTO_BACKUPS = 7 class AutoBackupManager: def __init__(self): self._timer_id = None self._backup = BackupManager() @staticmethod def _backups_dir() -> str: data_dir = os.path.join( os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "meshy", "backups", ) os.makedirs(data_dir, exist_ok=True) return data_dir @staticmethod def _safe_address(device_address: str) -> str: return device_address.replace(":", "_").replace("/", "_") def run(self, win) -> bool: if not win.is_connected or not win.storage: return False try: from gi.repository import Gio settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") sections = set(settings.get_strv("auto-backup-sections")) sections.discard("device_identity") backup = self._backup.export_backup(win, sections=sections or None) addr = self._safe_address(win._transport.connected_address or "unknown") date_str = datetime.now().strftime("%Y-%m-%d") filename = f"auto_{addr}_{date_str}.json" path = os.path.join(self._backups_dir(), filename) with open(path, "w", encoding="utf-8") as f: json.dump(backup, f, indent=2, ensure_ascii=False) log.info("Auto-backup saved: %s", path) self._prune(addr) return True except Exception as e: log.error("Auto-backup failed: %s", e) return False def schedule(self, win): self.cancel() self.run(win) def _daily_backup(): self.run(win) return True self._timer_id = GLib.timeout_add(_DAY_MS, _daily_backup) def cancel(self): if self._timer_id is not None: GLib.source_remove(self._timer_id) self._timer_id = None def list_backups(self, device_address: str | None = None) -> list[dict]: backups_dir = self._backups_dir() if device_address: pattern = f"auto_{self._safe_address(device_address)}_*.json" else: pattern = "auto_*.json" files = sorted(glob.glob(os.path.join(backups_dir, pattern)), reverse=True) result = [] for path in files: try: with open(path, encoding="utf-8") as f: data = json.load(f) mtime = os.path.getmtime(path) mdt = datetime.fromtimestamp(mtime) result.append( { "path": path, "filename": os.path.basename(path), "name": data.get("name", ""), "contacts": len(data.get("contacts", [])), "channels": len(data.get("channels", [])), "date": os.path.basename(path).rsplit("_", 1)[-1].replace(".json", ""), "time": mdt.strftime("%H:%M"), "mtime_dt": mdt, } ) except Exception: continue return result def _prune(self, safe_address: str): pattern = os.path.join(self._backups_dir(), f"auto_{safe_address}_*.json") files = sorted(glob.glob(pattern)) while len(files) > _MAX_AUTO_BACKUPS: oldest = files.pop(0) try: os.remove(oldest) log.info("Pruned old auto-backup: %s", oldest) except OSError as e: log.warning("Failed to prune %s: %s", oldest, e) meshy/src/ble.py000066400000000000000000000037731523412555500141270ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """BLE transport factory for cross-platform support. This module provides a platform-agnostic interface for BLE communication. The actual implementation is selected at runtime based on the platform: - Linux: BlueZ D-Bus API - macOS: CoreBluetooth framework (to be implemented) """ from meshy.platforms import is_linux, is_macos # Re-export BleDevice for backwards compatibility from meshy.platforms.ble_base import BleDevice __all__ = ["BleDevice", "BleTransport", "create_ble_transport"] def create_ble_transport(): """Factory function to create platform-appropriate BLE transport. Returns: BleTransportBase: Platform-specific BLE transport implementation Note: On macOS, if CoreBluetooth fails to initialize (metaclass conflict), this returns None and BLE features will be unavailable. """ if is_macos(): try: from meshy.platforms.macos.ble_corebluetooth import BleTransportCoreBluetooth # Use PyObjC alloc/init pattern for NSObject subclasses return BleTransportCoreBluetooth.alloc().init() except (TypeError, ImportError) as e: import logging log = logging.getLogger(__name__) log.warning(f"BLE not available on macOS: {e}") log.info("USB and TCP connectivity still available") return None elif is_linux(): from meshy.platforms.linux.ble_bluez import BleTransportBlueZ return BleTransportBlueZ() else: import logging logging.getLogger(__name__).info( "BLE not available on this platform, USB and TCP connectivity still available" ) return None class BleTransport: """BLE transport factory class for backwards compatibility. Usage: transport = BleTransport() # Returns platform-specific implementation """ def __new__(cls): return create_ble_transport() meshy/src/camera.py000066400000000000000000000014111523412555500146000ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Platform-agnostic camera/QR scanner service.""" from meshy.platforms import is_macos def create_qr_scanner(window, on_result): """Create platform-appropriate QR scanner dialog. Args: window: Parent GTK window on_result: Callback function(data: str) called with QR code data Returns: QR scanner dialog instance with start() method """ if is_macos(): from meshy.platforms.macos.camera_avfoundation import QRScannerDialogMacOS return QRScannerDialogMacOS(window, on_result) else: from meshy.qr_scanner import QRScannerDialog return QRScannerDialog(window, on_result) meshy/src/connection_controller.py000066400000000000000000000635641523412555500177730ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Connection, sync, and reconnect logic extracted from MeshyWindow.""" import gettext import logging import time from gi.repository import Adw, Gio, GLib, Gtk from meshy.ble import BleDevice from meshy.models import DeviceInfo from meshy.protocol import ( build_app_start, build_device_query, build_get_auto_add_config, build_get_batt_and_storage, build_get_channel, build_get_custom_vars, build_get_device_time, build_sync_next_message, ) from meshy.storage import Storage from meshy.transport_base import ConnectionState from meshy.utils import TimeoutManager log = logging.getLogger(__name__) _HANDSHAKE_TIMEOUT_MS = 5000 _MAX_HANDSHAKE_RETRIES = 2 _SYNC_TIMEOUT_MS = 90000 _MAX_RECONNECT_ATTEMPTS = 3 _RECONNECT_DELAYS = [2000, 4000, 8000] _CONTACTS_SYNC_TIMEOUT_MS = 10000 _CONTACTS_SYNC_MAX_RETRIES = 3 _CHANNEL_SYNC_TIMEOUT_MS = 15000 _CHANNEL_SYNC_MAX_RETRIES = 3 _MSG_SYNC_TIMEOUT_MS = 5000 _MSG_SYNC_MAX_RETRIES = 3 class ConnectionController: """Owns connection lifecycle, sync state, and reconnect logic.""" def __init__(self, win): self._win = win self.syncing: bool = False self.msg_syncing: bool = False self.sync_msg_count: int = 0 self.sync_contact_total: int = 0 self.sync_contact_current: int = 0 self.synced_contact_keys: set | None = None self.contacts_iter_busy: bool = False self.contacts_refetch_pending: bool = False self.incremental_sync: bool = False self.gps_poll_pending: bool = False self.contacts_sync_retries: int = 0 self.channel_sync_index: int = -1 self.channel_sync_retries: int = 0 self.msg_sync_retries: int = 0 self.handshake_received: bool = False self.handshake_retries: int = 0 self.reconnect_attempts: int = 0 self._timeouts = TimeoutManager() self.last_connected_address: str | None = None self.manual_disconnect: bool = False self.batt_timer_id: int | None = None self.watchdog_timer_id: int | None = None self.last_device_rx: float = 0.0 self.pending_device_address: str | None = None self.pending_transport_type: str | None = None def setup_transport_callbacks(self, transport): transport.on_state_changed = self.on_ble_state_changed transport.on_data_received = self._win._on_ble_data_received transport.on_device_discovered = self.on_ble_device_discovered transport.on_error = self._on_transport_error def _on_transport_error(self, message: str): GLib.idle_add(self._win.show_toast, message) def auto_connect(self): transport = self._win._transport if transport is not None: self._win._debug_log(f"auto_connect: state={transport.state}") if transport.state != ConnectionState.DISCONNECTED: return False settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") last_addr = settings.get_string("last-device-address") last_transport = settings.get_string("last-transport") self._win._debug_log(f"auto_connect: last_addr={last_addr}, transport={last_transport}") if last_addr: if last_transport == "tcp": from meshy.tcp import TcpTransport self._win._tcp = TcpTransport() self._win._transport = self._win._tcp self.setup_transport_callbacks(self._win._tcp) elif last_transport == "usb": from meshy.usb_serial import UsbSerialTransport self._win._usb = UsbSerialTransport() self._win._transport = self._win._usb self.setup_transport_callbacks(self._win._usb) else: self._win._transport = self._win._ble # Store address for auto-reconnect if this fails self.last_connected_address = last_addr self._win._show_connecting_screen(last_addr) self._win._transport.connect_by_address(last_addr) # Add fallback timeout in case connection doesn't start (e.g., BLE not ready) def _check_startup_connection(): if self._win._transport.state == ConnectionState.DISCONNECTED: # Connection didn't start, begin auto-reconnect sequence log.warning("Startup connection failed to start, beginning auto-reconnect") self._schedule_reconnect() return False GLib.timeout_add(1000, _check_startup_connection) else: self._win._show_connection_screen() return False def connect_to_device(self, device, transport=None): if transport: self._win._transport = transport self.setup_transport_callbacks(transport) else: self._win._transport = self._win._ble self.setup_transport_callbacks(self._win._ble) from meshy.tcp import TcpDevice from meshy.usb_serial import UsbDevice self.pending_device_address = device.address if isinstance(device, TcpDevice): self.pending_transport_type = "tcp" elif isinstance(device, UsbDevice): self.pending_transport_type = "usb" else: self.pending_transport_type = "ble" self._win._connection_banner.set_title(_("Connecting...")) self._win._connection_banner.set_revealed(True) self._win._transport.connect_device(device.path) def show_scan_dialog(self): dialog = Adw.AlertDialog( heading=_("Scan for Devices"), body=_("Scanning for MeshCore devices..."), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("scan", _("Scan")) dialog.set_response_appearance("scan", Adw.ResponseAppearance.SUGGESTED) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/scan-dialog-content.ui" ) dialog.set_extra_child(builder.get_object("content")) self._scan_listbox = builder.get_object("scan_listbox") self._scan_dialog = dialog self._win._ble.start_scan() def _on_response(d, response): if response == "cancel": self._win._ble.stop_scan() self._scan_dialog = None dialog.connect("response", _on_response) self._scan_listbox.connect("row-activated", self._on_scan_device_selected) dialog.present(self._win) def _on_scan_device_selected(self, listbox, row): device = row._ble_device if self._scan_dialog: self._scan_dialog.close() self._scan_dialog = None self._win._ble.stop_scan() self.pending_device_address = device.address self.pending_transport_type = "ble" self._win._ble.connect_device(device.path) def on_ble_device_discovered(self, device: BleDevice): def _add_to_list(): if not hasattr(self, "_scan_dialog") or not self._scan_dialog: return False row = Adw.ActionRow( title=device.name, subtitle=device.address, ) row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-symbolic")) row.set_activatable(True) row._ble_device = device self._scan_listbox.append(row) return False GLib.idle_add(_add_to_list) def on_ble_state_changed(self, state: ConnectionState): def _update_ui(): if state == ConnectionState.DISCONNECTED: self._timeouts.cancel_all() self._win.stop_login() self.channel_sync_index = -1 if self._win._device_view_inst is not None: self._win._device_view_inst.update_connection_status(False) if self.batt_timer_id: GLib.source_remove(self.batt_timer_id) self.batt_timer_id = None if self.watchdog_timer_id: GLib.source_remove(self.watchdog_timer_id) self.watchdog_timer_id = None self._win._auto_backup.cancel() # Best-effort logout from rooms/repeaters before cleanup if self._win._logged_in_rooms and self._win.is_connected: from meshy.protocol import build_logout for pkh in self._win._logged_in_rooms: c = self._win._contacts_by_key.get(pkh) if c: self._win.send_frame(build_logout(c.public_key)) if self._win._storage: try: self._win._storage.fail_orphaned_messages() except Exception: pass self._win._storage.close() self._win._storage = None self._win._contacts.clear() self._win._contacts_by_key.clear() self._win._discovered.clear() self._win._discovered_by_key.clear() self._win._channels.clear() self._win._device_info = DeviceInfo() self._win._direct_repeaters.clear() if self._win._signal_icon_timer_id: GLib.source_remove(self._win._signal_icon_timer_id) self._win._signal_icon_timer_id = None self._win._self_info.clear() self._win._msg_ctrl.cleanup_on_disconnect() self._win._logged_in_rooms.clear() self._win._frame_handler._timeouts.cancel_all() self._win._frame_handler.cleanup_on_disconnect() self.contacts_iter_busy = False self.contacts_refetch_pending = False if self._win._contacts_view_inst is not None: self._win._contacts_view_inst.update_contacts([]) if self._win._channels_view_inst is not None: self._win._channels_view_inst.update_channels([]) self.syncing = False self.msg_syncing = False self.sync_msg_count = 0 self._win._companion_box.set_visible(False) if self._should_auto_reconnect(): self._schedule_reconnect() else: self.reconnect_attempts = 0 settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") settings.set_string("last-device-address", "") settings.set_string("last-device-name", "") self._win._connection_banner.set_title(_("Not connected")) self._win._connection_banner.set_button_label(_("Connect")) self._win._connection_banner.set_revealed(True) self._win._show_connection_screen() if not self._win.get_visible(): self._win.present() elif state == ConnectionState.SCANNING: self._win._connection_banner.set_title(_("Scanning...")) self._win._connection_banner.set_button_label(_("Stop")) elif state == ConnectionState.CONNECTING: # If we're in auto-reconnect mode, show cancel button and shorter timeout if self._timeouts.is_active("reconnect") or self.reconnect_attempts > 0: attempt = self.reconnect_attempts self._win._connection_banner.set_title( _("Reconnecting ({current}/{total})...").format( current=attempt, total=_MAX_RECONNECT_ATTEMPTS ) ) self._win._connection_banner.set_button_label(_("Cancel")) self._start_connect_timeout(10000) else: self._win._connection_banner.set_title(_("Connecting...")) self._win._connection_banner.set_button_label("") self._start_connect_timeout() elif state == ConnectionState.CONNECTED: self._timeouts.cancel("connect") addr = self.pending_device_address if addr: settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") settings.set_string("last-device-address", addr) settings.set_string("last-transport", self.pending_transport_type or "ble") self.pending_device_address = None self._win._connection_banner.set_title(_("Syncing...")) self._win._connection_banner.set_button_label("") self._win._connection_banner.set_revealed(True) transport_type = self.pending_transport_type or Gio.Settings.new( "page.codeberg.sesivany.Meshy" ).get_string("last-transport") self._win._device_view.update_connection_status(True, transport_type) self._win._settings_view.update_transport_type(transport_type) self._win._hide_connection_screen() self.on_connected() return False GLib.idle_add(_update_ui) def _start_connect_timeout(self, timeout_ms=25000): self._timeouts.start("connect", timeout_ms, self._on_connect_timeout, timeout_ms) def _on_connect_timeout(self, timeout_ms): if self._win._transport and self._win._transport.state == ConnectionState.CONNECTING: log.info(f"Connection timed out after {timeout_ms/1000:.0f} seconds") self._win._transport.disconnect() def _start_handshake_timeout(self): self._timeouts.start("handshake", _HANDSHAKE_TIMEOUT_MS, self._on_handshake_timeout) def _on_handshake_timeout(self): if self.handshake_received: return False if self.handshake_retries < _MAX_HANDSHAKE_RETRIES: self.handshake_retries += 1 log.info(f"Handshake retry {self.handshake_retries}/{_MAX_HANDSHAKE_RETRIES}") self._win.send_frame(build_device_query()) self._start_handshake_timeout() return False log.warning("Device did not respond to handshake after retries, disconnecting") self._win.show_toast(_("Device did not respond. It may not support this connection type.")) self._win._transport.disconnect() return False def on_handshake_received(self): if self.handshake_received: return self.handshake_received = True self._timeouts.cancel("handshake") self.handshake_retries = 0 self.reconnect_attempts = 0 def on_connected(self): self.syncing = True self.handshake_received = False self.handshake_retries = 0 self.last_connected_address = self._win._transport.connected_address self._start_sync_timeout() self._start_handshake_timeout() device_addr = self._win._transport.connected_address if self._win._storage: self._win._storage.close() self._win._storage = Storage(device_address=device_addr) self._win._contacts = self._win._storage.get_contacts() self._win._contacts_by_key = {c.public_key_hex: c for c in self._win._contacts} self._win._discovered = self._win._storage.get_discovered_contacts() self._win._discovered_by_key = {c.public_key_hex: c for c in self._win._discovered} self._win._channels = self._win._storage.get_channels() self._win._contacts_view.update_contacts(self._win._contacts) self._win._channels_view.update_channels(self._win._channels) self._win._frame_handler.rebuild_key_store() self._win._frame_handler.restore_unread_counts() self._win._frame_handler.load_blocked_senders() self._win._settings_view.load_regions() self._win._settings_view.restore_default_scope() self._win.send_frame(build_device_query()) GLib.timeout_add(50, lambda: self._win.send_frame(build_app_start()) or False) GLib.timeout_add(100, lambda: self._win.send_frame(build_get_device_time()) or False) GLib.timeout_add(150, lambda: self._win.send_frame(build_get_batt_and_storage()) or False) self.contacts_sync_retries = 0 GLib.timeout_add(200, self._send_contacts_with_timeout) GLib.timeout_add(250, self._win._apply_default_scope) self.batt_timer_id = GLib.timeout_add(120000, self.poll_battery) self.last_device_rx = time.monotonic() self.watchdog_timer_id = GLib.timeout_add(15000, self.connection_watchdog) def _start_sync_timeout(self): self._timeouts.start("sync", _SYNC_TIMEOUT_MS, self._on_sync_timeout) def _on_sync_timeout(self): if not self.syncing: return False log.warning("Sync timed out, disconnecting") if not self.handshake_received: self.manual_disconnect = True self._timeouts.cancel("contacts_sync") self._timeouts.cancel("channel_sync") self._win._transport.disconnect() return False def _send_contacts_with_timeout(self): self._win._frame_handler.send_get_contacts() self._start_contacts_sync_timeout() return False def _start_contacts_sync_timeout(self): self._timeouts.start( "contacts_sync", _CONTACTS_SYNC_TIMEOUT_MS, self._on_contacts_sync_timeout ) def _cancel_contacts_sync_timeout(self): self._timeouts.cancel("contacts_sync") def _on_contacts_sync_timeout(self): if not self.syncing: return False self.contacts_sync_retries += 1 if self.contacts_sync_retries < _CONTACTS_SYNC_MAX_RETRIES: log.warning( f"Contacts sync timed out, retry " f"{self.contacts_sync_retries}/{_CONTACTS_SYNC_MAX_RETRIES}" ) self.contacts_iter_busy = False self._send_contacts_with_timeout() else: log.warning("Contacts sync failed after retries, skipping to channels") self.contacts_iter_busy = False self.synced_contact_keys = None self.fetch_channels_sequential(0) return False def _should_auto_reconnect(self) -> bool: if self.manual_disconnect: self.manual_disconnect = False return False if self.reconnect_attempts >= _MAX_RECONNECT_ATTEMPTS: return False return self.last_connected_address def _schedule_reconnect(self): delay = _RECONNECT_DELAYS[min(self.reconnect_attempts, len(_RECONNECT_DELAYS) - 1)] attempt = self.reconnect_attempts + 1 log.info(f"Auto-reconnect attempt {attempt}/{_MAX_RECONNECT_ATTEMPTS} " f"in {delay}ms") # Hide connecting screen if it's showing (e.g., from startup auto-connect) if self._win._main_stack.get_visible_child_name() == "connecting": self._win._show_connection_screen() self._win._connection_banner.set_title( _("Reconnecting ({current}/{total})...").format( current=attempt, total=_MAX_RECONNECT_ATTEMPTS ) ) self._win._connection_banner.set_button_label(_("Cancel")) self._win._connection_banner.set_revealed(True) self._timeouts.start("reconnect", delay, self._do_reconnect) def _do_reconnect(self): self.reconnect_attempts += 1 addr = self.last_connected_address if addr and self._win._transport.state == ConnectionState.DISCONNECTED: log.info(f"Reconnecting to {addr}") self._win._transport.connect_by_address(addr) # If connection didn't start (e.g., BLE not ready), schedule timeout to retry or give up def _check_connection_started(): if self._win._transport.state == ConnectionState.DISCONNECTED: # Connection didn't start, treat as timeout log.warning( f"Reconnect attempt {self.reconnect_attempts}/{_MAX_RECONNECT_ATTEMPTS} failed to start connection (state still DISCONNECTED)" ) if self.reconnect_attempts >= _MAX_RECONNECT_ATTEMPTS: log.info( f"Max reconnect attempts ({_MAX_RECONNECT_ATTEMPTS}) reached, giving up" ) self.reconnect_attempts = 0 settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") settings.set_string("last-device-address", "") settings.set_string("last-device-name", "") self._win._connection_banner.set_title(_("Not connected")) self._win._connection_banner.set_button_label(_("Connect")) self._win._connection_banner.set_revealed(True) self._win._show_connection_screen() else: # Try again log.info( f"Scheduling reconnect attempt {self.reconnect_attempts + 1}/{_MAX_RECONNECT_ATTEMPTS}" ) self._schedule_reconnect() else: log.info( f"Reconnect attempt {self.reconnect_attempts} started successfully (state: {self._win._transport.state})" ) return False GLib.timeout_add(1000, _check_connection_started) return False def cancel_reconnect(self): self._timeouts.cancel("reconnect") def poll_battery(self) -> bool: if self._win._transport.is_connected: self._win.send_frame(build_get_batt_and_storage()) if self._win._custom_vars.get("gps") == "1": self.gps_poll_pending = True GLib.timeout_add(50, lambda: self._win.send_frame(build_device_query()) or False) return self._win._transport.is_connected def connection_watchdog(self) -> bool: if not self._win._transport.is_connected: self.watchdog_timer_id = None return False silence = time.monotonic() - self.last_device_rx if silence > 180: log.warning(f"Connection watchdog: no data for {silence:.0f}s, disconnecting") self._win._transport.disconnect() return False return True def fetch_channels_sequential(self, index): self._timeouts.cancel("channel_sync") max_ch = self._win._device_info.max_channels or 8 if self.syncing: self._win._connection_banner.set_title(_("Syncing channels") + f"\n{index}/{max_ch}") if index >= max_ch: self.channel_sync_index = -1 if self.syncing: self.syncing = False self._timeouts.cancel("sync") self._win._connection_banner.set_revealed(False) self.msg_syncing = True self.sync_msg_count = 0 self._win.send_frame(build_get_auto_add_config()) self._win.send_frame(build_get_custom_vars()) self.msg_sync_retries = 0 self.send_sync_next_message() return self.channel_sync_index = index self.channel_sync_retries = 0 self._win.send_frame(build_get_channel(index)) self._start_channel_sync_timeout(index) def _start_channel_sync_timeout(self, index): self._timeouts.start( "channel_sync", _CHANNEL_SYNC_TIMEOUT_MS, self._on_channel_sync_timeout, index ) def _on_channel_sync_timeout(self, index): if self.channel_sync_index != index: return False self.channel_sync_retries += 1 if self.channel_sync_retries < _CHANNEL_SYNC_MAX_RETRIES: log.warning( f"Channel {index} fetch timed out, retry " f"{self.channel_sync_retries}/{_CHANNEL_SYNC_MAX_RETRIES}" ) self._win.send_frame(build_get_channel(index)) self._start_channel_sync_timeout(index) else: log.warning( f"Channel {index} fetch failed after " f"{_CHANNEL_SYNC_MAX_RETRIES} retries, skipping" ) self.fetch_channels_sequential(index + 1) return False def send_sync_next_message(self): self._timeouts.cancel("msg_sync") self._win.send_frame(build_sync_next_message()) self._timeouts.start("msg_sync", _MSG_SYNC_TIMEOUT_MS, self._on_msg_sync_timeout) def _on_msg_sync_timeout(self): if not self.msg_syncing: return False self.msg_sync_retries += 1 if self.msg_sync_retries < _MSG_SYNC_MAX_RETRIES: log.warning( f"Message sync timed out, retry " f"{self.msg_sync_retries}/{_MSG_SYNC_MAX_RETRIES}" ) self.send_sync_next_message() else: log.warning("Message sync failed after retries, finishing sync") self.finish_msg_sync() return False def finish_msg_sync(self): self._timeouts.cancel("msg_sync") if not self.msg_syncing: return self.msg_syncing = False if self.sync_msg_count > 0: self._win._send_notification( "Meshy", gettext.dngettext( "meshy", "You received {} message while disconnected.", "You received {} messages while disconnected.", self.sync_msg_count, ).format(self.sync_msg_count), "sync-summary", ) self.sync_msg_count = 0 settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") if settings.get_boolean("auto-backup"): self._win._auto_backup.schedule(self._win) self._win.refresh_stats() def send_get_contacts(self, incremental: bool = False): self._win._frame_handler.send_get_contacts(incremental=incremental) meshy/src/frame_handler.py000066400000000000000000002001241523412555500161410ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Protocol frame handling extracted from MeshyWindow.""" import logging import struct import time from datetime import datetime from gi.repository import Gio, GLib from meshy.models import ( Channel, ChannelMessage, Contact, ContactType, Message, MessageStatus, NotificationLevel, ) from meshy.protocol import ( PUSH_CODE_ADVERT, PUSH_CODE_CONTACT_DELETED, PUSH_CODE_LOG_DATA, PUSH_CODE_LOGIN_FAILED, PUSH_CODE_LOGIN_SUCCESS, PUSH_CODE_MSG_WAITING, PUSH_CODE_NEW_ADVERT, PUSH_CODE_PATH_DISCOVERY_RESPONSE, PUSH_CODE_PATH_UPDATED, PUSH_CODE_SEND_CONFIRMED, PUSH_CODE_STATUS_RESPONSE, PUSH_CODE_TELEMETRY_RESPONSE, PUSH_CODE_TRACE_DATA, RESP_CODE_ADVERT_PATH, RESP_CODE_ALLOWED_REPEAT_FREQ, RESP_CODE_AUTO_ADD_CONFIG, RESP_CODE_BATT_AND_STORAGE, RESP_CODE_BINARY_RESPONSE, RESP_CODE_CHANNEL_DATA_RECV, RESP_CODE_CHANNEL_INFO, RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3, RESP_CODE_CONTACT, RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CONTACT_MSG_RECV_V3, RESP_CODE_CONTACTS_START, RESP_CODE_CONTROL_DATA, RESP_CODE_CURR_TIME, RESP_CODE_CUSTOM_VARS, RESP_CODE_DEFAULT_FLOOD_SCOPE, RESP_CODE_DEVICE_INFO, RESP_CODE_DISABLED, RESP_CODE_END_OF_CONTACTS, RESP_CODE_ERR, RESP_CODE_EXPORT_CONTACT, RESP_CODE_NO_MORE_MESSAGES, RESP_CODE_OK, RESP_CODE_PRIVATE_KEY, RESP_CODE_SELF_INFO, RESP_CODE_SENT, RESP_CODE_STATS, STATS_TYPE_RADIO, build_get_allowed_repeat_freq, build_get_contact_by_key, build_get_contacts, build_set_device_time, identify_response, parse_advert_path_response, parse_allowed_repeat_freq, parse_auto_add_config, parse_batt_and_storage, parse_channel_data, parse_channel_info, parse_channel_message, parse_contact_frame, parse_contact_message, parse_curr_time, parse_custom_vars, parse_default_flood_scope, parse_device_info, parse_discover_response, parse_error_response, parse_neighbors_response, parse_push_send_confirmed, parse_self_info, parse_sent_response, parse_stats_response, parse_status_response, parse_trace_response, ) from meshy.utils import TimeoutManager, format_trip_time log = logging.getLogger(__name__) class FrameHandler: """Handles incoming protocol frames from the companion device.""" def __init__(self, win): self._win = win self._timeouts = TimeoutManager() self._pending_channel_snr = {} self._ok_callbacks = [] self._blocked_senders: set[str] = set() def load_blocked_senders(self): if self._win._storage: self._blocked_senders = self._win._storage.get_blocked_channel_senders() def update_blocked_senders(self): self.load_blocked_senders() def cleanup_on_disconnect(self): self._pending_channel_snr.clear() for _on_ok, _on_error, timeout_id in self._ok_callbacks: GLib.source_remove(timeout_id) self._ok_callbacks.clear() def handle_frame(self, data: bytes): """Process a received protocol frame on the main thread.""" code = identify_response(data) if code is None: return False # Log all responses when login callback is active if self._win._login_callback: log.info( f"RX during login wait: code=0x{code:02x} ({code}) len={len(data)} hex={data.hex()[:60]}..." ) log.debug(f"RX code={code} len={len(data)} hex={data.hex()}") if code == RESP_CODE_OK: if self._ok_callbacks: on_ok, _on_error, timeout_id = self._ok_callbacks.pop(0) GLib.source_remove(timeout_id) on_ok() return False self._win._ignore_next_err = False return False if code == RESP_CODE_ERR: err = parse_error_response(data) if self._ok_callbacks: _on_ok, on_error, timeout_id = self._ok_callbacks.pop(0) GLib.source_remove(timeout_id) on_error(err) return False if self._win._ignore_next_err: self._win._ignore_next_err = False if err: log.debug( f'Suppressed device error: {err["message"]} (code=0x{err["code"]:02x})' ) return False if err and err["code"] == 0x06 and self._win._conn_ctrl.syncing: log.debug(f'Suppressed sync-time illegal argument (code=0x{err["code"]:02x})') return False if err: log.warning(f'Device error: {err["message"]} (code=0x{err["code"]:02x})') if self._win._region_callbacks and err["code"] == 0x03: self._win._table_full_error = True for cb in list(self._win._region_callbacks.values()): cb(None) self._win._region_callbacks.clear() elif not self._win._region_callbacks: self._win.show_toast(_("Device error: {}").format(err["message"])) if self._win._msg_ctrl.sent_msg_queue: front = self._win._msg_ctrl.sent_msg_queue[0] if front is None or (isinstance(front, dict) and "type" in front): self._win._msg_ctrl.sent_msg_queue.pop(0) return False if code == RESP_CODE_SELF_INFO: info = parse_self_info(data) if self._win._conn_ctrl.gps_poll_pending: self._win._conn_ctrl.gps_poll_pending = False if info: lat = info.get("adv_lat", 0.0) lon = info.get("adv_lon", 0.0) if abs(lat) > 1e-6 or abs(lon) > 1e-6: self._win._device_info.latitude = lat self._win._device_info.longitude = lon self._win._device_view.update_location(lat, lon) if self._win._map_view_inst is not None: self._win._map_view.update_contacts(self._win._contacts) return False self._win._conn_ctrl.on_handshake_received() if info: self._win._self_info = info self._win._device_info.public_key = info.get("public_key", "") self._win._device_info.name = info.get("name", "") self._win._device_info.tx_power = info.get("tx_power", 0) self._win._device_info.max_tx_power = info.get("max_tx_power", 0) self._win._device_info.radio_freq = info.get("radio_freq", 0) self._win._device_info.radio_bw = info.get("radio_bw", 0) self._win._device_info.radio_sf = info.get("radio_sf", 0) raw_cr = info.get("radio_cr", 0) if 1 <= raw_cr <= 4: self._win._device_info.cr_old_encoding = True self._win._device_info.radio_cr = raw_cr + 4 else: self._win._device_info.cr_old_encoding = False self._win._device_info.radio_cr = raw_cr lat = info.get("adv_lat", 0.0) lon = info.get("adv_lon", 0.0) if abs(lat) > 1e-6 or abs(lon) > 1e-6: self._win._device_info.latitude = lat self._win._device_info.longitude = lon self._win._device_view.update_device_info(self._win._device_info) self._win._device_view.update_location( info.get("adv_lat", 0.0), info.get("adv_lon", 0.0) ) if self._win._map_view_inst is not None: self._win._map_view.update_contacts(self._win._contacts) self._win._settings_view.update_device_info(self._win._device_info) self._win._settings_view.update_advert_location( info.get("adv_lat", 0.0), info.get("adv_lon", 0.0), info.get("adv_loc_policy", 0), ) self._win._settings_view.update_telemetry_modes( info.get("telemetry_mode_base", 0), info.get("telemetry_mode_loc", 0), info.get("telemetry_mode_env", 0), ) self._win._device_info.multi_acks = info.get("multi_acks", 0) self._win._settings_view.update_multi_acks(info.get("multi_acks", 0)) self._win._update_companion_label() self._win._save_tcp_companion_if_needed() elif code == RESP_CODE_CUSTOM_VARS: self._win._custom_vars = parse_custom_vars(data) self._win._settings_view.update_gps_settings(self._win._custom_vars) self._win._device_view.update_gps_enabled(self._win._custom_vars.get("gps") == "1") elif code == RESP_CODE_DEVICE_INFO: info = parse_device_info(data) if info: for key in ( "model", "ver", "fw_build", "fw_ver", "max_contacts", "max_channels", "path_hash_mode", "repeat", "ble_pin", ): if key in info: setattr(self._win._device_info, key, info[key]) self._win._device_view.update_device_info(self._win._device_info) self._win._settings_view.update_device_info(self._win._device_info) if info.get("fw_ver", 0) >= 9: self._win.send_frame(build_get_allowed_repeat_freq()) elif code == RESP_CODE_ALLOWED_REPEAT_FREQ: ranges = parse_allowed_repeat_freq(data) self._win._device_info.allowed_repeat_freqs = ranges log.info("Allowed repeat freq ranges: %s", ranges) elif code == RESP_CODE_BATT_AND_STORAGE: info = parse_batt_and_storage(data) if info: self._win._device_info.battery_mv = info.get("level", 0) self._win._device_info.storage_used_kb = info.get("used_kb", 0) self._win._device_info.storage_total_kb = info.get("total_kb", 0) self._win._device_view.update_device_info(self._win._device_info) self._win._update_companion_label() elif code == RESP_CODE_STATS: stats = parse_stats_response(data) if stats: sub = stats["sub_type"] self._win._device_stats[sub] = stats self._win._device_view.update_stats(self._win._device_stats) if sub == STATS_TYPE_RADIO: total = stats.get("tx_air_secs", 0) + stats.get("rx_air_secs", 0) if total > self._win._msg_ctrl.prev_total_air_secs: self._win._msg_ctrl.last_airtime_bump = time.monotonic() self._win._msg_ctrl.prev_total_air_secs = total if self._win._radio_stats_callback: self._win._radio_stats_callback(stats) elif code == RESP_CODE_CONTACTS_START: self._win._conn_ctrl.contacts_iter_busy = True self._win._storage._batch_depth += 1 self._win._conn_ctrl._start_contacts_sync_timeout() self._win._conn_ctrl.sync_contact_total = ( int.from_bytes(data[1:5], "little") if len(data) >= 5 else 0 ) self._win._conn_ctrl.sync_contact_current = 0 self._win._conn_ctrl.synced_contact_keys = set() if self._win._conn_ctrl.syncing: self._win._connection_banner.set_title( _("Syncing contacts") + f"\n{0}/{self._win._conn_ctrl.sync_contact_total}" ) elif code == RESP_CODE_CONTACT: contact = parse_contact_frame(data) if contact: self.add_or_update_contact(contact) else: log.warning(f"Failed to parse contact frame (raw {len(data)} bytes), skipping") if self._win._conn_ctrl.contacts_iter_busy: self._win._conn_ctrl._start_contacts_sync_timeout() if contact: c = self.dict_to_contact(contact) if c and self._win._conn_ctrl.synced_contact_keys is not None: self._win._conn_ctrl.synced_contact_keys.add(c.public_key_hex) self._win._conn_ctrl.sync_contact_current += 1 if self._win._conn_ctrl.syncing: self._win._connection_banner.set_title( _("Syncing contacts") + f"\n{self._win._conn_ctrl.sync_contact_current}/{self._win._conn_ctrl.sync_contact_total}" ) elif code == RESP_CODE_END_OF_CONTACTS: self._win._conn_ctrl.contacts_iter_busy = False self._win._conn_ctrl._cancel_contacts_sync_timeout() if self._win._storage._batch_depth > 0: self._win._storage._batch_depth -= 1 if self._win._storage._batch_depth == 0: self._win._storage._db.commit() # Remove contacts no longer on the device (full sync only) if ( self._win._conn_ctrl.synced_contact_keys and not self._win._conn_ctrl.incremental_sync ): synced = self._win._conn_ctrl.synced_contact_keys stale_keys = set() for c in self._win._contacts: if c.public_key_hex not in synced: stale_keys.add(c.public_key_hex) self._win._contacts_by_key.pop(c.public_key_hex, None) self._win._storage.deactivate_contact(c.public_key_hex) self._win._contacts = [ c for c in self._win._contacts if c.public_key_hex not in stale_keys ] self._win._conn_ctrl.synced_contact_keys = None self._win._conn_ctrl.incremental_sync = False self._win._contacts_view.update_contacts(self._win._contacts) if self._win._map_view_inst is not None: self._win._map_view.update_contacts(self._win._contacts) self._win._refresh_chat_header() # Trigger deferred refetch if one was requested during iteration if self._win._conn_ctrl.contacts_refetch_pending: self._win._conn_ctrl.contacts_refetch_pending = False self.schedule_contacts_refetch() # Proceed to channel sync only during initial sync — a later # contacts refetch must NOT restart channels/msg sync (which # would reset sync_msg_count and lose the accumulated count). # Skip if channel sync is already running (contacts timeout # may have started it while contacts were still streaming). if self._win._conn_ctrl.syncing and self._win._conn_ctrl.channel_sync_index < 0: self._win._fetch_channels_sequential(0) elif code in (RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CONTACT_MSG_RECV_V3): self._win._msg_ctrl.last_rx_time = time.monotonic() msg_data = parse_contact_message(data) if msg_data: self.handle_incoming_message(msg_data) self._win._conn_ctrl.msg_sync_retries = 0 self._win._send_sync_next_message() elif code in (RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3): self._win._msg_ctrl.last_rx_time = time.monotonic() msg_data = parse_channel_message(data) if msg_data: self.handle_incoming_channel_message(msg_data) self._win._conn_ctrl.msg_sync_retries = 0 self._win._send_sync_next_message() elif code == RESP_CODE_CHANNEL_DATA_RECV: result = parse_channel_data(data) if result: log.debug( f"Channel data received: ch={result['channel_idx']} " f"type={result['data_type']} len={result['data_len']}" ) elif code == RESP_CODE_NO_MORE_MESSAGES: self._win._finish_msg_sync() elif code == RESP_CODE_SENT: result = parse_sent_response(data) if result and self._win._msg_ctrl.sent_msg_queue: entry = self._win._msg_ctrl.sent_msg_queue.pop(0) if isinstance(entry, dict) and "type" in entry: if "expected_ack" in result: tag_hex = result["expected_ack"].hex() self._win._pending_binary_requests[tag_hex] = entry req_type = entry["type"] if req_type == "neighbors": self._win._neighbors_callback = None elif req_type == "acl": self._win._acl_callback = None elif req_type == "telemetry": self._win._telemetry_callback = None elif entry is None: # Non-message send (channel, CLI, advert, trace, etc.) if self._win._trace_callback: timeout_ms = result.get("suggested_timeout", 15000) if self._win._trace_timeout_callback: self._win._trace_timeout_callback(timeout_ms) elif isinstance(entry, str) and entry.startswith("region:"): # Region discovery request — map ack tag to callback callback_key = entry[7:] # strip 'region:' prefix if "expected_ack" in result and callback_key in self._win._region_callbacks: ack_hex = result["expected_ack"].hex() self._win._region_callbacks[ack_hex] = self._win._region_callbacks.pop( callback_key ) elif "expected_ack" in result: msg_id = entry ack_key = result["expected_ack"].hex() if msg_id not in self._win._msg_ctrl.retry_context: # Already delivered by an earlier attempt's ACK — don't # overwrite DELIVERED status with SENT from this late # RESP_CODE_SENT response. pass else: if ack_key != "00000000": self._win._msg_ctrl.pending_acks[ack_key] = msg_id self._win._msg_ctrl._acks_by_msg.setdefault(msg_id, set()).add(ack_key) self._win._msg_ctrl.register_ack_key(msg_id, ack_key) is_flood = result.get("type", 0) == 1 detail = self._win._msg_ctrl.send_detail(msg_id, is_flood) self._win.set_message_status(msg_id, MessageStatus.SENT, detail) est_timeout = result.get("suggested_timeout", 30000) timeout_id = GLib.timeout_add( int(est_timeout * 1.5), self._win._msg_ctrl._on_message_timeout, msg_id, ack_key, ) self._win._msg_ctrl.pending_timeouts[msg_id] = timeout_id elif result and self._win._trace_callback: # Trace sent confirmation with empty queue (fallback) timeout_ms = result.get("suggested_timeout", 15000) if self._win._trace_timeout_callback: self._win._trace_timeout_callback(timeout_ms) elif code == PUSH_CODE_SEND_CONFIRMED: result = parse_push_send_confirmed(data) if result and "code" in result: ack_key = result["code"] if ack_key in self._win._msg_ctrl.pending_acks: msg_id = self._win._msg_ctrl.pending_acks.pop(ack_key) # Remove remaining ack_keys for this message remaining = self._win._msg_ctrl._acks_by_msg.pop(msg_id, set()) remaining.discard(ack_key) for k in remaining: self._win._msg_ctrl.pending_acks.pop(k, None) if msg_id in self._win._msg_ctrl.pending_timeouts: GLib.source_remove(self._win._msg_ctrl.pending_timeouts.pop(msg_id)) ctx = self._win._msg_ctrl.retry_context.get(msg_id, {}) send_time = ctx.get("ack_send_times", {}).get(ack_key) if send_time is not None: trip_ms = int((time.monotonic() - send_time) * 1000) else: trip_ms = result.get("trip_time_ms", 0) self._win._msg_ctrl.record_path_result( msg_id, success=True, trip_ms=trip_ms, ack_key=ack_key ) strategy = ctx.get("strategy", "traditional") if strategy in ("override_direct", "override_flood"): self._win._msg_ctrl._restore_original_path(ctx) # For flood success, schedule attribution after refetch path_used = ctx.get("path_used", {}) if path_used.get("path_length", 0) < 0: contact = ctx.get("contact") if contact: self._win._msg_ctrl.pending_flood_attributions.setdefault( contact.public_key_hex, [] ).append((trip_ms, False)) self.refresh_contact(contact.public_key_hex) self._win._msg_ctrl.retry_context.pop(msg_id, None) detail = format_trip_time(trip_ms) self._win.set_message_status(msg_id, MessageStatus.DELIVERED, detail) # Re-fetch contacts soon to pick up the newly discovered path self.schedule_contacts_refetch() elif ack_key in self._win._msg_ctrl.grace_acks: # Late ACK arrived during grace period — upgrade from failed to delivered msg_id = self._win._msg_ctrl.grace_acks.pop(ack_key) # Remove remaining ack_keys for this message remaining = self._win._msg_ctrl._grace_by_msg.pop(msg_id, set()) remaining.discard(ack_key) for k in remaining: self._win._msg_ctrl.grace_acks.pop(k, None) if msg_id in self._win._msg_ctrl.grace_timeouts: GLib.source_remove(self._win._msg_ctrl.grace_timeouts.pop(msg_id)) grace_ctx = self._win._msg_ctrl.grace_contexts.pop(msg_id, None) send_time = ( grace_ctx.get("ack_send_times", {}).get(ack_key) if grace_ctx else None ) if send_time is not None: trip_ms = int((time.monotonic() - send_time) * 1000) else: trip_ms = result.get("trip_time_ms", 0) trip_str = format_trip_time(trip_ms) detail = _("{} (late)").format(trip_str) if trip_str else _("late") log.info(f"Late ACK received for message {msg_id[:8]}") self._win.set_message_status(msg_id, MessageStatus.DELIVERED, detail) # Record late path result with reduced weight if grace_ctx: contact_key = grace_ctx["contact_key"] path_used = grace_ctx.get("ack_paths", {}).get(ack_key) or grace_ctx.get( "path_used", {} ) if path_used.get("path_length", -1) >= 0: self._win._msg_ctrl.path_history.record_result( contact_key, path_used["path_bytes"], self._win._msg_ctrl._device_hash_size, path_used["hop_count"], success=True, trip_ms=trip_ms, weight_increment=self._win._msg_ctrl.GRACE_WEIGHT_INCREMENT, ) else: self._win._msg_ctrl.pending_flood_attributions.setdefault( contact_key, [] ).append((trip_ms, True)) self.refresh_contact(contact_key) elif code == PUSH_CODE_MSG_WAITING: self._win._conn_ctrl.msg_sync_retries = 0 self._win._send_sync_next_message() elif code in (PUSH_CODE_ADVERT, PUSH_CODE_NEW_ADVERT): self._win._msg_ctrl.last_rx_time = time.monotonic() contact_dict = parse_contact_frame(data) if contact_dict: contact = self.dict_to_contact(contact_dict) # Notify live advert listener if active if self._win._advert_listener: self._win._advert_listener(contact) # If already a known contact, update it if self._win.is_contact_known(contact.public_key_hex): self.add_or_update_contact(contact_dict) self._win._contacts_view.update_contacts(self._win._contacts) # Always add/update in discovered list (dual-write) self.add_or_update_discovered(contact) elif code == PUSH_CODE_PATH_UPDATED: if len(data) >= 33: pub_key_hex = data[1:33].hex() self.refresh_contact(pub_key_hex) else: self.schedule_contacts_refetch() elif code == RESP_CODE_CHANNEL_INFO: ch_info = parse_channel_info(data) if ch_info: expected = self._win._conn_ctrl.channel_sync_index if expected < 0 or ch_info["channel_idx"] != expected: log.debug( f"Ignoring stale CHANNEL_INFO idx={ch_info['channel_idx']} " f"(expected {expected})" ) return False channel = Channel( index=ch_info["channel_idx"], name=ch_info.get("channel_name", ""), psk=ch_info.get("channel_secret", bytes(16)), ) if channel.is_empty: prev = [c for c in self._win._channels if c.index == channel.index] if prev: self._win._channels = [ c for c in self._win._channels if c.index != channel.index ] self.refresh_channels() else: self.add_or_update_channel(channel) next_idx = ch_info["channel_idx"] + 1 else: log.warning(f"Failed to parse channel info (raw {len(data)} bytes), skipping") next_idx = ( self._win._conn_ctrl.channel_sync_index + 1 if self._win._conn_ctrl.channel_sync_index >= 0 else 0 ) self._win._fetch_channels_sequential(next_idx) elif code == RESP_CODE_CURR_TIME: device_time = parse_curr_time(data) if device_time is not None: now = int(datetime.now().timestamp()) drift = now - device_time if drift > 0: log.info( f"Device clock: {datetime.fromtimestamp(device_time)} " f"(drift {drift}s behind), sending time sync" ) self._win.send_frame(build_set_device_time()) if drift > 300: mins = drift // 60 self._win.show_toast( _("Device clock was {}min behind — synced").format(mins) ) else: log.info( f"Device clock: {datetime.fromtimestamp(device_time)} " f"(drift {drift}s), no sync needed" ) if drift < -300: mins = abs(drift) // 60 self._win.show_toast( _("Device clock is {}min ahead of system").format(mins) ) elif code == RESP_CODE_AUTO_ADD_CONFIG: config = parse_auto_add_config(data) if config: self._win._settings_view.update_auto_add_config(config) elif code == RESP_CODE_DEFAULT_FLOOD_SCOPE: result = parse_default_flood_scope(data) if result is not None: scope_name = result.get("scope_name", "") log.info(f"Default flood scope: " f"{scope_name if scope_name else '(none)'}") elif code == RESP_CODE_EXPORT_CONTACT: if getattr(self._win, "_pending_qr_callback", None): callback = self._win._pending_qr_callback self._win._pending_qr_callback = None callback(data) elif self._win._pending_clipboard_export: self._win._pending_clipboard_export = False clipboard = self._win.get_clipboard() clipboard.set("meshcore://" + data[1:].hex()) # Persist clipboard content so it survives focus loss (Wayland) clipboard.store_async(None, None) elif code == RESP_CODE_PRIVATE_KEY: callback = self._win._private_key_callback self._win._private_key_callback = None if callback and len(data) >= 65: callback(data[1:65]) elif code == RESP_CODE_DISABLED: callback = self._win._private_key_callback self._win._private_key_callback = None if callback: callback(None) elif code == RESP_CODE_BINARY_RESPONSE: # 0x8C format: [code(1)][reserved(1)][tag(4)][payload...] handled = False if len(data) >= 6: tag_hex = data[2:6].hex() region_cb = self._win._region_callbacks.pop(tag_hex, None) if region_cb: region_text = data[10:].decode("utf-8", "ignore").strip("\x00") region_cb(region_text) handled = True elif tag_hex in self._win._pending_binary_requests: req = self._win._pending_binary_requests.pop(tag_hex) payload = data[6:] if len(data) > 6 else b"" if req["type"] == "neighbors": result = parse_neighbors_response(data) req["callback"](result) elif req["type"] == "acl": req["callback"](data) elif req["type"] == "telemetry": req["callback"](payload) handled = True if not handled: if self._win._neighbors_callback: result = parse_neighbors_response(data) callback = self._win._neighbors_callback self._win._neighbors_callback = None callback(result) elif self._win._acl_callback: callback = self._win._acl_callback self._win._acl_callback = None callback(data) elif self._win._telemetry_callback: callback = self._win._telemetry_callback self._win._telemetry_callback = None lpp_data = data[6:] if len(data) > 6 else b"" callback(lpp_data) elif code == PUSH_CODE_TELEMETRY_RESPONSE: # 0x8B format: [code(1)][reserved(1)][pubkey_prefix(6)][lpp_data...] if self._win._telemetry_callback: callback = self._win._telemetry_callback self._win._telemetry_callback = None lpp_data = data[8:] if len(data) > 8 else b"" callback(lpp_data) elif code == RESP_CODE_CONTROL_DATA: result = parse_discover_response(data) if result and self._win._discover_callback: self._win._discover_callback(result) elif code == RESP_CODE_ADVERT_PATH: result = parse_advert_path_response(data) if result and self._win._advert_path_callback: callback = self._win._advert_path_callback self._win._advert_path_callback = None callback(result) elif code == PUSH_CODE_PATH_DISCOVERY_RESPONSE: if self._win._path_discovery_callback: self._win._path_discovery_callback(data) elif code == PUSH_CODE_LOG_DATA: self.handle_log_data(data) elif code == PUSH_CODE_TRACE_DATA: result = parse_trace_response(data) if result and self._win._trace_callback: self._win._trace_callback(result) elif code == PUSH_CODE_LOGIN_SUCCESS: log.info(f"Received LOGIN_SUCCESS response, len={len(data)}, hex={data.hex()}") # Response format: [code][perms][prefix×6][timestamp×4][...] — 14 bytes if len(data) < 8: log.warning(f"LOGIN_SUCCESS too short: {len(data)} bytes, expected >= 8") return False perms = data[1] is_admin = (perms & 1) == 1 log.info(f"Login permissions: 0x{perms:02x}, is_admin={is_admin}") response_prefix = data[2:8] # Extract repeater clock timestamp if present (bytes 8-11, LE uint32) login_timestamp = None if len(data) >= 12: login_timestamp = struct.unpack_from("= 13 else (3 if is_admin else 2) log.info(f"ACL permissions: 0x{acl_permissions:02x}") if len(data) >= 14: fw_ver_level = data[13] log.info(f"Firmware version level: {fw_ver_level}") log.info( f'Response prefix: {response_prefix.hex()}, expected: {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}' ) if self._win._login_callback: if ( self._win._login_target_prefix and response_prefix == self._win._login_target_prefix ): log.info("Prefix matches! Login successful.") self._win._login_is_admin = is_admin was_flood = self._win._login_was_flood_retry pub_key = self._win._login_pub_key cb = self._win._login_callback self._win.stop_login() if was_flood and pub_key: log.info("Flood login succeeded, clearing stale path for re-discovery") from meshy.protocol import build_reset_path self._win.send_frame(build_reset_path(pub_key)) cb(True, login_timestamp, is_admin, acl_permissions) else: log.warning( f'Prefix mismatch: got {response_prefix.hex()}, expected {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}' ) else: log.warning("LOGIN_SUCCESS received but no callback registered") elif code == PUSH_CODE_STATUS_RESPONSE: log.info(f"Status response received: {len(data)} bytes") result = parse_status_response(data) if result and self._win._status_callback: log.info( f'Status parsed: battery={result["battery_mv"]}mV uptime={result["uptime_secs"]}s' ) self._win._status_callback(result) self._win._status_callback = None elif not result: log.warning(f"Status parse failed: data too short ({len(data)} bytes)") elif not self._win._status_callback: log.warning("Status response received but no callback registered") elif code == PUSH_CODE_LOGIN_FAILED: log.info(f"Received LOGIN_FAILED response, len={len(data)}, hex={data.hex()}") # Response format: [code][unknown][6-byte-prefix] if len(data) < 8: log.warning(f"LOGIN_FAILED too short: {len(data)} bytes, expected >= 8") return False response_prefix = data[2:8] log.info( f'Response prefix: {response_prefix.hex()}, expected: {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}' ) if self._win._login_callback: if ( self._win._login_target_prefix and response_prefix == self._win._login_target_prefix ): log.info("Prefix matches! Login failed (wrong password).") cb = self._win._login_callback self._win.stop_login() cb(False, None, False, 0) else: log.warning( f'Prefix mismatch: got {response_prefix.hex()}, expected {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}' ) else: log.warning("LOGIN_FAILED received but no callback registered") elif code == PUSH_CODE_CONTACT_DELETED: if len(data) >= 33: deleted_key = data[1:33].hex() log.info("Contact deleted by firmware auto-add: %s", deleted_key[:16]) self._win._contacts_by_key.pop(deleted_key, None) self._win._contacts = [ c for c in self._win._contacts if c.public_key_hex != deleted_key ] self._win._storage.deactivate_contact(deleted_key) self._win._contacts_view.update_contacts(self._win._contacts) if self._win._map_view_inst is not None: self._win._map_view.update_contacts(self._win._contacts) self._win._refresh_chat_header() return False # Don't repeat idle callback def expect_ok(self, on_ok, on_error, timeout_ms=5000): """Register a callback for the next OK/ERROR response.""" timeout_id = GLib.timeout_add(timeout_ms, self._on_expect_timeout) self._ok_callbacks.append((on_ok, on_error, timeout_id)) def _on_expect_timeout(self): if self._ok_callbacks: _on_ok, on_error, _tid = self._ok_callbacks.pop(0) on_error({"message": "timeout", "code": 0}) return False def dict_to_contact(self, c: dict) -> Contact | None: """Convert meshcore contact dict to Contact model.""" try: path_length = c.get("out_path_len", -1) return Contact( public_key=bytes.fromhex(c["public_key"]), name=c.get("adv_name", "Unknown"), type=c.get("type", 1), flags=c.get("flags", 0), path_length=path_length, path=bytes.fromhex(c.get("out_path", "")), path_hash_size=c.get("path_hash_size", 1), latitude=c.get("adv_lat"), longitude=c.get("adv_lon"), last_seen=datetime.fromtimestamp(c["last_advert"]) if c.get("last_advert") else None, device_lastmod=datetime.fromtimestamp(c["lastmod"]) if c.get("lastmod") else None, is_active=True, ) except (ValueError, KeyError) as e: log.warning(f"Invalid contact data: {e}") return None def add_or_update_contact(self, contact_dict: dict): """Add or update a contact from a meshcore contact dict.""" contact = self.dict_to_contact(contact_dict) if contact is None: return key = contact.public_key_hex existing = self._win._contacts_by_key.get(key) if existing: contact.force_flood = existing.force_flood contact.auto_reset_path = existing.auto_reset_path contact.smart_route_selection = existing.smart_route_selection if existing.last_message_at and not contact.last_message_at: contact.last_message_at = existing.last_message_at existing.__dict__.update(contact.__dict__) contact = existing else: self._win._contacts.append(contact) self._win._contacts_by_key[key] = contact self._win._storage.save_contact(contact) if contact.path_length >= 0: pending_list = self._win._msg_ctrl.pending_flood_attributions.pop(key, None) if pending_list: for pending_trip_ms, late in pending_list: self._win._msg_ctrl.record_flood_attribution(key, pending_trip_ms, late=late) else: device_hash_size = ( getattr(getattr(self._win, "_device_info", None), "path_hash_mode", 0) + 1 ) self._win._msg_ctrl.path_history.seed_from_contact( contact.public_key_hex, contact.path.hex(), device_hash_size, contact.path_length, ) self.schedule_contacts_refresh() def schedule_contacts_refresh(self): """Debounce contact list UI updates — refresh once after a burst of changes.""" self._timeouts.start("contacts_refresh", 200, self.do_contacts_refresh) def do_contacts_refresh(self): self._win._contacts_view.update_contacts(self._win._contacts) if self._win._map_view_inst is not None: self._win._map_view.update_contacts(self._win._contacts) self._win._refresh_chat_header() return False def schedule_contacts_refetch(self): """Debounced re-fetch of contacts from device. Coalesces multiple rapid requests (e.g. SEND_CONFIRMED + PATH_UPDATED) into a single GET_CONTACTS call after a short delay. """ self._timeouts.start("contacts_refetch", 300, self.do_contacts_refetch) def do_contacts_refetch(self): self.send_get_contacts(incremental=True) def send_get_contact_by_key(self, pub_key_hex: str): """Fetch a single contact from the device by its public key.""" self._win.send_frame(build_get_contact_by_key(bytes.fromhex(pub_key_hex))) def refresh_contact(self, pub_key_hex: str): """Fetch a single contact by key, with bulk refetch as fallback.""" self.send_get_contact_by_key(pub_key_hex) self.schedule_contacts_refetch() def send_get_contacts(self, incremental: bool = False): """Send CMD_GET_CONTACTS, deferring if the firmware iterator is busy.""" if self._win._conn_ctrl.contacts_iter_busy: self._win._conn_ctrl.contacts_refetch_pending = True return False self._win._conn_ctrl.contacts_iter_busy = True since = None if incremental and self._win._storage: since = self._win._storage.get_max_device_lastmod() self._win._conn_ctrl.incremental_sync = incremental and since is not None self._win.send_frame(build_get_contacts(since=since)) return False def refresh_channels(self): self._win._channels_view.update_channels(self._win._channels) self.rebuild_key_store() def add_or_update_discovered(self, contact: Contact): """Add or update a contact in the discovered list (memory + SQLite).""" contact.received_at = datetime.now() key = contact.public_key_hex existing = self._win._discovered_by_key.get(key) if existing: existing.__dict__.update(contact.__dict__) else: self._win._discovered.append(contact) self._win._discovered_by_key[key] = contact if self._win._storage: self._win._storage.save_discovered_contact(contact) def add_or_update_channel(self, channel: Channel): """Add or update a channel, preserving local settings.""" for i, c in enumerate(self._win._channels): if c.index == channel.index: # Preserve local settings not stored on device channel.unread_count = c.unread_count channel.notification_level = c.notification_level channel.region = c.region channel.retention_days = c.retention_days self._win._channels[i] = channel self._win._storage.save_channel(channel) self.refresh_channels() return # New channel - check if we have stored settings from a previous session stored = self._win._storage.get_channel(channel.index) if stored: channel.notification_level = stored.notification_level channel.region = stored.region channel.retention_days = stored.retention_days self._win._channels.append(channel) self._win._storage.save_channel(channel) self.refresh_channels() def is_contact_chat_visible(self, contact: Contact) -> bool: """Check if the chat for this contact is currently visible.""" if not self._win._chat_view_inst: return False return ( self._win._current_view == "contacts" and self._win._chat_view_inst._contact is not None and self._win._chat_view_inst._contact.public_key_hex == contact.public_key_hex and self._win.is_active() ) def is_channel_chat_visible(self, channel_index: int) -> bool: """Check if the chat for this channel is currently visible.""" if not self._win._channels_view_inst: return False chat = self._win._channels_view_inst.get_chat_widget() return ( self._win._current_view == "channels" and chat._current_channel is not None and chat._current_channel.index == channel_index and self._win.is_active() ) def handle_incoming_message(self, msg_data: dict): """Handle an incoming direct message.""" sender_prefix = msg_data.get("pubkey_prefix", "") try: contact = self._win._storage.find_contact_by_prefix(bytes.fromhex(sender_prefix)) except ValueError: log.warning(f"Invalid sender prefix: {sender_prefix}") return if not contact: log.warning(f"Message from unknown sender: {sender_prefix}") return is_cli = msg_data.get("txt_type", 0) == 1 sender_ts = min(msg_data["sender_timestamp"], time.time()) text = msg_data["text"] room_sender_name = "" # Room messages: sender pub key prefix is in the parsed "signature" field sig = msg_data.get("signature", "") if contact.type == ContactType.ROOM and sig and not is_cli: for c in self._win._contacts: if c.public_key_hex.startswith(sig): room_sender_name = c.name break if not room_sender_name: room_sender_name = sig if contact.type in ( ContactType.CHAT, ContactType.ROOM, ) and self._win._storage.has_duplicate_message(contact.public_key_hex, sender_ts, text): log.debug(f"Dropping duplicate message from {contact.name}") return message = Message( sender_key=contact.public_key, text=text, timestamp=datetime.fromtimestamp(sender_ts), is_outgoing=False, is_cli=is_cli, status=MessageStatus.DELIVERED, room_sender_name=room_sender_name, ) # Route prefixed CLI responses to their registered callback if ( is_cli and len(text) > 3 and text[2] == "|" and text[:3] in self._win._cli_pending_prefixes ): prefix = text[:3] cb = self._win._cli_pending_prefixes.pop(prefix) cb(text[3:]) return # Route CLI responses to management dialog if open if is_cli and self._win._cli_response_callback: if self._win._cli_response_callback(contact, message): return # Repeaters and sensors don't have a chat view – silently drop # any non-CLI messages so no phantom unread badge can appear. if contact.type in (ContactType.REPEATER, ContactType.SENSOR): log.debug(f"Dropping non-CLI message from {contact.type.name} {contact.name}") return self._win._storage.save_message(contact.public_key_hex, message) contact.last_message_at = message.timestamp self.schedule_contacts_refresh() if contact.path_length < 0: self.refresh_contact(contact.public_key_hex) self._win._chat_view.on_message_received(contact, message) if not self.is_contact_chat_visible(contact): # Initialize last_read_at if not yet set so the divider has a reference if self._win._storage.get_contact_last_read_at(contact.public_key_hex) is None: self._win._storage.set_contact_last_read_at( contact.public_key_hex, message.timestamp.timestamp() - 1, ) self._win._contacts_view.mark_unread(contact.public_key_hex, True) if self._win._conn_ctrl.msg_syncing: self._win._conn_ctrl.sync_msg_count += 1 else: notif_body = ( f"{room_sender_name}: {message.text}" if room_sender_name else message.text ) self._win._send_notification( contact.name, notif_body, f"contact-{contact.public_key_hex}", ) def handle_incoming_channel_message(self, msg_data: dict): """Handle an incoming channel message.""" channel_idx = msg_data["channel_idx"] # After channel sync, reject messages for channels we haven't configured. # This guards against firmware delivering ghost messages for empty # all-zero-key slots (channel hash 0x37). if self._win._conn_ctrl.channel_sync_index == -1: known = any(ch.index == channel_idx and not ch.is_empty for ch in self._win._channels) if not known: log.debug("Dropping message for unconfigured channel %d", channel_idx) return my_name = self._win._self_info.get("name", "") sender_name = msg_data.get("sender_name", "") msg_text = msg_data.get("msg_text", msg_data.get("text", "")) raw_sender_ts = msg_data["sender_timestamp"] sender_ts = min(raw_sender_ts, time.time()) # Detect echo/repeat of our own outgoing message if my_name and sender_name == my_name: snr = msg_data.get("SNR") path_len = msg_data.get("path_len", -1) log.info( f"Echo detected: ch={channel_idx} ts={sender_ts} " f'text="{msg_text[:30]}" snr={snr} path_len={path_len}' ) path_hops = [] path_bytes_hex = msg_data.get("path_bytes", "") path_len_val = msg_data.get("path_len", 0) if path_bytes_hex and path_len_val > 0: hash_size = len(path_bytes_hex) // (2 * path_len_val) if path_len_val else 1 chars = hash_size * 2 path_hops = [ path_bytes_hex[i : i + chars].upper() for i in range(0, len(path_bytes_hex), chars) ] self._win._channels_view.on_repeat_received( channel_idx, sender_ts, msg_text, path_hops, ) return if sender_name and sender_name in self._blocked_senders: log.debug("Dropping channel message from blocked sender: %s", sender_name) return channel_msg = ChannelMessage( channel_index=channel_idx, sender_name=sender_name, text=msg_text, timestamp=datetime.fromtimestamp(sender_ts), ) # Cache SNR for LOG_DATA to pick up (use raw timestamp to match # the key used by LOG_DATA before clamping) snr = msg_data.get("SNR") if snr is not None: cache_key = (channel_idx, raw_sender_ts, msg_text) self._pending_channel_snr[cache_key] = snr # Check if this is a duplicate (same message via different repeater) is_dup = self._win._channels_view.on_path_variant_received( channel_msg, ) if is_dup: return # Don't save or notify for duplicate copies self._win._storage.save_channel_message(channel_msg) self._win._channels_view.on_channel_message_received(channel_msg) # Find the channel for notification settings channel = None for ch in self._win._channels: if ch.index == channel_msg.channel_index: channel = ch break if not self.is_channel_chat_visible(channel_msg.channel_index): # Initialize last_read_at if not yet set so the divider has a reference if self._win._storage.get_channel_last_read_at(channel_msg.channel_index) is None: self._win._storage.set_channel_last_read_at( channel_msg.channel_index, channel_msg.timestamp.timestamp() - 1, ) self._win._channels_view.mark_unread(channel_msg.channel_index, True) # Send notification based on channel settings if channel: level = channel.notification_level if level == NotificationLevel.DEFAULT: settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") level = NotificationLevel( settings.get_int("default-channel-notification-level") ) should_notify = False if level == NotificationLevel.ALL: should_notify = True elif level == NotificationLevel.MENTIONS: if my_name and my_name.lower() in channel_msg.text.lower(): should_notify = True if should_notify: if self._win._conn_ctrl.msg_syncing: self._win._conn_ctrl.sync_msg_count += 1 else: ch_name = channel.name or _("Channel {}").format(channel.index) sender = channel_msg.sender_name or _("Someone") self._win._send_notification( ch_name, _("{sender}: {text}").format(sender=sender, text=channel_msg.text), f"channel-{channel_msg.channel_index}", ) def restore_unread_counts(self): """Restore unread message counts from storage on startup.""" if not self._win._storage: return for contact in self._win._contacts: # Repeaters and sensors have no chat view – skip them. if contact.type in (ContactType.REPEATER, ContactType.SENSOR): continue count = self._win._storage.count_unread_messages(contact.public_key_hex) if count > 0: if self._win._storage.get_contact_last_read_at(contact.public_key_hex) is None: oldest = self._win._storage.get_oldest_unread_contact_timestamp( contact.public_key_hex ) ts = (oldest - 1) if oldest is not None else (datetime.now().timestamp() - 1) self._win._storage.set_contact_last_read_at(contact.public_key_hex, ts) self._win._contacts_view._unread_counts[contact.public_key_hex] = count self._win._contacts_view._unread_keys.add(contact.public_key_hex) # Refresh to show badges self._win._contacts_view.update_contacts(self._win._contacts) for channel in self._win._channels: if channel.is_empty: continue count = self._win._storage.count_unread_channel_messages(channel.index) if count > 0: # Initialize last_read_at if not set, so the unread divider # is placed correctly when the user opens the channel. if self._win._storage.get_channel_last_read_at(channel.index) is None: oldest = self._win._storage.get_oldest_unread_channel_timestamp(channel.index) ts = (oldest - 1) if oldest is not None else (datetime.now().timestamp() - 1) self._win._storage.set_channel_last_read_at(channel.index, ts) self._win._channels_view._unread_counts[channel.index] = count self._win._channels_view._unread_indices.add(channel.index) # Refresh to show badges self._win._channels_view.update_channels(self._win._channels) self._win.update_nav_unread_contacts() self._win.update_nav_unread_channels() def rebuild_key_store(self): """Rebuild the channel hash->PSK map from current channels.""" from meshy.mesh_crypto import channel_hash self._win._channel_hash_map = {} self._win._channel_psk_map = {} # hash -> psk bytes for ch in self._win._channels: if ch.is_empty: continue h = channel_hash(ch.psk_hex) self._win._channel_hash_map[h] = ch.index self._win._channel_psk_map[h] = ch.psk def handle_log_data(self, data: bytes): """Handle LOG_DATA (0x88) push - raw radio packet with path bytes.""" from meshy.mesh_crypto import ( PAYLOAD_ADVERT, PAYLOAD_GROUP_TEXT, decode_log_packet, decrypt_group_text, ) from meshy.models import RxLogEntry if len(data) < 4: return snr = struct.unpack("b", data[1:2])[0] / 4.0 rssi = struct.unpack("b", data[2:3])[0] mesh_data = data[3:] try: result = decode_log_packet(mesh_data) except Exception as e: log.warning(f"LOG_DATA decode failed: {e}") return if result: entry = RxLogEntry( timestamp=datetime.now(), snr=snr, rssi=rssi, payload_type=result["payload_type"], route_type=result.get("route_type", 0), path=result.get("path", []), path_hash_size=result.get("path_hash_size", 1), pkt_hash=result.get("pkt_hash", ""), size=len(mesh_data), transport_from=result.get("transport_from", ""), transport_to=result.get("transport_to", ""), raw_hex=mesh_data.hex(), ) self._win._rx_log.append(entry) if self._win._rx_log_callback: self._win._rx_log_callback(entry) if entry.path: key = entry.path[-1] now = time.monotonic() existing = self._win._direct_repeaters.get(key) best = max(snr, existing[0]) if existing else snr self._win._direct_repeaters[key] = (best, now, 0) self._win._repeaters_refreshed.add(key) stale = [ k for k, (_, ts, _mc) in self._win._direct_repeaters.items() if now - ts > 600 ] for k in stale: del self._win._direct_repeaters[k] if len(self._win._direct_repeaters) > 5: weakest = min( self._win._direct_repeaters, key=lambda k: self._win._direct_repeaters[k][0] ) del self._win._direct_repeaters[weakest] if not self._win._signal_icon_timer_id: self._win._signal_icon_timer_id = GLib.timeout_add_seconds( 1, self._win._update_signal_icon_tick ) if not result: return if result["payload_type"] == PAYLOAD_ADVERT and "advert" in result: advert = result["advert"] self_key = self._win._self_info.get("public_key", "") if advert["public_key"] != self_key: contact = self.dict_to_contact(advert) if contact: self.add_or_update_discovered(contact) if result["payload_type"] != PAYLOAD_GROUP_TEXT: return if not self._win._channel_hash_map: return ch_hash = result.get("channel_hash") ciphertext = result.get("ciphertext") if not ch_hash or not ciphertext: return psk = self._win._channel_psk_map.get(ch_hash) if not psk: return decrypted = decrypt_group_text(ciphertext, psk) if not decrypted: log.warning( f"LOG_DATA: decryption failed, " f"channel_hash={ch_hash}, " f"available_channels={[ch.index for ch in self._win._channels if not ch.is_empty]}" ) return sender = decrypted.get("sender", "") text = decrypted.get("message", "") raw_timestamp = decrypted.get("timestamp", 0) timestamp = min(raw_timestamp, time.time()) path_hops = result.get("path", []) if not sender or not text: return path_bytes_hex = "".join(h.lower() for h in path_hops) my_name = self._win._self_info.get("name", "") log.info( f'LOG_DATA: sender="{sender}" path={path_hops} ' f'text="{text[:30]}..." ts={timestamp}' ) # Match channel using pre-built hash map channel_idx = self._win._channel_hash_map.get(ch_hash.lower()) if channel_idx is None: log.debug(f"LOG_DATA: no channel match for hash={ch_hash}") return # Pull SNR cached by CHANNEL_MSG_RECV handler (use raw timestamp # to match the key stored by CHANNEL_MSG_RECV before clamping) cache_key = (channel_idx, raw_timestamp, text) cached_snr = self._pending_channel_snr.pop(cache_key, None) path_info = { "snr": cached_snr, "path_len": len(path_hops), "path_bytes": path_bytes_hex, } if my_name and sender == my_name: # Our own message echoed back through repeaters original_key = self._win._channels_view.on_repeat_received( channel_idx, timestamp, text, path_hops, ) store_ts = original_key[1] if original_key else timestamp if self._win._storage: self._win._storage.save_channel_message_path( channel_idx, store_ts, text, path_info, ) else: # Someone else's message - record path variant channel_msg = ChannelMessage( channel_index=channel_idx, sender_name=sender, text=text, timestamp=datetime.fromtimestamp(timestamp), ) if self._win._storage: self._win._storage.save_channel_message_path( channel_idx, timestamp, text, path_info, ) self._win._channels_view.add_path_info( channel_msg, path_info, ) meshy/src/host_integration.py000066400000000000000000000013271523412555500167360ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Helpers for invoking host commands across Flatpak and native runtimes.""" from __future__ import annotations import shutil import subprocess from collections.abc import Sequence from pathlib import Path def is_flatpak() -> bool: return Path("/.flatpak-info").exists() def host_command(args: Sequence[str]) -> list[str]: if is_flatpak() and shutil.which("flatpak-spawn"): return ["flatpak-spawn", "--host", *args] return list(args) def run_host_command(args: Sequence[str], **kwargs) -> subprocess.CompletedProcess: return subprocess.run(host_command(args), **kwargs) meshy/src/location.py000066400000000000000000000025661523412555500151740ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Platform-agnostic location service.""" from meshy.platforms import is_linux, is_macos class LocationService: """Platform-agnostic location service. Provides GPS location access across different platforms: - macOS: CoreLocation framework - Linux: GeoClue2 D-Bus service """ @staticmethod def get_location(callback): """Request current GPS location. Args: callback: Function called with (latitude, longitude, error). On success: (float, float, None) On failure: (None, None, error_string) Example: def on_location(lat, lon, error): if error: print(f"Error: {error}") else: print(f"Location: {lat}, {lon}") LocationService.get_location(on_location) """ if is_macos(): from meshy.platforms.macos.location_corelocation import get_location_macos get_location_macos(callback) elif is_linux(): from meshy.platforms.linux.location_geoclue import get_location_linux get_location_linux(callback) else: callback(None, None, "Location services not available on this platform") meshy/src/main.py000066400000000000000000000005421523412555500143000ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import sys import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from meshy.application import MeshyApplication def main(version): app = MeshyApplication(version=version) return app.run(sys.argv) meshy/src/mesh_crypto.py000066400000000000000000000144721523412555500157170ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Lightweight MeshCore packet decoding and channel crypto. Replaces meshcoredecoder for the subset of features Meshy needs: - Channel hash calculation (SHA-256) - GroupText payload decryption (HMAC-SHA256 + AES-128-ECB) - Minimal packet header/path parsing for LOG_DATA frames """ import hashlib import hmac import struct try: from Crypto.Cipher import AES except ImportError: AES = None # Payload type constants (from header bits 2-5) PAYLOAD_ADVERT = 0x04 PAYLOAD_GROUP_TEXT = 0x05 def channel_hash(psk_hex: str) -> str: """Calculate the 1-byte channel hash from a PSK hex string. This is SHA-256(psk_bytes)[0] as a 2-char lowercase hex string. """ h = hashlib.sha256(bytes.fromhex(psk_hex)).digest() return f"{h[0]:02x}" def decrypt_group_text(ciphertext: bytes, psk: bytes) -> dict | None: """Decrypt a GroupText payload using the channel PSK. Args: ciphertext: The encrypted payload (after channel_hash byte). First 2 bytes are HMAC-SHA256 MAC, rest is AES-128-ECB ciphertext. psk: The 16-byte channel pre-shared key. Returns: Dict with 'sender', 'message', 'timestamp', 'flags' or None on failure. """ if len(ciphertext) < 3: return None mac = ciphertext[:2] encrypted = ciphertext[2:] # Verify HMAC-SHA256: key is PSK padded to 32 bytes with zeros key32 = psk + b"\x00" * (32 - len(psk)) if len(psk) < 32 else psk[:32] expected_mac = hmac.new(key32, encrypted, hashlib.sha256).digest() if mac[0] != expected_mac[0] or mac[1] != expected_mac[1]: return None # Decrypt with AES-128-ECB if AES is None: return None key16 = psk[:16] if len(psk) >= 16 else psk + b"\x00" * (16 - len(psk)) cipher = AES.new(key16, AES.MODE_ECB) plaintext = cipher.decrypt(encrypted) # Parse plaintext: timestamp(4 LE) + flags(1) + "sender: message" if len(plaintext) < 6: return None timestamp = struct.unpack(" dict | None: """Parse a raw mesh packet from LOG_DATA, extract GroupText if present. Parses the MeshCore packet header, skips path data, and extracts the payload. Only GroupText payloads are fully decoded. Args: raw: Raw packet bytes (after LOG_DATA framing). Returns: Dict with 'payload_type', 'path' (list of hex hop strings), 'channel_hash', and 'decrypted' (if GroupText), or None. """ if len(raw) < 2: return None header = raw[0] route_type = header & 0x03 payload_type = (header >> 2) & 0x0F offset = 1 # Parse transport codes for TC_FLOOD (0) and TC_DIRECT (3) transport_from = "" transport_to = "" if route_type == 0 or route_type == 3: if len(raw) < offset + 4: return None transport_from = raw[offset : offset + 2].hex() transport_to = raw[offset + 2 : offset + 4].hex() offset += 4 # Parse path length byte if len(raw) < offset + 1: return None path_len_byte = raw[offset] offset += 1 # Decode path: bits 7:6 = hash_size selector, bits 5:0 = hop count hash_size = (path_len_byte >> 6) + 1 hop_count = path_len_byte & 63 path_byte_length = hop_count * hash_size # Legacy fallback: reserved hash_size (4) or path extends past packet if hash_size == 4 or offset + path_byte_length > len(raw): path_byte_length = min(path_len_byte, len(raw) - offset, 64) hop_count = path_byte_length hash_size = 1 # Extract path hops as hex strings path = [] for i in range(hop_count): hop_start = offset + i * hash_size hop_bytes = raw[hop_start : hop_start + hash_size] path.append(hop_bytes.hex()) offset += path_byte_length # Extract payload payload = raw[offset:] pkt_hash = hashlib.sha256(bytes([payload_type]) + payload).hexdigest()[:16] result = { "payload_type": payload_type, "route_type": route_type, "path": path, "path_hash_size": hash_size, "pkt_hash": pkt_hash, "transport_from": transport_from, "transport_to": transport_to, } if payload_type == PAYLOAD_ADVERT: advert = parse_advert_payload(payload) if advert: result["advert"] = advert # Decode GroupText payload: channel_hash(1) + mac(2) + ciphertext elif payload_type == PAYLOAD_GROUP_TEXT and len(payload) >= 3: result["channel_hash"] = f"{payload[0]:02x}" result["ciphertext"] = payload[1:] # mac + encrypted data return result def parse_advert_payload(payload: bytes) -> dict | None: """Parse an ADVERT payload from a LOG_DATA frame. Format: pubkey(32) + timestamp(4) + signature(64) + flags(1) + [lat(4) + lon(4) if flag 0x10] + [name(null-terminated) if flag 0x80] """ if len(payload) < 101: # 32 + 4 + 64 + 1 return None pub_key = payload[:32].hex() timestamp = struct.unpack_from("= offset + 8: lat = struct.unpack_from(" offset: end = payload.index(0, offset) if 0 in payload[offset:] else len(payload) name = payload[offset:end].decode("utf-8", "ignore") return { "public_key": pub_key, "type": node_type, "adv_name": name, "last_advert": timestamp, "adv_lat": lat, "adv_lon": lon, } meshy/src/meshcore_packets.py000066400000000000000000000061151523412555500166750ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later from enum import Enum class AnonReqType(Enum): REGIONS = 0x01 OWNER = 0x02 BASIC = 0x03 # just remote clock class BinaryReqType(Enum): STATUS = 0x01 KEEP_ALIVE = 0x02 TELEMETRY = 0x03 MMA = 0x04 ACL = 0x05 NEIGHBOURS = 0x06 class ControlType(Enum): NODE_DISCOVER_REQ = 0x80 NODE_DISCOVER_RESP = 0x90 class CommandType(Enum): APP_START = 1 SEND_TXT_MSG = 2 SEND_CHANNEL_TXT_MSG = 3 GET_CONTACTS = 4 # with optional 'since' (for efficient sync) GET_DEVICE_TIME = 5 SET_DEVICE_TIME = 6 SEND_SELF_ADVERT = 7 SET_ADVERT_NAME = 8 ADD_UPDATE_CONTACT = 9 SYNC_NEXT_MESSAGE = 10 SET_RADIO_PARAMS = 11 SET_RADIO_TX_POWER = 12 RESET_PATH = 13 SET_ADVERT_LATLON = 14 REMOVE_CONTACT = 15 SHARE_CONTACT = 16 EXPORT_CONTACT = 17 IMPORT_CONTACT = 18 REBOOT = 19 GET_BATT_AND_STORAGE = 20 # was CMD_GET_BATTERY_VOLTAGE SET_TUNING_PARAMS = 21 DEVICE_QUERY = 22 EXPORT_PRIVATE_KEY = 23 IMPORT_PRIVATE_KEY = 24 SEND_RAW_DATA = 25 SEND_LOGIN = 26 SEND_STATUS_REQ = 27 HAS_CONNECTION = 28 LOGOUT = 29 # 'Disconnect' GET_CONTACT_BY_KEY = 30 GET_CHANNEL = 31 SET_CHANNEL = 32 SIGN_START = 33 SIGN_DATA = 34 SIGN_FINISH = 35 SEND_TRACE_PATH = 36 SET_DEVICE_PIN = 37 SET_OTHER_PARAMS = 38 SEND_TELEMETRY_REQ = 39 # can deprecate this GET_CUSTOM_VARS = 40 SET_CUSTOM_VAR = 41 GET_ADVERT_PATH = 42 GET_TUNING_PARAMS = 43 # NOTE: CMD range 44..49 parked, potentially for WiFi operations BINARY_REQ = 50 FACTORY_RESET = 51 PATH_DISCOVERY = 52 SET_FLOOD_SCOPE = 54 SEND_CONTROL_DATA = 55 SEND_ANON_REQ = 57 SET_AUTOADD_CONFIG = 58 GET_AUTOADD_CONFIG = 59 GET_ALLOWED_REPEAT_FREQ = 60 SET_PATH_HASH_MODE = 61 SET_DEFAULT_FLOOD_SCOPE = 63 GET_DEFAULT_FLOOD_SCOPE = 64 # Packet prefixes for the protocol class PacketType(Enum): OK = 0 ERROR = 1 CONTACT_START = 2 CONTACT = 3 CONTACT_END = 4 SELF_INFO = 5 MSG_SENT = 6 CONTACT_MSG_RECV = 7 CHANNEL_MSG_RECV = 8 CURRENT_TIME = 9 NO_MORE_MSGS = 10 CONTACT_URI = 11 BATTERY = 12 DEVICE_INFO = 13 PRIVATE_KEY = 14 DISABLED = 15 CONTACT_MSG_RECV_V3 = 16 CHANNEL_MSG_RECV_V3 = 17 CHANNEL_INFO = 18 SIGN_START = 19 SIGNATURE = 20 CUSTOM_VARS = 21 ADVERT_PATH = 22 TUNING_PARAMS = 23 STATS = 24 AUTOADD_CONFIG = 25 ALLOWED_REPEAT_FREQ = 26 CHANNEL_DATA_RECV = 27 DEFAULT_FLOOD_SCOPE = 28 # Push notifications ADVERTISEMENT = 0x80 PATH_UPDATE = 0x81 ACK = 0x82 MESSAGES_WAITING = 0x83 RAW_DATA = 0x84 LOGIN_SUCCESS = 0x85 LOGIN_FAILED = 0x86 STATUS_RESPONSE = 0x87 LOG_DATA = 0x88 TRACE_DATA = 0x89 PUSH_CODE_NEW_ADVERT = 0x8A TELEMETRY_RESPONSE = 0x8B BINARY_RESPONSE = 0x8C PATH_DISCOVERY_RESPONSE = 0x8D CONTROL_DATA = 0x8E CONTACT_DELETED = 0x8F meshy/src/meshy.in000066400000000000000000000026361523412555500144650ustar00rootroot00000000000000#!@PYTHON@ # Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import os import sys import signal import locale import gettext VERSION = '@VERSION@' pkgdatadir = os.environ.get('MESHY_PKGDATADIR', '@pkgdatadir@') appdir = os.environ.get('APPDIR') if appdir: pkgdatadir = os.path.join(appdir, 'usr', 'share', 'meshy') localedir = os.path.join(appdir, 'usr', 'share', 'locale') else: localedir = '@localedir@' QR_SCANNER_ENABLED = '@QR_SCANNER_ENABLED@' == 'true' SHORTCUTS_DIALOG_ENABLED = '@SHORTCUTS_DIALOG_ENABLED@' == 'true' signal.signal(signal.SIGINT, signal.SIG_DFL) # Set up gettext for translations # locale.bindtextdomain is not available on macOS, so use try/except try: locale.bindtextdomain('meshy', localedir) locale.textdomain('meshy') except AttributeError: # macOS doesn't have bindtextdomain in locale module # gettext.install will handle the translation setup pass gettext.bindtextdomain('meshy', localedir) gettext.install('meshy', localedir) if __name__ == '__main__': import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') import meshy as _meshy_pkg _meshy_pkg.pkgdatadir = pkgdatadir _meshy_pkg.QR_SCANNER_ENABLED = QR_SCANNER_ENABLED _meshy_pkg.SHORTCUTS_DIALOG_ENABLED = SHORTCUTS_DIALOG_ENABLED from meshy.main import main sys.exit(main(VERSION)) meshy/src/meson.build000066400000000000000000000046641523412555500151550ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later meshy_sources = [ '__init__.py', 'main.py', 'application.py', 'window.py', 'protocol.py', 'ble.py', 'location.py', 'camera.py', 'notification.py', 'models.py', 'storage.py', 'meshcore_packets.py', 'mesh_crypto.py', 'transport_base.py', 'usb_serial.py', 'tcp.py', 'utils.py', 'backup.py', 'frame_handler.py', 'message_controller.py', 'path_history.py', 'connection_controller.py', 'host_integration.py', 'theme_manager.py', 'theme_chooser_dialog.py', 'background_portal.py', 'smaz.py', 'repeater_helpers.py', ] if get_option('qr_scanner') meshy_sources += ['qr_scanner.py'] endif views_sources = [ 'views/__init__.py', 'views/chat_helpers.py', 'views/chat_mixin.py', 'views/chat_view.py', 'views/contact_detail_dialog.py', 'views/contacts_view.py', 'views/channels_view.py', 'views/settings_view.py', 'views/device_view.py', 'views/connection_view.py', 'views/repeater_view.py', 'views/sensor_view.py', 'views/map_view.py', ] # Platform abstraction layer platforms_sources = [ 'platforms/__init__.py', 'platforms/ble_base.py', 'platforms/notification_base.py', ] platforms_linux_sources = [ 'platforms/linux/__init__.py', 'platforms/linux/ble_bluez.py', 'platforms/linux/location_geoclue.py', 'platforms/linux/notification_gio.py', ] platforms_macos_sources = [ 'platforms/macos/__init__.py', 'platforms/macos/ble_corebluetooth.py', 'platforms/macos/app_activation.py', 'platforms/macos/location_corelocation.py', 'platforms/macos/camera_avfoundation.py', 'platforms/macos/login_items.py', 'platforms/macos/notification_native.py', ] py_installation.install_sources(meshy_sources, subdir: 'meshy') py_installation.install_sources(views_sources, subdir: 'meshy' / 'views') py_installation.install_sources(platforms_sources, subdir: 'meshy' / 'platforms') if host_machine.system() == 'darwin' py_installation.install_sources(platforms_macos_sources, subdir: 'meshy' / 'platforms' / 'macos') else # Linux and other Unix-like systems (BSD) share the GIO/GeoClue backends py_installation.install_sources(platforms_linux_sources, subdir: 'meshy' / 'platforms' / 'linux') endif configure_file( input: 'meshy.in', output: 'meshy', configuration: conf, install: true, install_dir: get_option('bindir'), install_mode: 'rwxr-xr-x', ) meshy/src/message_controller.py000066400000000000000000000466371523412555500172620ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Message sending, retry, and delivery tracking extracted from MeshyWindow.""" from __future__ import annotations import logging import time import uuid from datetime import datetime from gi.repository import GLib from meshy.models import ( ChannelMessage, Contact, Message, MessageStatus, ) from meshy.path_history import PathHistoryService from meshy.protocol import ( STATS_TYPE_RADIO, build_reset_path, build_send_channel_text_msg, build_send_text_msg, build_set_flood_scope, ) from meshy.utils import format_trip_time log = logging.getLogger(__name__) PATH_RETRIES = 4 FLOOD_RETRIES = 3 MAX_IN_FLIGHT = 6 class MessageController: """Owns message send/retry state and delivery tracking.""" def __init__(self, win): self._win = win self._path_history: PathHistoryService | None = None self.pending_acks: dict[str, str] = {} self._acks_by_msg: dict[str, set[str]] = {} self.retry_context: dict[str, dict] = {} self.sent_msg_queue: list[str] = [] self.pending_timeouts: dict[str, int] = {} self.grace_acks: dict[str, str] = {} self._grace_by_msg: dict[str, set[str]] = {} self.grace_timeouts: dict[str, int] = {} self.pending_flood_attributions: dict[str, list[tuple[int, bool]]] = {} self.grace_contexts: dict[str, dict] = {} self.last_rx_time: float = 0.0 self.prev_total_air_secs: int = 0 self.last_airtime_bump: float = 0.0 @property def path_history(self) -> PathHistoryService: if self._path_history is None: self._path_history = PathHistoryService(self._win._storage) return self._path_history @property def _device_hash_size(self) -> int: return getattr(getattr(self._win, "_device_info", None), "path_hash_mode", 0) + 1 @staticmethod def _determine_strategy(contact: Contact, route_override: str | None) -> str: if route_override == "direct": return "override_direct" if route_override == "flood": return "override_flood" if contact.force_flood: return "force_flood" if contact.is_smart_routing: return "smart" return "traditional" def cleanup_on_disconnect(self): self._path_history = None self.pending_acks.clear() self._acks_by_msg.clear() self.retry_context.clear() for tid in self.pending_timeouts.values(): GLib.source_remove(tid) self.pending_timeouts.clear() for tid in self.grace_timeouts.values(): GLib.source_remove(tid) self.grace_timeouts.clear() self.grace_acks.clear() self._grace_by_msg.clear() self.grace_contexts.clear() self.pending_flood_attributions.clear() self.sent_msg_queue.clear() def send_message(self, contact: Contact, text: str, route_override: str | None = None): msg_id = str(uuid.uuid4()) timestamp = int(datetime.now().timestamp()) message = Message( sender_key=contact.public_key, text=text, timestamp=datetime.fromtimestamp(timestamp), is_outgoing=True, status=MessageStatus.PENDING, message_id=msg_id, ) self._win._chat_view.on_message_sent(message) has_path = contact.path_length >= 0 and contact.path_length != 0xFF strategy = self._determine_strategy(contact, route_override) self.retry_context[msg_id] = { "contact": contact, "text": text, "timestamp": timestamp, "attempt": 0, "started_with_path": has_path, "strategy": strategy, "route_override": route_override, } def _after_render(): self._win._storage.save_message(contact.public_key_hex, message) contact.last_message_at = message.timestamp self._win._frame_handler.schedule_contacts_refresh() self._send_message_attempt(msg_id) return False GLib.idle_add(_after_render) def resend_message(self, contact: Contact, message: Message): msg_id = message.message_id timestamp = int(message.timestamp.timestamp()) if msg_id in self.pending_timeouts: GLib.source_remove(self.pending_timeouts.pop(msg_id)) for k in self._acks_by_msg.pop(msg_id, set()): self.pending_acks.pop(k, None) for k in self._grace_by_msg.pop(msg_id, set()): self.grace_acks.pop(k, None) if msg_id in self.grace_timeouts: GLib.source_remove(self.grace_timeouts.pop(msg_id)) if msg_id in self.sent_msg_queue: self.sent_msg_queue.remove(msg_id) self._win.set_message_status(msg_id, MessageStatus.PENDING) has_path = contact.path_length >= 0 and contact.path_length != 0xFF strategy = self._determine_strategy(contact, None) self.retry_context[msg_id] = { "contact": contact, "text": message.text, "timestamp": timestamp, "attempt": 0, "started_with_path": has_path, "strategy": strategy, } self._send_message_attempt(msg_id) def _send_channel_frame(self, channel_index: int, frame: bytes): channel = next((ch for ch in self._win._channels if ch.index == channel_index), None) region = channel.region if channel else "" if region: self._win.send_frame(build_set_flood_scope(region)) self._win.send_frame(frame) GLib.timeout_add(300, self._win._reset_session_scope) else: self._win.send_frame(frame) def send_channel_message(self, channel_index: int, text: str): timestamp = int(datetime.now().timestamp()) self._send_channel_frame( channel_index, build_send_channel_text_msg(channel_index, text, timestamp=timestamp) ) name = self._win._self_info.get("name", _("Me")) msg = ChannelMessage( channel_index=channel_index, sender_name=name, text=text, timestamp=datetime.fromtimestamp(timestamp), is_outgoing=True, ) self._win._storage.save_channel_message(msg) self._win._channels_view.on_channel_message_sent(msg, timestamp) def resend_channel_message(self, channel_index: int, text: str) -> int: new_ts = int(datetime.now().timestamp()) self._send_channel_frame( channel_index, build_send_channel_text_msg(channel_index, text, timestamp=new_ts) ) return new_ts def delete_message(self, message_id: str): self._win._storage.delete_message(message_id) def delete_channel_message(self, channel_index: int, timestamp: float, text: str): self._win._storage.delete_channel_message(channel_index, timestamp, text) def max_retries_for(self, ctx: dict) -> int: strategy = ctx.get("strategy", "traditional") if strategy == "override_direct": return 1 if strategy in ("override_flood", "force_flood"): return FLOOD_RETRIES if strategy == "smart": contact = ctx.get("contact") has_history = contact and self.path_history.has_paths(contact.public_key_hex) return PATH_RETRIES + 1 if has_history else FLOOD_RETRIES if ctx.get("started_with_path"): return PATH_RETRIES + 1 return FLOOD_RETRIES def send_detail(self, msg_id: str, is_flood: bool) -> str: ctx = self.retry_context.get(msg_id) contact = ctx.get("contact") if ctx else None if is_flood: route = _("flood") elif contact and contact.path_length == 0: route = _("direct") else: route = _("path") if not ctx: return _("– {route}").format(route=route) attempt = ctx["attempt"] + 1 max_retries = self.max_retries_for(ctx) if attempt <= 1: return _("– {route}").format(route=route) return _("({attempt}/{total}) – {route}").format( attempt=attempt, total=max_retries, route=route ) def _airtime_busy_fraction(self) -> float: if self.last_airtime_bump == 0.0: return 0.0 ms = (time.monotonic() - self.last_airtime_bump) * 1000 if ms >= 8000: return 0.0 return 1.0 - ms / 8000 def _radio_quiet_delay_ms(self) -> int: elapsed = time.monotonic() - self.last_rx_time if elapsed > 3.0: return 0 radio = self._win._device_stats.get(STATS_TYPE_RADIO) if not radio: return 1000 noise = radio.get("noise_floor", -118) noise_severity = max(0.0, min(1.0, (noise + 118) / 30.0)) snr = radio.get("last_snr", 12.0) snr_severity = max(0.0, min(1.0, (12.0 - snr) / 14.0)) air_busy = self._airtime_busy_fraction() severity = max(noise_severity, snr_severity) * 0.82 + air_busy * 0.18 return int(500 + severity * 7500) def _in_flight_count(self) -> int: return len(self.sent_msg_queue) + len(self._acks_by_msg) def _send_message_attempt(self, msg_id: str): ctx = self.retry_context.get(msg_id) if not ctx: return if self._in_flight_count() >= MAX_IN_FLIGHT: GLib.timeout_add(500, lambda: self._send_message_attempt(msg_id) or False) return if ctx["strategy"] == "smart" and ctx["attempt"] == 0 and not ctx.get("_refreshed"): ctx["_refreshed"] = True self._win._frame_handler.do_contacts_refetch() GLib.timeout_add(500, self._do_send_message, msg_id) return delay = self._radio_quiet_delay_ms() if delay > 0: self._win._chat_view.update_message_status( msg_id, MessageStatus.PENDING, _("waiting {} (radio busy)").format(format_trip_time(delay)), ) GLib.timeout_add(delay, self._do_send_message, msg_id) else: self._do_send_message(msg_id) def _reset_to_flood(self, contact: Contact): if contact.path_length >= 0: self._win.send_frame(build_reset_path(contact.public_key)) contact.path_length = -1 contact.path = b"" def _restore_original_path(self, ctx: dict): orig = ctx.get("original_path") if not orig: return contact = ctx["contact"] pl = orig["path_length"] if pl < 0: self._reset_to_flood(contact) else: hash_mode = orig["path_hash_size"] - 1 path_len_byte = (hash_mode << 6) | (pl & 0x3F) self._win.set_contact_path(contact, path_len_byte, orig["path"]) def _apply_traditional_route(self, ctx: dict, contact: Contact): if ( contact.auto_reset_path and ctx.get("started_with_path") and ctx["attempt"] >= PATH_RETRIES ): self._reset_to_flood(contact) def _apply_smart_route(self, ctx: dict, contact: Contact): has_history = self.path_history.has_paths(contact.public_key_hex) if not has_history or ctx["attempt"] >= PATH_RETRIES: self._reset_to_flood(contact) return tried = ctx.get("paths_tried", []) best = self.path_history.select_path( contact.public_key_hex, exclude_paths=tried, ) if best is None and tried: ctx["paths_tried"] = [] best = self.path_history.select_path(contact.public_key_hex) if best: path_bytes_raw = bytes.fromhex(best.path_bytes) hash_mode = best.path_hash_size - 1 path_len_byte = (hash_mode << 6) | (best.hop_count & 0x3F) if contact.path.hex() != best.path_bytes or contact.path_length != best.hop_count: self._win.set_contact_path(contact, path_len_byte, path_bytes_raw) else: self._reset_to_flood(contact) def _do_send_message(self, msg_id: str) -> bool: ctx = self.retry_context.get(msg_id) if not ctx: return False contact_key = ctx["contact"].public_key_hex contact = self._win._contacts_by_key.get(contact_key, ctx["contact"]) ctx["contact"] = contact strategy = ctx["strategy"] if strategy in ("override_direct", "override_flood", "smart", "traditional"): if "original_path" not in ctx: ctx["original_path"] = { "path_length": contact.path_length, "path": contact.path, "path_hash_size": contact.path_hash_size, } if strategy == "override_direct": self._win.set_contact_path(contact, 0, b"") elif strategy == "override_flood" or strategy == "force_flood": self._reset_to_flood(contact) elif strategy == "smart": try: self._apply_smart_route(ctx, contact) except Exception: log.exception("Smart route selection failed, sending with current path") else: self._apply_traditional_route(ctx, contact) ctx["path_used"] = { "path_length": contact.path_length, "path_bytes": contact.path.hex(), "path_hash_size": contact.path_hash_size, "hop_count": max(contact.path_length, 0), } ctx.setdefault("attempt_paths", {})[ctx["attempt"]] = ctx["path_used"] ctx.setdefault("paths_tried", []).append(contact.path.hex()) self.sent_msg_queue.append(msg_id) frame = build_send_text_msg( contact.public_key, ctx["text"], attempt=ctx["attempt"], timestamp=ctx["timestamp"], ) self._win.send_frame(frame) ctx["last_send_mono"] = time.monotonic() GLib.timeout_add(10000, self._on_sent_pending_timeout, msg_id) return False def register_ack_key(self, msg_id: str, ack_key: str): ctx = self.retry_context.get(msg_id) if ctx: ctx.setdefault("ack_paths", {})[ack_key] = ctx.get("attempt_paths", {}).get( ctx["attempt"] ) ctx.setdefault("ack_send_times", {})[ack_key] = ctx.get("last_send_mono") def _on_sent_pending_timeout(self, msg_id: str) -> bool: if msg_id in self.sent_msg_queue: log.warning(f"Message {msg_id[:8]} stuck in sent queue, forcing retry") self.sent_msg_queue.remove(msg_id) self._on_message_timeout(msg_id, "") return False def _on_message_timeout(self, msg_id: str, ack_key: str) -> bool: if msg_id in self.pending_timeouts: self.pending_timeouts.pop(msg_id) ctx = self.retry_context.get(msg_id) if ctx: self.record_path_result(msg_id, success=False, ack_key=ack_key) max_retries = self.max_retries_for(ctx) if ctx else 0 if ctx and ctx["attempt"] < max_retries - 1: ctx["attempt"] += 1 backoff_ms = 1000 * (1 << (ctx["attempt"] - 1)) detail = "({current}/{total})".format(current=ctx["attempt"] + 1, total=max_retries) self._win.set_message_status(msg_id, MessageStatus.PENDING, detail) GLib.timeout_add(backoff_ms, lambda: self._send_message_attempt(msg_id) or False) elif ctx: attempt = ctx["attempt"] + 1 contact = ctx.get("contact") started_with_path = ctx.get("started_with_path") strategy = ctx.get("strategy", "traditional") self._restore_original_path(ctx) self.retry_context.pop(msg_id, None) self._win.set_message_status(msg_id, MessageStatus.FAILED, f"{attempt}/{max_retries}") # Move all ack_keys for this message to grace_acks all_ack_keys = list(self._acks_by_msg.pop(msg_id, set())) if ack_key and ack_key != "00000000" and ack_key not in all_ack_keys: all_ack_keys.append(ack_key) for k in all_ack_keys: self.pending_acks.pop(k, None) self.grace_acks[k] = msg_id self._grace_by_msg.setdefault(msg_id, set()).add(k) if all_ack_keys: grace_id = GLib.timeout_add(30000, self._on_grace_expired, msg_id) self.grace_timeouts[msg_id] = grace_id if contact: self.grace_contexts[msg_id] = { "contact_key": contact.public_key_hex, "ack_paths": ctx.get("ack_paths", {}), "ack_send_times": ctx.get("ack_send_times", {}), "path_used": ctx.get("path_used"), } if strategy == "traditional" and contact: contact_key = contact.public_key_hex current = self._win._contacts_by_key.get(contact_key) if current and current.auto_reset_path and started_with_path: self._reset_to_flood(current) self._win._contacts_view.update_contacts(self._win._contacts) self._win._refresh_chat_header() if contact: key = contact.public_key_hex GLib.timeout_add( 500, lambda: self._win._frame_handler.refresh_contact(key) or False ) return False def record_path_result( self, msg_id: str, success: bool, trip_ms: int = 0, ack_key: str | None = None ): ctx = self.retry_context.get(msg_id) if not ctx: return path_used = None if ack_key: path_used = ctx.get("ack_paths", {}).get(ack_key) if not path_used: path_used = ctx.get("path_used") if not path_used or path_used["path_length"] < 0: # Flood was used - if successful, wait for firmware to set path, # then record via refetch callback return self.path_history.record_result( ctx["contact"].public_key_hex, path_used["path_bytes"], self._device_hash_size, path_used["hop_count"], success=success, trip_ms=trip_ms if success else None, ) GRACE_WEIGHT_INCREMENT = 0.2 def record_flood_attribution( self, contact_key: str, trip_ms: int = 0, late: bool = False, ): """Credit the contact's firmware-discovered path after a flood success.""" fresh = self._win._contacts_by_key.get(contact_key) if not fresh or fresh.path_length < 0: return log.debug( "Flood attribution%s: crediting path %s for %s (trip=%dms)", " (late)" if late else "", fresh.path.hex(), contact_key[:8], trip_ms, ) self.path_history.record_result( contact_key, fresh.path.hex(), self._device_hash_size, fresh.path_length, success=True, trip_ms=trip_ms, weight_increment=self.GRACE_WEIGHT_INCREMENT if late else None, ) def _on_grace_expired(self, msg_id: str) -> bool: for k in self._grace_by_msg.pop(msg_id, set()): self.grace_acks.pop(k, None) self.grace_timeouts.pop(msg_id, None) self.grace_contexts.pop(msg_id, None) return False meshy/src/models.py000066400000000000000000000233771523412555500146520ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Data models for MeshCore entities.""" import gettext import hashlib from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum, auto from typing import ClassVar def _(msg): return gettext.dgettext("meshy", msg) def ngettext(singular, plural, n): return gettext.dngettext("meshy", singular, plural, n) def battery_percent_from_mv(mv: int) -> int: """Estimate LiPo battery percentage from voltage. Piecewise linear approximation with breakpoints at 4.2V/100%, 4.0V/90%, 3.6V/10%, 3.5V/5%, 3.0V/0%. """ _TABLE = [(4200, 100), (4000, 90), (3600, 10), (3500, 5), (3000, 0)] if mv >= _TABLE[0][0]: return 100 if mv <= _TABLE[-1][0]: return 0 for i in range(len(_TABLE) - 1): if mv >= _TABLE[i + 1][0]: v_hi, p_hi = _TABLE[i] v_lo, p_lo = _TABLE[i + 1] return round(p_lo + (p_hi - p_lo) * (mv - v_lo) / (v_hi - v_lo)) return 0 def battery_icon_name(percent: int) -> str: if percent >= 90: return "battery-level-100-symbolic" if percent >= 80: return "battery-level-80-symbolic" if percent >= 70: return "battery-level-70-symbolic" if percent >= 60: return "battery-level-60-symbolic" if percent >= 40: return "battery-level-50-symbolic" if percent >= 30: return "battery-level-30-symbolic" if percent >= 20: return "battery-level-20-symbolic" return "battery-level-low-symbolic" SNR_THRESHOLDS = { 7: (4.0, -2.0, -4.0, -6.0), 8: (4.0, -4.0, -6.0, -8.0), 9: (4.0, -6.0, -8.0, -10.0), 10: (4.0, -8.0, -10.0, -13.0), 11: (4.0, -10.0, -12.5, -15.0), 12: (4.0, -12.5, -15.0, -18.0), } SIGNAL_ICONS = ( "strength-bars-1-symbolic", "strength-bars-2-symbolic", "strength-bars-3-symbolic", "strength-bars-4-symbolic", "strength-bars-5-symbolic", ) SIGNAL_LABELS = ( "Excellent", "Good", "Fair", "Poor", "Very poor", ) def snr_to_icon(snr: float, sf: int = 12) -> str: thresholds = SNR_THRESHOLDS.get(sf, SNR_THRESHOLDS[12]) for i, t in enumerate(thresholds): if snr >= t: return SIGNAL_ICONS[i] return SIGNAL_ICONS[4] def snr_to_label(snr: float, sf: int = 12) -> str: thresholds = SNR_THRESHOLDS.get(sf, SNR_THRESHOLDS[12]) for i, t in enumerate(thresholds): if snr >= t: return SIGNAL_LABELS[i] return SIGNAL_LABELS[4] class ContactType(IntEnum): CHAT = 1 REPEATER = 2 ROOM = 3 SENSOR = 4 class MessageStatus(IntEnum): PENDING = auto() SENT = auto() DELIVERED = auto() FAILED = auto() CONTACT_FLAG_FAVORITE = 0x01 CONTACT_FLAG_TELE_BASE = 0x02 CONTACT_FLAG_TELE_LOC = 0x04 CONTACT_FLAG_TELE_ENV = 0x08 @dataclass class Contact: _global_smart_default: ClassVar[bool] = False public_key: bytes name: str type: int = ContactType.CHAT flags: int = 0 path_length: int = -1 # -1 = flood path: bytes = b"" path_hash_size: int = 1 # 1, 2, or 3 bytes per hop force_flood: bool = False auto_reset_path: bool = True smart_route_selection: bool | None = None latitude: float | None = None longitude: float | None = None last_seen: datetime | None = None received_at: datetime | None = None device_lastmod: datetime | None = None last_message_at: datetime | None = None is_active: bool = True @property def public_key_hex(self) -> str: return self.public_key.hex() @property def type_label(self) -> str: labels = { ContactType.CHAT: "Chat", ContactType.REPEATER: "Repeater", ContactType.ROOM: "Room", ContactType.SENSOR: "Sensor", } return labels.get(self.type, "Unknown") @property def is_smart_routing(self) -> bool: if self.smart_route_selection is None: return Contact._global_smart_default return self.smart_route_selection @property def path_label(self) -> str: if self.is_smart_routing: return _("Smart") if self.force_flood: return _("Flood") if self.path_length < 0: return _("Flood") if self.path_length == 0: return _("Direct") return ngettext("%d hop", "%d hops", self.path_length) % self.path_length @property def path_hops(self) -> list[str]: """Split path bytes into individual hop prefixes using path_hash_size.""" if self.path_length <= 0 or not self.path: return [] hs = self.path_hash_size return [ self.path[i : i + hs].hex().upper() for i in range(0, min(len(self.path), self.path_length * hs), hs) ] @property def is_favorite(self) -> bool: return bool(self.flags & CONTACT_FLAG_FAVORITE) @property def has_location(self) -> bool: if self.latitude is None or self.longitude is None: return False lat, lon = self.latitude, self.longitude if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0): return False # Filter out (0,0)..(1,1) range — unset or deliberately hidden return not (0.0 <= lat <= 1.0 and 0.0 <= lon <= 1.0) @dataclass class Message: sender_key: bytes text: str timestamp: datetime is_outgoing: bool is_cli: bool = False status: MessageStatus = MessageStatus.PENDING message_id: str | None = None retry_count: int = 0 expected_ack_hash: int | None = None sent_at: datetime | None = None delivered_at: datetime | None = None trip_time_ms: int | None = None path_length: int | None = None path_bytes: bytes = b"" room_sender_name: str = "" # resolved sender name for room messages @property def sender_key_hex(self) -> str: return self.sender_key.hex() class NotificationLevel(IntEnum): DEFAULT = -1 ALL = 0 MENTIONS = 1 NONE = 2 @dataclass class Channel: index: int name: str psk: bytes # 16 bytes unread_count: int = 0 notification_level: NotificationLevel = NotificationLevel.DEFAULT region: str = "" retention_days: int = 0 PUBLIC_CHANNEL_PSK = "8b3387e9c5cdea6ac9e5edbaa115cd72" @property def psk_hex(self) -> str: return self.psk.hex() @property def is_empty(self) -> bool: return not self.name and all(b == 0 for b in self.psk) @property def is_public_channel(self) -> bool: return self.psk_hex == self.PUBLIC_CHANNEL_PSK @property def is_hashtag_channel(self) -> bool: if self.is_public_channel or not self.name.startswith("#"): return False expected = hashlib.sha256(self.name.encode()).digest()[:16] return self.psk == expected @property def channel_type(self) -> str: if self.is_public_channel: return "Public" if self.is_hashtag_channel: return "Hashtag" return "Private" @staticmethod def empty(index: int) -> "Channel": return Channel(index=index, name="", psk=bytes(16)) @dataclass class DeviceInfo: # From SELF_INFO (meshcore reader field names) name: str = "" # Node name public_key: str = "" # Hex string tx_power: int = 0 max_tx_power: int = 0 radio_freq: float = 0.0 # MHz radio_bw: float = 0.0 # kHz radio_sf: int = 0 radio_cr: int = 0 cr_old_encoding: bool = False # From DEVICE_INFO model: str = "" # Board/manufacturer name ver: str = "" # Firmware version string fw_build: str = "" # Build date fw_ver: int = 0 # Firmware version code max_contacts: int = 32 max_channels: int = 8 path_hash_mode: int = 0 # 0=1-byte, 1=2-byte, 2=3-byte (fw v10+) multi_acks: int = 0 # 0=off, 1=send extra ACK (fw v10+) repeat: bool = False # client repeat / off-grid mode (fw v9+) allowed_repeat_freqs: list[tuple[int, int]] = field(default_factory=list) ble_pin: int = 0 # BLE pairing PIN (from DEVICE_INFO, fw v3+) # Location from SELF_INFO (adv_lat/adv_lon) latitude: float | None = None longitude: float | None = None # From BATT_AND_STORAGE battery_mv: int = 0 storage_used_kb: int = 0 storage_total_kb: int = 0 @property def battery_percent(self) -> int: return battery_percent_from_mv(self.battery_mv) @property def storage_percent(self) -> int: if self.storage_total_kb == 0: return 0 return int(self.storage_used_kb * 100 / self.storage_total_kb) PAYLOAD_TYPENAMES = [ "REQ", "RESPONSE", "TEXT_MSG", "ACK", "ADVERT", "GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH", "TRACE", "MULTIPART", "CONTROL", ] ROUTE_TYPENAMES = ["TC_FLOOD", "FLOOD", "DIRECT", "TC_DIRECT"] @dataclass class RxLogEntry: timestamp: datetime snr: float rssi: int payload_type: int route_type: int path: list = field(default_factory=list) path_hash_size: int = 1 pkt_hash: str = "" size: int = 0 transport_from: str = "" transport_to: str = "" raw_hex: str = "" @property def payload_type_name(self) -> str: if 0 <= self.payload_type < len(PAYLOAD_TYPENAMES): return PAYLOAD_TYPENAMES[self.payload_type] return f"UNK({self.payload_type})" @property def route_type_name(self) -> str: if 0 <= self.route_type < len(ROUTE_TYPENAMES): return ROUTE_TYPENAMES[self.route_type] return f"UNK({self.route_type})" @dataclass class ChannelMessage: channel_index: int sender_name: str text: str timestamp: datetime is_outgoing: bool = False status: MessageStatus = MessageStatus.DELIVERED meshy/src/notification.py000066400000000000000000000025501523412555500160430ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Platform notification facade. Exports ``NotificationService`` from the platform backend: - Linux: GIO notification backend using Gio.Notification - macOS: Native UserNotifications framework (UNUserNotificationCenter) IMPORTANT - macOS Limitation: The native UserNotifications framework requires a properly signed Python executable. Homebrew Python has only ad-hoc signatures which UNUserNotificationCenter rejects. For notifications to work on macOS, use either: - Python from python.org (properly signed) - Bundled Python framework (signed with Developer ID) """ import sys if sys.platform == "darwin": from .platforms.macos.notification_native import ( MacOSNotificationService as NotificationService, ) else: try: from .platforms.linux.notification_gio import ( GioNotificationService as NotificationService, ) except ImportError: from .platforms.notification_base import ( NotificationService as _Base, ) class NotificationService(_Base): # type: ignore[no-redef] def send_notification(self, title, body, notification_id): pass def is_supported(self): return False __all__ = ["NotificationService"] meshy/src/path_history.py000066400000000000000000000106271523412555500160760ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Path history tracking and smart route selection for private messages.""" from __future__ import annotations import logging import time from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from meshy.storage import Storage log = logging.getLogger(__name__) WEIGHT_SUCCESS_INCREMENT = 0.5 WEIGHT_FAILURE_DECREMENT = 0.2 MAX_WEIGHT = 5.0 @dataclass class PathRecord: id: int contact_key_hex: str path_bytes: str path_hash_size: int hop_count: int success_count: int failure_count: int last_trip_ms: int | None route_weight: float last_success_at: float | None class PathHistoryService: def __init__(self, storage: Storage): self._storage = storage def record_result( self, contact_key_hex: str, path_bytes: str, path_hash_size: int, hop_count: int, success: bool, trip_ms: int | None = None, weight_increment: float | None = None, ): if weight_increment is None: weight_increment = WEIGHT_SUCCESS_INCREMENT if success else -WEIGHT_FAILURE_DECREMENT self._storage.upsert_path_record( contact_key_hex, path_bytes, path_hash_size, hop_count, success, trip_ms, weight_increment=weight_increment, max_weight=MAX_WEIGHT, ) log.debug( "Path %s for %s: %s (trip=%s)", path_bytes, contact_key_hex[:8], "success" if success else "failure", trip_ms, ) FIRMWARE_DISCOVERY_WEIGHT = 1.0 def seed_from_contact( self, contact_key_hex: str, path_bytes: str, path_hash_size: int, hop_count: int, ): existing = [ r for r in self._storage.get_path_history(contact_key_hex) if r["path_bytes"] == path_bytes ] if existing: return self._storage.upsert_path_record( contact_key_hex, path_bytes, path_hash_size, hop_count, success=True, initial_weight=self.FIRMWARE_DISCOVERY_WEIGHT, ) log.debug( "Seeded path %s for %s from firmware (weight=%.1f)", path_bytes, contact_key_hex[:8], self.FIRMWARE_DISCOVERY_WEIGHT, ) def has_paths(self, contact_key_hex: str) -> bool: return bool(self._storage.get_path_history(contact_key_hex)) def select_path( self, contact_key_hex: str, exclude_paths: list[str] | None = None, ) -> PathRecord | None: records = [self._dict_to_record(r) for r in self._storage.get_path_history(contact_key_hex)] if not records: return None if exclude_paths: candidates = [r for r in records if r.path_bytes not in exclude_paths] if not candidates: return None else: candidates = records return max(candidates, key=self.score, default=None) def score(self, record: PathRecord) -> float: total = record.success_count + record.failure_count reliability = (record.success_count + 1) / (total + 2) if record.last_trip_ms and record.last_trip_ms > 0: latency = 1.0 / (1.0 + record.last_trip_ms / 10000.0) else: latency = 0.5 if record.last_success_at: age_days = (time.time() - record.last_success_at) / 86400.0 freshness = 1.0 / (1.0 + age_days) else: freshness = 0.0 return reliability * 0.6 + latency * 0.2 + freshness * 0.2 def score_dict(self, d: dict) -> float: return self.score(self._dict_to_record(d)) @staticmethod def _dict_to_record(d: dict) -> PathRecord: return PathRecord( id=d["id"], contact_key_hex=d["contact_key_hex"], path_bytes=d["path_bytes"], path_hash_size=d["path_hash_size"], hop_count=d["hop_count"], success_count=d["success_count"], failure_count=d["failure_count"], last_trip_ms=d["last_trip_ms"], route_weight=d["route_weight"], last_success_at=d["last_success_at"], ) meshy/src/platforms/000077500000000000000000000000001523412555500150105ustar00rootroot00000000000000meshy/src/platforms/__init__.py000066400000000000000000000012001523412555500171120ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Platform detection utilities for cross-platform support.""" import sys def get_platform(): """Return 'linux', 'macos', or 'other' based on the current platform.""" if sys.platform == "darwin": return "macos" if sys.platform.startswith("linux"): return "linux" return "other" def is_macos(): """Return True if running on macOS.""" return sys.platform == "darwin" def is_linux(): """Return True if running on Linux.""" return sys.platform.startswith("linux") meshy/src/platforms/ble_base.py000066400000000000000000000065121523412555500171220ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Platform-agnostic BLE transport base class.""" from abc import ABC, abstractmethod from collections.abc import Callable from meshy.transport_base import ConnectionState # Nordic UART Service UUIDs (same across all platforms) NUS_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" NUS_TX_CHAR_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" # Notify (device -> app) NUS_RX_CHAR_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" # Write (app -> device) class BleDevice: """Platform-agnostic BLE device representation.""" def __init__(self, path: str, address: str, name: str, rssi: int = -100): self.path = path self.address = address self.name = name self.rssi = rssi def __repr__(self): return f"BleDevice({self.name}, {self.address})" class BleTransportBase(ABC): """Abstract base class for platform-specific BLE implementations. Implementations must support the Nordic UART Service (NUS) for communication with MeshCore devices. """ # Callbacks that consumers can set on_state_changed: Callable[[ConnectionState], None] | None = None on_data_received: Callable[[bytes], None] | None = None on_device_discovered: Callable[[BleDevice], None] | None = None on_passkey_requested: Callable[[str, Callable], None] | None = None on_error: Callable[[str], None] | None = None @property @abstractmethod def state(self) -> ConnectionState: """Current connection state.""" ... @property @abstractmethod def is_connected(self) -> bool: """True if connected to a device.""" ... @property @abstractmethod def connected_address(self) -> str | None: """Address of the connected device, or None.""" ... @property @abstractmethod def discovered_devices(self) -> list[BleDevice]: """List of discovered BLE devices.""" ... @abstractmethod def start_scan(self): """Start scanning for BLE devices.""" ... @abstractmethod def stop_scan(self): """Stop scanning for BLE devices.""" ... @abstractmethod def connect_device(self, device_path: str): """Connect to a device by its path/identifier.""" ... @abstractmethod def connect_by_address(self, address: str): """Connect to a device by its MAC address.""" ... @abstractmethod def pair_device(self, device_path: str): """Pair with a device.""" ... @abstractmethod def disconnect(self): """Disconnect from the current device.""" ... @abstractmethod def send_data(self, data: bytes): """Send data to the connected device.""" ... @abstractmethod def is_bluetooth_enabled(self) -> bool: """Check if Bluetooth is enabled.""" ... @abstractmethod def has_bluetooth_adapter(self) -> bool: """Check if a Bluetooth adapter is available.""" ... @abstractmethod def get_paired_devices(self) -> list[BleDevice]: """Get list of paired devices that support NUS.""" ... @abstractmethod def remove_device(self, device_path: str) -> bool: """Remove/unpair a device.""" ... meshy/src/platforms/linux/000077500000000000000000000000001523412555500161475ustar00rootroot00000000000000meshy/src/platforms/linux/__init__.py000066400000000000000000000002431523412555500202570ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Linux-specific implementations.""" meshy/src/platforms/linux/ble_bluez.py000066400000000000000000001410441523412555500204700ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """BlueZ D-Bus BLE transport for MeshCore companion devices (Linux). Uses the BlueZ D-Bus API to discover, connect, and communicate with MeshCore devices via the Nordic UART Service (NUS). """ import logging import time from collections import deque from collections.abc import Callable import gi gi.require_version("Gtk", "4.0") from gi.repository import Gio, GLib from meshy.platforms.ble_base import ( NUS_RX_CHAR_UUID, NUS_SERVICE_UUID, NUS_TX_CHAR_UUID, BleDevice, BleTransportBase, ) from meshy.transport_base import ConnectionState log = logging.getLogger(__name__) BLUEZ_BUS_NAME = "org.bluez" BLUEZ_ADAPTER_IFACE = "org.bluez.Adapter1" BLUEZ_DEVICE_IFACE = "org.bluez.Device1" BLUEZ_AGENT_IFACE = "org.bluez.Agent1" BLUEZ_AGENT_MANAGER_IFACE = "org.bluez.AgentManager1" BLUEZ_GATT_SERVICE_IFACE = "org.bluez.GattService1" BLUEZ_GATT_CHAR_IFACE = "org.bluez.GattCharacteristic1" DBUS_OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager" DBUS_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties" AGENT_PATH = "/io/github/meshy/agent" _MAX_WRITE_RETRIES = 3 _WRITE_RETRY_DELAY_MS = 200 _CONNECT_TIMEOUT_MS = 8000 _MAX_CONNECT_ATTEMPTS = 3 _CONNECT_RETRY_DELAY_MS = 700 _CONNECT_RETRY_LONG_DELAY_MS = 1200 # Agent1 D-Bus interface XML for registration AGENT_INTROSPECTION_XML = """ """ class BleTransportBlueZ(BleTransportBase): """BLE transport using BlueZ D-Bus API (Linux).""" def __init__(self): self._bus: Gio.DBusConnection | None = None self._state = ConnectionState.DISCONNECTED self._adapter_path: str | None = None self._device_path: str | None = None self._tx_char_path: str | None = None self._rx_char_path: str | None = None self._device_address: str | None = None self._notify_signal_id: int | None = None self._disconnect_signal_id: int | None = None self._iface_added_signal_id: int | None = None self._discovered_devices: dict[str, BleDevice] = {} self._services_check_count: int = 0 self._recent_rx: deque = deque(maxlen=32) # (timestamp, data_hash) for dedup self._write_queue: deque = deque() # pending write data self._write_in_flight: bool = False self._write_current: bytes | None = None # data currently being written self._write_retries: int = 0 # Callbacks self.on_state_changed: Callable[[ConnectionState], None] | None = None self.on_data_received: Callable[[bytes], None] | None = None self.on_device_discovered: Callable[[BleDevice], None] | None = None self.on_passkey_requested: Callable[[str, Callable], None] | None = ( None # (device_path, reply_callback) ) self._agent_registered = False self._agent_reg_id: int | None = None self._passkey_invocation = None # pending D-Bus invocation for passkey reply self._connect_to_system_bus() def _connect_to_system_bus(self): try: self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) log.info("Connected to system D-Bus") except GLib.Error as e: log.error(f"Failed to connect to system bus: {e}") def _is_bluez_available(self) -> bool: """Quick check whether the org.bluez D-Bus name is owned. This queries the D-Bus daemon itself (always fast) rather than calling into BlueZ, so it never blocks the main loop even when no Bluetooth hardware is present. """ if not self._bus: return False try: result = self._bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", GLib.Variant("(s)", (BLUEZ_BUS_NAME,)), GLib.VariantType.new("(b)"), Gio.DBusCallFlags.NONE, 1000, None, ) return result.get_child_value(0).get_boolean() except GLib.Error: return False @property def state(self) -> ConnectionState: return self._state @property def is_connected(self) -> bool: return self._state == ConnectionState.CONNECTED @property def connected_address(self) -> str | None: return self._device_address @property def discovered_devices(self) -> list[BleDevice]: return list(self._discovered_devices.values()) def _set_state(self, state: ConnectionState): if self._state != state: old = self._state self._state = state log.info(f"BLE state: {old.name} -> {state.name}") if self.on_state_changed: self.on_state_changed(state) def _call_bluez( self, object_path: str, interface: str, method: str, parameters: GLib.Variant | None = None, reply_type: GLib.VariantType | None = None, ) -> GLib.Variant | None: """Synchronous D-Bus method call to BlueZ.""" try: result = self._bus.call_sync( BLUEZ_BUS_NAME, object_path, interface, method, parameters, reply_type, Gio.DBusCallFlags.NONE, 10000, None, ) return result except GLib.Error as e: log.error(f"D-Bus call {interface}.{method} on {object_path} failed: {e}") return None def _call_bluez_async( self, object_path: str, interface: str, method: str, parameters: GLib.Variant | None = None, callback: Callable | None = None, timeout_ms: int = 30000, ): """Async D-Bus method call to BlueZ.""" def _on_done(bus, result): try: res = bus.call_finish(result) if callback: callback(res, None) except GLib.Error as e: log.error(f"Async D-Bus call {interface}.{method} on {object_path} failed: {e}") if callback: callback(None, e) self._bus.call( BLUEZ_BUS_NAME, object_path, interface, method, parameters, None, Gio.DBusCallFlags.NONE, timeout_ms, None, _on_done, ) def _get_property(self, object_path: str, interface: str, prop: str) -> GLib.Variant | None: result = self._call_bluez( object_path, DBUS_PROPERTIES_IFACE, "Get", GLib.Variant("(ss)", (interface, prop)), GLib.VariantType.new("(v)"), ) if result: return result.get_child_value(0).get_variant() return None def _find_adapter(self) -> str | None: """Find the first Bluetooth adapter.""" if not self._is_bluez_available(): return None result = self._call_bluez( "/", DBUS_OBJECT_MANAGER_IFACE, "GetManagedObjects", None, GLib.VariantType.new("(a{oa{sa{sv}}})"), ) if not result: return None objects = result.get_child_value(0) for i in range(objects.n_children()): entry = objects.get_child_value(i) path = entry.get_child_value(0).get_string() interfaces = entry.get_child_value(1) for j in range(interfaces.n_children()): iface_entry = interfaces.get_child_value(j) iface_name = iface_entry.get_child_value(0).get_string() if iface_name == BLUEZ_ADAPTER_IFACE: return path return None def start_scan(self): """Start BLE device discovery.""" if self._state != ConnectionState.DISCONNECTED: return self._adapter_path = self._find_adapter() if not self._adapter_path: log.error("No Bluetooth adapter found") return self._discovered_devices.clear() self._set_state(ConnectionState.SCANNING) # Subscribe to InterfacesAdded for new devices self._iface_added_signal_id = self._bus.signal_subscribe( BLUEZ_BUS_NAME, DBUS_OBJECT_MANAGER_IFACE, "InterfacesAdded", None, None, Gio.DBusSignalFlags.NONE, self._on_interfaces_added, ) # Set discovery filter - use NUS service UUID (what MeshCore devices actually advertise) self._call_bluez( self._adapter_path, BLUEZ_ADAPTER_IFACE, "SetDiscoveryFilter", GLib.Variant( "(a{sv})", ( { "Transport": GLib.Variant("s", "le"), }, ), ), ) # Check already known/paired devices first self._check_existing_devices() # Start discovery self._call_bluez(self._adapter_path, BLUEZ_ADAPTER_IFACE, "StartDiscovery") log.info("BLE scan started") def _check_existing_devices(self): """Check for already-known devices that have the NUS service.""" result = self._call_bluez( "/", DBUS_OBJECT_MANAGER_IFACE, "GetManagedObjects", None, GLib.VariantType.new("(a{oa{sa{sv}}})"), ) if not result: return objects = result.get_child_value(0) for i in range(objects.n_children()): entry = objects.get_child_value(i) path = entry.get_child_value(0).get_string() interfaces = entry.get_child_value(1) for j in range(interfaces.n_children()): iface_entry = interfaces.get_child_value(j) iface_name = iface_entry.get_child_value(0).get_string() if iface_name == BLUEZ_DEVICE_IFACE: props = iface_entry.get_child_value(1) self._process_device(path, props) def _on_interfaces_added( self, connection, sender, object_path, interface_name, signal_name, parameters ): """Handle new D-Bus interface (new device discovered).""" path = parameters.get_child_value(0).get_string() interfaces = parameters.get_child_value(1) for i in range(interfaces.n_children()): entry = interfaces.get_child_value(i) iface_name = entry.get_child_value(0).get_string() if iface_name == BLUEZ_DEVICE_IFACE: props = entry.get_child_value(1) self._process_device(path, props) def _process_device(self, path: str, props: GLib.Variant): """Process a discovered device's properties. Accept devices that have the NUS service UUID in their advertised UUIDs. """ address = name = "" rssi = -100 uuids = [] for k in range(props.n_children()): prop = props.get_child_value(k) key = prop.get_child_value(0).get_string() val = prop.get_child_value(1).get_variant() if key == "Address": address = val.get_string() elif key == "Name": name = val.get_string() elif key == "Alias": if not name: name = val.get_string() elif key == "RSSI": rssi = val.get_int16() elif key == "UUIDs": uuids = [val.get_child_value(i).get_string() for i in range(val.n_children())] if not address: return # Accept devices that have NUS service UUID has_nus = NUS_SERVICE_UUID.lower() in [u.lower() for u in uuids] if not has_nus: return log.info(f"Found NUS device: {name} ({address}) at {path}") device = BleDevice(path=path, address=address, name=name or address, rssi=rssi) self._discovered_devices[path] = device if self.on_device_discovered: self.on_device_discovered(device) def stop_scan(self): """Stop BLE device discovery.""" if self._state != ConnectionState.SCANNING: return if self._adapter_path: self._call_bluez(self._adapter_path, BLUEZ_ADAPTER_IFACE, "StopDiscovery") if self._iface_added_signal_id: self._bus.signal_unsubscribe(self._iface_added_signal_id) self._iface_added_signal_id = None self._set_state(ConnectionState.DISCONNECTED) log.info("BLE scan stopped") def connect_device(self, device_path: str): """Connect to a BLE device by its D-Bus object path.""" if self._state == ConnectionState.SCANNING: self.stop_scan() self._set_state(ConnectionState.CONNECTING) self._device_path = device_path self._services_check_count = 0 self._reconnect_attempted = False # Read the device address addr_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, "Address") self._device_address = addr_var.get_string() if addr_var else None # Register agent - needed for devices requiring auth on every connection self._register_agent() # Ensure device is trusted (required for GATT service resolution) self._call_bluez( device_path, DBUS_PROPERTIES_IFACE, "Set", GLib.Variant("(ssv)", (BLUEZ_DEVICE_IFACE, "Trusted", GLib.Variant("b", True))), ) # Check if device is paired, if not try pairing first paired_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, "Paired") if paired_var and not paired_var.get_boolean(): log.info(f"Device {device_path} not paired, initiating pairing") self.pair_device(device_path) return # Check if already connected connected_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, "Connected") if connected_var and connected_var.get_boolean(): log.info(f"Device {device_path} is already connected") # Check if services are already resolved resolved_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, "ServicesResolved") if resolved_var and resolved_var.get_boolean(): log.info("Services already resolved, finding characteristics") self._find_nus_characteristics() return else: log.info("Waiting for services to resolve...") GLib.timeout_add(500, self._check_services_resolved) return # Not connected, initiate connection with retry log.info(f"Initiating connection to {device_path}") self._attempt_connect(device_path, attempt=1) def _attempt_connect(self, device_path: str, attempt: int): """Try to connect with automatic retry on failure.""" if self._state != ConnectionState.CONNECTING: return log.info(f"Connect attempt {attempt}/{_MAX_CONNECT_ATTEMPTS} for {device_path}") def _on_connected(result, error): if self._state != ConnectionState.CONNECTING: return if error: error_msg = str(error) if "Already Connected" in error_msg: log.info("Device reports already connected") GLib.timeout_add(500, self._check_services_resolved) return log.warning(f"Connect attempt {attempt} failed: {error}") if attempt < _MAX_CONNECT_ATTEMPTS: self._call_bluez(device_path, BLUEZ_DEVICE_IFACE, "Disconnect") delay = _CONNECT_RETRY_LONG_DELAY_MS if attempt > 1 else _CONNECT_RETRY_DELAY_MS log.info(f"Retrying connect after BlueZ disconnect " f"(delay {delay}ms)") def _retry(): self._attempt_connect(device_path, attempt + 1) return False GLib.timeout_add(delay, _retry) else: log.error( f"All {_MAX_CONNECT_ATTEMPTS} connect attempts " f"failed for {device_path}" ) self._set_state(ConnectionState.DISCONNECTED) return log.info(f"Connect succeeded on attempt {attempt} for {device_path}") GLib.timeout_add(500, self._check_services_resolved) self._call_bluez_async( device_path, BLUEZ_DEVICE_IFACE, "Connect", callback=_on_connected, timeout_ms=_CONNECT_TIMEOUT_MS, ) def connect_by_address(self, address: str): """Connect to a device by its Bluetooth address.""" # Find the device path from known devices for path, device in self._discovered_devices.items(): if device.address.upper() == address.upper(): self.connect_device(path) return # Try to find it in BlueZ known/paired devices result = self._call_bluez( "/", DBUS_OBJECT_MANAGER_IFACE, "GetManagedObjects", None, GLib.VariantType.new("(a{oa{sa{sv}}})"), ) if result: objects = result.get_child_value(0) for i in range(objects.n_children()): entry = objects.get_child_value(i) path = entry.get_child_value(0).get_string() interfaces = entry.get_child_value(1) for j in range(interfaces.n_children()): iface_entry = interfaces.get_child_value(j) iface_name = iface_entry.get_child_value(0).get_string() if iface_name == BLUEZ_DEVICE_IFACE: addr_var = self._get_property(path, BLUEZ_DEVICE_IFACE, "Address") if addr_var and addr_var.get_string().upper() == address.upper(): log.info(f"Found device at {path} for address {address}") self.connect_device(path) return # Device not in BlueZ (maybe removed) - scan to find it log.info(f"Device {address} not in BlueZ, scanning to rediscover...") target_address = address.upper() orig_callback = self.on_device_discovered def _on_found(device): if device.address.upper() == target_address: log.info(f"Rediscovered {device.name} at {device.path}") self.on_device_discovered = orig_callback if self.state == ConnectionState.SCANNING: self.stop_scan() # Need to pair since bond was removed self._set_state(ConnectionState.CONNECTING) self.pair_device(device.path) self.on_device_discovered = _on_found self.start_scan() # Give up after 10s def _scan_timeout(): if self.on_device_discovered == _on_found: self.on_device_discovered = orig_callback if self.state == ConnectionState.SCANNING: self.stop_scan() log.error(f"Device {address} not found after scanning") self._set_state(ConnectionState.DISCONNECTED) return False GLib.timeout_add(10000, _scan_timeout) def _check_services_resolved(self) -> bool: """Check if GATT services are resolved after connection.""" if not self._device_path or self._state != ConnectionState.CONNECTING: return False self._services_check_count += 1 if not self._device_path or self._state != ConnectionState.CONNECTING: return False # Check if still connected connected_var = self._get_property(self._device_path, BLUEZ_DEVICE_IFACE, "Connected") is_connected = connected_var and connected_var.get_boolean() log.debug( f"Checking ServicesResolved (attempt {self._services_check_count}, " f"connected={is_connected})" ) if not is_connected and not self._reconnect_attempted: # Device not connected yet - retry Connect with a longer delay self._reconnect_attempted = True device_path = self._device_path log.info("Device not connected, retrying Connect in 3s...") def _do_retry(): if self._state != ConnectionState.CONNECTING: return False self._services_check_count = 0 log.info("Retrying Connect...") def _on_connect(result, error): if error: log.error(f"Retry connect failed: {error}") self._set_state(ConnectionState.DISCONNECTED) return log.info("Retry connect succeeded, waiting for services...") GLib.timeout_add(500, self._check_services_resolved) self._call_bluez_async( device_path, BLUEZ_DEVICE_IFACE, "Connect", callback=_on_connect, ) return False GLib.timeout_add(3000, _do_retry) return False # Stop polling, retry starts new one if not is_connected and self._reconnect_attempted: # Already tried reconnect, still not connected log.error("Device not connected after reconnect") self._set_state(ConnectionState.DISCONNECTED) return False # Connected but services not resolved resolved_var = self._get_property(self._device_path, BLUEZ_DEVICE_IFACE, "ServicesResolved") if resolved_var and resolved_var.get_boolean(): log.info("Services resolved, finding NUS characteristics") self._find_nus_characteristics() return False # After 20 attempts (10s) with connection but no services, try disconnect+reconnect if self._services_check_count == 20 and not self._reconnect_attempted: self._reconnect_attempted = True device_path = self._device_path log.info("Services not resolving despite connection, trying disconnect+reconnect") self._call_bluez(device_path, BLUEZ_DEVICE_IFACE, "Disconnect") def _do_reconnect(): if self._state != ConnectionState.CONNECTING: return False log.info("Reconnecting after disconnect...") self._services_check_count = 0 def _on_reconnect(result, error): if error: log.error(f"Reconnect failed: {error}") self._set_state(ConnectionState.DISCONNECTED) return log.info("Reconnect succeeded, waiting for services...") GLib.timeout_add(500, self._check_services_resolved) self._call_bluez_async( device_path, BLUEZ_DEVICE_IFACE, "Connect", callback=_on_reconnect, ) return False GLib.timeout_add(3000, _do_reconnect) return False # Stop polling, reconnect starts new one # Give up after 40 attempts (20 seconds) if self._services_check_count > 40: log.error("Timed out waiting for services to resolve") self._set_state(ConnectionState.DISCONNECTED) return False return True def _find_nus_characteristics(self): """Find NUS TX/RX characteristics after services are resolved.""" result = self._call_bluez( "/", DBUS_OBJECT_MANAGER_IFACE, "GetManagedObjects", None, GLib.VariantType.new("(a{oa{sa{sv}}})"), ) if not result: log.error("Failed to enumerate GATT services") self._set_state(ConnectionState.DISCONNECTED) return self._tx_char_path = None self._rx_char_path = None objects = result.get_child_value(0) for i in range(objects.n_children()): entry = objects.get_child_value(i) path = entry.get_child_value(0).get_string() if not path.startswith(self._device_path): continue interfaces = entry.get_child_value(1) for j in range(interfaces.n_children()): iface_entry = interfaces.get_child_value(j) iface_name = iface_entry.get_child_value(0).get_string() if iface_name == BLUEZ_GATT_CHAR_IFACE: # Read UUID from the properties dict in the managed objects char_props = iface_entry.get_child_value(1) uuid_var = char_props.lookup_value("UUID", GLib.VariantType.new("s")) if uuid_var: uuid = uuid_var.get_string().lower() log.debug(f" Characteristic {path}: UUID={uuid}") if uuid == NUS_TX_CHAR_UUID: self._tx_char_path = path log.info(f"Found NUS TX (notify) at {path}") elif uuid == NUS_RX_CHAR_UUID: self._rx_char_path = path log.info(f"Found NUS RX (write) at {path}") if self._tx_char_path and self._rx_char_path: log.info("Found both NUS characteristics, starting notifications") self._start_notifications() else: log.error( f"NUS characteristics not found (TX={self._tx_char_path}, RX={self._rx_char_path})" ) # Fallback: try reading UUID via Get property call self._find_nus_characteristics_fallback() def _find_nus_characteristics_fallback(self): """Fallback: enumerate characteristics by calling Get on each one.""" log.info("Trying fallback characteristic discovery...") result = self._call_bluez( "/", DBUS_OBJECT_MANAGER_IFACE, "GetManagedObjects", None, GLib.VariantType.new("(a{oa{sa{sv}}})"), ) if not result: self._set_state(ConnectionState.DISCONNECTED) return objects = result.get_child_value(0) for i in range(objects.n_children()): entry = objects.get_child_value(i) path = entry.get_child_value(0).get_string() if not path.startswith(self._device_path): continue interfaces = entry.get_child_value(1) for j in range(interfaces.n_children()): iface_entry = interfaces.get_child_value(j) iface_name = iface_entry.get_child_value(0).get_string() if iface_name == BLUEZ_GATT_CHAR_IFACE: uuid_var = self._get_property(path, BLUEZ_GATT_CHAR_IFACE, "UUID") if uuid_var: uuid = uuid_var.get_string().lower() log.debug(f" Fallback: Char {path} UUID={uuid}") if uuid == NUS_TX_CHAR_UUID: self._tx_char_path = path elif uuid == NUS_RX_CHAR_UUID: self._rx_char_path = path if self._tx_char_path and self._rx_char_path: log.info("Fallback found NUS characteristics") self._start_notifications() else: log.error(f"Fallback also failed (TX={self._tx_char_path}, RX={self._rx_char_path})") self._set_state(ConnectionState.DISCONNECTED) def _start_notifications(self): """Start notifications on the TX characteristic to receive data.""" # Subscribe to PropertiesChanged for the TX characteristic self._notify_signal_id = self._bus.signal_subscribe( BLUEZ_BUS_NAME, DBUS_PROPERTIES_IFACE, "PropertiesChanged", self._tx_char_path, None, Gio.DBusSignalFlags.NONE, self._on_tx_notify, ) # Listen for disconnect self._disconnect_signal_id = self._bus.signal_subscribe( BLUEZ_BUS_NAME, DBUS_PROPERTIES_IFACE, "PropertiesChanged", self._device_path, None, Gio.DBusSignalFlags.NONE, self._on_device_properties_changed, ) # Start notifications on the TX characteristic def _on_notify_started(result, error): if error: error_msg = str(error) if "Already notifying" in error_msg or "In Progress" in error_msg: log.info("Notifications already active") self._set_state(ConnectionState.CONNECTED) else: log.error(f"Failed to start notifications: {error}") self._set_state(ConnectionState.DISCONNECTED) return log.info("BLE notifications started successfully") self._set_state(ConnectionState.CONNECTED) self._call_bluez_async( self._tx_char_path, BLUEZ_GATT_CHAR_IFACE, "StartNotify", callback=_on_notify_started ) def _on_tx_notify( self, connection, sender, object_path, interface_name, signal_name, parameters ): """Handle data received via TX characteristic notification.""" iface = parameters.get_child_value(0).get_string() if iface != BLUEZ_GATT_CHAR_IFACE: return changed = parameters.get_child_value(1) value_var = changed.lookup_value("Value", GLib.VariantType.new("ay")) if value_var: data = bytes(value_var.unpack()) if data: # Deduplicate: BlueZ sometimes delivers the same notification twice now = time.monotonic() data_hash = hash(data) for ts, dh in self._recent_rx: if dh == data_hash and now - ts < 0.5: return # duplicate within 500ms, skip self._recent_rx.append((now, data_hash)) log.debug(f"BLE RX [{len(data)} bytes]: {data[:20].hex()}...") if self.on_data_received: self.on_data_received(data) def _on_device_properties_changed( self, connection, sender, object_path, interface_name, signal_name, parameters ): """Handle device property changes (e.g., disconnect).""" # Only handle events for the currently connected device if object_path != self._device_path: return iface = parameters.get_child_value(0).get_string() if iface != BLUEZ_DEVICE_IFACE: return changed = parameters.get_child_value(1) connected_var = changed.lookup_value("Connected", GLib.VariantType.new("b")) if connected_var and not connected_var.get_boolean(): log.info(f"Device {object_path} disconnected") self._cleanup() def send_data(self, data: bytes): """Queue data for sending via the RX characteristic (WriteValue). Writes are serialized: only one WriteValue is in-flight at a time. Subsequent calls are queued and drained as each write completes. """ if not self._rx_char_path or self._state != ConnectionState.CONNECTED: log.warning(f"Cannot send: state={self._state.name}, rx_path={self._rx_char_path}") return if self._write_in_flight: self._write_queue.append(data) return self._do_write(data) def _do_write(self, data: bytes): """Send data immediately via BlueZ WriteValue.""" self._write_in_flight = True self._write_current = data log.debug(f"BLE TX [{len(data)} bytes]: {data[:20].hex()}...") value = GLib.Variant( "(aya{sv})", ( list(data), {}, ), ) self._call_bluez_async( self._rx_char_path, BLUEZ_GATT_CHAR_IFACE, "WriteValue", parameters=value, callback=self._on_write_done, ) def _is_device_connected(self) -> bool: if not self._device_path: return False connected_var = self._get_property(self._device_path, BLUEZ_DEVICE_IFACE, "Connected") return connected_var.get_boolean() if connected_var else False def _on_write_done(self, result, error): if error: error_msg = str(error) log.error(f"Write failed: {error_msg}") if "In Progress" in error_msg and self._write_current is not None: self._write_retries += 1 if self._write_retries <= _MAX_WRITE_RETRIES: log.info( f"Retrying write after In Progress error " f"{self._write_retries}/{_MAX_WRITE_RETRIES}" ) GLib.timeout_add(100, self._retry_current_write) return log.warning("In Progress retries exhausted, dropping frame") failed_data = self._write_current self._write_current = None self._write_in_flight = False if "In Progress" not in error_msg and self._is_device_connected(): if self._write_retries < _MAX_WRITE_RETRIES and failed_data is not None: self._write_retries += 1 log.warning( f"Write error but device connected, retry " f"{self._write_retries}/{_MAX_WRITE_RETRIES}" ) self._write_queue.appendleft(failed_data) GLib.timeout_add(_WRITE_RETRY_DELAY_MS, self._drain_write_queue) return if "In Progress" not in error_msg: log.warning(f"Write failed after {_MAX_WRITE_RETRIES} retries, dropping frame") elif not self._is_device_connected(): log.warning("Write failure and device disconnected, cleaning up") self._cleanup() return self._write_retries = 0 GLib.idle_add(self._drain_write_queue) return self._write_retries = 0 self._write_current = None self._write_in_flight = False GLib.idle_add(self._drain_write_queue) def _retry_current_write(self): """Retry the current in-flight write (used for InProgress errors). If the queue already has new writes, the response likely arrived via notification despite the InProgress error — skip the stale retry and drain the queue instead. """ if self._write_current is not None and not self._write_queue: self._do_write(self._write_current) else: self._write_current = None self._write_in_flight = False self._drain_write_queue() return False def _drain_write_queue(self): """Send the next queued write if idle.""" if self._write_in_flight or not self._write_queue: return False if not self._rx_char_path or self._state != ConnectionState.CONNECTED: self._write_queue.clear() return False self._do_write(self._write_queue.popleft()) return False def _register_agent(self): """Register our Agent1 with BlueZ for handling pairing.""" if self._agent_registered: return introspection = Gio.DBusNodeInfo.new_for_xml(AGENT_INTROSPECTION_XML) self._agent_reg_id = self._bus.register_object( AGENT_PATH, introspection.interfaces[0], self._on_agent_method_call, None, None, ) result = self._call_bluez( "/org/bluez", BLUEZ_AGENT_MANAGER_IFACE, "RegisterAgent", GLib.Variant("(os)", (AGENT_PATH, "KeyboardDisplay")), ) if result is None: # Might already be registered from a previous session, # re-register the D-Bus object but skip the BlueZ call log.warning("Agent registration call failed (may already be registered)") # Set as default agent so BlueZ always uses ours self._call_bluez( "/org/bluez", BLUEZ_AGENT_MANAGER_IFACE, "RequestDefaultAgent", GLib.Variant("(o)", (AGENT_PATH,)), ) self._agent_registered = True log.info("BlueZ agent registered as default") def _unregister_agent(self): """Unregister our Agent1.""" if not self._agent_registered: return self._call_bluez( "/org/bluez", BLUEZ_AGENT_MANAGER_IFACE, "UnregisterAgent", GLib.Variant("(o)", (AGENT_PATH,)), ) if self._agent_reg_id: self._bus.unregister_object(self._agent_reg_id) self._agent_reg_id = None self._agent_registered = False log.info("BlueZ agent unregistered") def _set_device_trusted(self, device_path: str): """Mark a device as trusted in BlueZ.""" log.info(f"Setting {device_path} as trusted") self._call_bluez( device_path, DBUS_PROPERTIES_IFACE, "Set", GLib.Variant("(ssv)", (BLUEZ_DEVICE_IFACE, "Trusted", GLib.Variant("b", True))), ) def _on_agent_method_call( self, connection, sender, object_path, interface_name, method_name, parameters, invocation ): """Handle Agent1 D-Bus method calls from BlueZ.""" log.info(f"Agent1 call: {method_name} params={parameters}") if method_name == "Release": invocation.return_value(None) elif method_name == "RequestPasskey": device_path = parameters.get_child_value(0).get_string() log.info(f"Passkey requested for {device_path}") self._set_device_trusted(device_path) self._passkey_invocation = invocation if self.on_passkey_requested: self.on_passkey_requested(device_path, self._reply_passkey) else: invocation.return_value(GLib.Variant("(u)", (0,))) elif method_name == "RequestPinCode": device_path = parameters.get_child_value(0).get_string() log.info(f"PIN code requested for {device_path}") self._set_device_trusted(device_path) self._passkey_invocation = invocation if self.on_passkey_requested: self.on_passkey_requested(device_path, self._reply_pincode) else: invocation.return_value(GLib.Variant("(s)", ("000000",))) elif method_name == "RequestConfirmation": device_path = parameters.get_child_value(0).get_string() passkey = parameters.get_child_value(1).get_uint32() log.info(f"Confirmation requested for {device_path}: {passkey:06d}") self._set_device_trusted(device_path) invocation.return_value(None) elif method_name == "DisplayPasskey": device_path = parameters.get_child_value(0).get_string() passkey = parameters.get_child_value(1).get_uint32() log.info(f"Display passkey for {device_path}: {passkey:06d}") invocation.return_value(None) elif method_name in ("RequestAuthorization", "AuthorizeService"): device_path = parameters.get_child_value(0).get_string() self._set_device_trusted(device_path) invocation.return_value(None) elif method_name == "Cancel": log.info("Pairing cancelled by BlueZ") self._passkey_invocation = None invocation.return_value(None) else: invocation.return_dbus_error("org.bluez.Error.Rejected", "Not implemented") def _reply_passkey(self, passkey: int): """Reply to a pending RequestPasskey with the user-provided passkey.""" if self._passkey_invocation: self._passkey_invocation.return_value(GLib.Variant("(u)", (passkey,))) self._passkey_invocation = None def _reply_pincode(self, passkey: int): """Reply to a pending RequestPinCode with the user-provided PIN.""" if self._passkey_invocation: self._passkey_invocation.return_value(GLib.Variant("(s)", (f"{passkey:06d}",))) self._passkey_invocation = None def pair_device(self, device_path: str): """Initiate pairing with a device. Registers agent first, then calls Pair() which handles connect+auth+bond.""" self._register_agent() self._set_state(ConnectionState.CONNECTING) self._device_path = device_path # Read address before pairing addr_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, "Address") self._device_address = addr_var.get_string() if addr_var else None def _on_pair_done(result, error): if error: error_msg = str(error) if "Already Exists" in error_msg: log.info("Device already paired, connecting...") self.connect_device(device_path) else: log.error(f"Pairing failed: {error}") if "AuthenticationFailed" in error_msg: if self.on_error: self.on_error(_("Wrong PIN — try again")) self.pair_device(device_path) return elif "AuthenticationCanceled" in error_msg: user_msg = _("Pairing cancelled") elif "ConnectionAttemptFailed" in error_msg: user_msg = _("Pairing failed — could not connect") else: user_msg = _("Pairing failed") if self.on_error: self.on_error(user_msg) self._set_state(ConnectionState.DISCONNECTED) return log.info(f"Pairing succeeded for {device_path}") self.connect_device(device_path) log.info(f"Calling Pair() on {device_path}") self._call_bluez_async( device_path, BLUEZ_DEVICE_IFACE, "Pair", callback=_on_pair_done, ) def is_bluetooth_enabled(self) -> bool: """Check if the Bluetooth adapter is powered on.""" adapter_path = self._adapter_path or self._find_adapter() if not adapter_path: return False powered = self._get_property(adapter_path, BLUEZ_ADAPTER_IFACE, "Powered") return powered.get_boolean() if powered else False def has_bluetooth_adapter(self) -> bool: """Check if a Bluetooth adapter exists.""" return self._find_adapter() is not None def get_paired_devices(self) -> list[BleDevice]: """Get list of already-paired devices that have NUS service.""" if not self._is_bluez_available(): return [] result = self._call_bluez( "/", DBUS_OBJECT_MANAGER_IFACE, "GetManagedObjects", None, GLib.VariantType.new("(a{oa{sa{sv}}})"), ) if not result: return [] devices = [] objects = result.get_child_value(0) for i in range(objects.n_children()): entry = objects.get_child_value(i) path = entry.get_child_value(0).get_string() interfaces = entry.get_child_value(1) for j in range(interfaces.n_children()): iface_entry = interfaces.get_child_value(j) iface_name = iface_entry.get_child_value(0).get_string() if iface_name == BLUEZ_DEVICE_IFACE: props = iface_entry.get_child_value(1) address = name = "" paired = False uuids = [] for k in range(props.n_children()): prop = props.get_child_value(k) key = prop.get_child_value(0).get_string() val = prop.get_child_value(1).get_variant() if key == "Address": address = val.get_string() elif key == "Name" or key == "Alias" and not name: name = val.get_string() elif key == "Paired": paired = val.get_boolean() elif key == "UUIDs": uuids = [ val.get_child_value(m).get_string() for m in range(val.n_children()) ] has_nus = NUS_SERVICE_UUID.lower() in [u.lower() for u in uuids] if paired and has_nus and address: devices.append(BleDevice(path=path, address=address, name=name or address)) return devices def remove_device(self, device_path: str) -> bool: """Remove (unpair) a Bluetooth device via Adapter1.RemoveDevice.""" adapter = self._adapter_path or self._find_adapter() if not adapter: log.error("No Bluetooth adapter found for device removal") return False result = self._call_bluez( adapter, BLUEZ_ADAPTER_IFACE, "RemoveDevice", GLib.Variant("(o)", (device_path,)) ) return result is not None def disconnect(self): """Disconnect from the current device.""" if self._state in (ConnectionState.DISCONNECTED, ConnectionState.DISCONNECTING): return self._set_state(ConnectionState.DISCONNECTING) # Unsubscribe signals FIRST to prevent race conditions # when connecting to a different device if self._notify_signal_id: self._bus.signal_unsubscribe(self._notify_signal_id) self._notify_signal_id = None if self._disconnect_signal_id: self._bus.signal_unsubscribe(self._disconnect_signal_id) self._disconnect_signal_id = None if self._tx_char_path: self._call_bluez(self._tx_char_path, BLUEZ_GATT_CHAR_IFACE, "StopNotify") device_path = self._device_path self._tx_char_path = None self._rx_char_path = None self._device_path = None self._device_address = None self._write_queue.clear() self._write_in_flight = False self._write_current = None if device_path: self._call_bluez_async( device_path, BLUEZ_DEVICE_IFACE, "Disconnect", callback=lambda r, e: self._set_state(ConnectionState.DISCONNECTED), ) else: self._set_state(ConnectionState.DISCONNECTED) def _cleanup(self): """Clean up connection state.""" if self._notify_signal_id: self._bus.signal_unsubscribe(self._notify_signal_id) self._notify_signal_id = None if self._disconnect_signal_id: self._bus.signal_unsubscribe(self._disconnect_signal_id) self._disconnect_signal_id = None self._tx_char_path = None self._rx_char_path = None self._device_path = None self._device_address = None self._write_queue.clear() self._write_in_flight = False self._write_current = None self._write_retries = 0 self._set_state(ConnectionState.DISCONNECTED) meshy/src/platforms/linux/location_geoclue.py000066400000000000000000000025471523412555500220440ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """GeoClue2-based location service for Linux.""" import logging log = logging.getLogger(__name__) def get_location_linux(callback): """Get current location using GeoClue2. Args: callback: Function to call with (latitude, longitude, error). If successful, error is None. If failed, lat/lon are None. """ try: import gi gi.require_version("Geoclue", "2.0") from gi.repository import Geoclue def _on_ready(source, result, *args): try: geoclue = Geoclue.Simple.new_finish(result) loc = geoclue.get_location() lat = loc.get_property("latitude") lon = loc.get_property("longitude") log.info(f"Location obtained: {lat}, {lon}") callback(lat, lon, None) except Exception as e: log.error(f"Failed to get location: {e}") callback(None, None, str(e)) Geoclue.Simple.new( "page.codeberg.sesivany.Meshy", Geoclue.AccuracyLevel.EXACT, None, _on_ready, ) except Exception as e: log.error(f"Failed to initialize GeoClue: {e}") callback(None, None, str(e)) meshy/src/platforms/linux/notification_gio.py000066400000000000000000000030721523412555500220470ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """GTK/GIO-based notification service for Linux.""" import logging from gi.repository import Gio, Gtk from ..notification_base import NotificationService log = logging.getLogger(__name__) class GioNotificationService(NotificationService): """Notification service using Gio.Notification (GTK standard on Linux).""" def __init__(self, application: Gtk.Application): """Initialize notification service. Args: application: GTK application instance for sending notifications """ self._app = application log.debug("GioNotificationService initialized") def send_notification(self, title: str, body: str, notification_id: str) -> None: """Send a desktop notification using Gio.Notification. Args: title: Notification title body: Notification body text notification_id: Unique identifier for this notification """ try: notification = Gio.Notification.new(title) notification.set_body(body) self._app.send_notification(notification_id, notification) log.debug(f"Sent notification: {title}") except Exception as e: log.warning(f"Failed to send notification: {e}") def is_supported(self) -> bool: """Check if Gio notifications are supported. Returns: True - Gio.Notification is always available with GTK4 """ return True meshy/src/platforms/macos/000077500000000000000000000000001523412555500161125ustar00rootroot00000000000000meshy/src/platforms/macos/__init__.py000066400000000000000000000002431523412555500202220ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """macOS-specific implementations.""" meshy/src/platforms/macos/app_activation.py000066400000000000000000000033211523412555500214640ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """macOS application activation helper. GTK4 on macOS needs explicit NSApplication activation to show windows properly. """ def activate_macos_app(): """Activate the macOS application so windows appear. This configures NSApplication to behave as a regular GUI app and brings it to the foreground. """ try: from AppKit import ( NSApplication, NSApplicationActivationPolicyRegular, NSApplicationActivateAllWindows, NSApplicationActivateIgnoringOtherApps, ) # Get the shared NSApplication instance app = NSApplication.sharedApplication() # Set activation policy to regular app (shows in Dock, can be focused) # This must be done before activating app.setActivationPolicy_(NSApplicationActivationPolicyRegular) # Activate the app and bring it to the foreground # activateIgnoringOtherApps: brings app to front regardless of other apps app.activateIgnoringOtherApps_(True) import logging log = logging.getLogger(__name__) log.info("macOS app activated successfully") return True except ImportError: # PyObjC not available import logging log = logging.getLogger(__name__) log.warning("PyObjC AppKit not available - window may not activate automatically") return False except Exception as e: import logging log = logging.getLogger(__name__) log.warning(f"Failed to activate macOS app: {e}") import traceback traceback.print_exc() return False meshy/src/platforms/macos/ble_corebluetooth.py000066400000000000000000000640671523412555500222010ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """CoreBluetooth BLE transport for MeshCore companion devices (macOS). Uses the macOS CoreBluetooth framework to discover, connect, and communicate with MeshCore devices via the Nordic UART Service (NUS). This implementation uses PyObjC to interface with macOS CoreBluetooth APIs. """ import logging from meshy.platforms.ble_base import ( BleDevice, NUS_RX_CHAR_UUID, NUS_SERVICE_UUID, NUS_TX_CHAR_UUID, ) from meshy.transport_base import ConnectionState log = logging.getLogger(__name__) # BLE connection timeouts and retry settings _BLE_CONNECTION_TIMEOUT_S = 30 # Import CoreBluetooth framework via PyObjC try: from Foundation import NSObject from CoreBluetooth import ( CBUUID, CBCentralManager, CBCharacteristicWriteWithResponse, CBManagerStatePoweredOff, CBManagerStatePoweredOn, CBManagerStateUnauthorized, CBManagerStateUnknown, CBManagerStateUnsupported, CBPeripheral, ) COREBLUETOOTH_AVAILABLE = True except ImportError: log.warning("PyObjC CoreBluetooth not available - BLE will not work on macOS") COREBLUETOOTH_AVAILABLE = False NSObject = object # Fallback for class definition class BleTransportCoreBluetooth(NSObject): """BLE transport using CoreBluetooth framework (macOS). This class duck-types the BleTransportBase interface and extends NSObject to serve as a CoreBluetooth delegate. It does not inherit from BleTransportBase to avoid metaclass conflicts with NSObject. """ def init(self): """Initialize the CoreBluetooth transport (Objective-C style required for PyObjC).""" if not COREBLUETOOTH_AVAILABLE: return None # Initialize parent NSObject using objc.super from objc import super as objc_super self = objc_super(BleTransportCoreBluetooth, self).init() if self is None: return None # Initialize instance variables self._state = ConnectionState.DISCONNECTED self._central_manager = None self._connected_peripheral = None self._discovered_devices = {} self._device_address = None self._tx_characteristic = None self._rx_characteristic = None self._connection_timeout_id = None # GLib timeout source ID # Callbacks (from BleTransportBase interface) self.on_state_changed = None self.on_data_received = None self.on_device_discovered = None self.on_passkey_requested = None # Initialize CoreBluetooth central manager on main thread from gi.repository import GLib GLib.idle_add(self._init_central_manager) log.info("CoreBluetooth transport initialized") return self def _init_central_manager(self): """Initialize the CBCentralManager (must be called on main thread). Note: This will crash if called from a command-line Python process without a proper Info.plist containing NSBluetoothAlwaysUsageDescription. macOS's TCC (privacy system) requires this before any Bluetooth access. """ if not COREBLUETOOTH_AVAILABLE: return False # Check if we're running in a proper app bundle with Info.plist # If not, skip BLE initialization to avoid TCC crash try: from Foundation import NSBundle, NSURL import sys import os # Try to find the .app bundle by walking up from the executable bundle = None executable_path = os.path.realpath(sys.argv[0]) log.debug(f"Executable path: {executable_path}") # Check if we're inside a .app bundle # Look for any .app/Contents pattern (could be MacOS, Resources, etc.) if '.app/Contents/' in executable_path: # Extract the .app bundle path - find first .app and include it parts = executable_path.split('.app/Contents/') if len(parts) >= 2: app_path = parts[0] + '.app' log.debug(f"Detected .app bundle path: {app_path}") if os.path.exists(app_path): bundle_url = NSURL.fileURLWithPath_(app_path) bundle = NSBundle.bundleWithURL_(bundle_url) log.debug(f"Loaded bundle from: {app_path}") else: log.warning(f"App path does not exist: {app_path}") # Fallback to main bundle if not bundle: bundle = NSBundle.mainBundle() log.debug(f"Using main bundle: {bundle.bundlePath()}") info = bundle.infoDictionary() if info: has_bluetooth_key = "NSBluetoothAlwaysUsageDescription" in info log.debug(f"Bundle has Info.plist: YES, has Bluetooth key: {has_bluetooth_key}") if has_bluetooth_key: log.debug(f"Bluetooth description: {info['NSBluetoothAlwaysUsageDescription']}") else: log.warning("Bundle has no Info.plist") # Check if NSBluetoothAlwaysUsageDescription exists in Info.plist if info and "NSBluetoothAlwaysUsageDescription" in info: log.info("Info.plist contains Bluetooth permissions, initializing CoreBluetooth") self._central_manager = CBCentralManager.alloc().initWithDelegate_queue_(self, None) log.info("CoreBluetooth initialized successfully") else: log.warning("No Info.plist with NSBluetoothAlwaysUsageDescription found") log.warning("BLE disabled - run from .app bundle for Bluetooth support") log.info("USB and TCP connectivity still available") # Don't initialize - would cause TCC crash except Exception as e: log.error(f"Failed to check for Info.plist: {e}", exc_info=True) log.warning("BLE disabled to prevent TCC crash") return False # Don't repeat # ─── Properties (BleTransportBase interface) ─────────────────── @property def state(self) -> ConnectionState: return self._state @property def is_connected(self) -> bool: return self._state == ConnectionState.CONNECTED @property def connected_address(self) -> str | None: return self._device_address @property def discovered_devices(self) -> list[BleDevice]: return list(self._discovered_devices.values()) # ─── BLE Operations (BleTransportBase interface) ─────────────── def start_scan(self): """Start scanning for BLE devices.""" if not COREBLUETOOTH_AVAILABLE: log.error("CoreBluetooth not available") return if not self.is_bluetooth_enabled(): log.error("Bluetooth is not enabled") return log.info("Starting BLE scan...") self._set_state(ConnectionState.SCANNING) self._discovered_devices.clear() if self._central_manager: # Scan for devices advertising the NUS service service_uuids = [CBUUID.UUIDWithString_(NUS_SERVICE_UUID)] self._central_manager.scanForPeripheralsWithServices_options_(service_uuids, None) def stop_scan(self): """Stop scanning for BLE devices.""" if self._central_manager: self._central_manager.stopScan() self._set_state(ConnectionState.DISCONNECTED) def connect_device(self, device_path: str): """Connect to a device by its path/identifier. device_path is the peripheral UUID string. """ if not COREBLUETOOTH_AVAILABLE: log.error("CoreBluetooth not available") return # Find the peripheral in discovered devices peripheral = None for device in self._discovered_devices.values(): if device.path == device_path: peripheral = device._peripheral # Internal reference break # If not found in discovered devices, try to retrieve by UUID # (this handles paired devices that haven't been scanned this session) if not peripheral: log.info(f"Device not in discovered list, retrieving by UUID: {device_path}") try: from Foundation import NSUUID uuid = NSUUID.alloc().initWithUUIDString_(device_path) if uuid and self._central_manager: peripherals = self._central_manager.retrievePeripheralsWithIdentifiers_([uuid]) if peripherals and len(peripherals) > 0: peripheral = peripherals[0] log.info(f"Retrieved peripheral: {peripheral.name()}") else: log.error(f"Could not retrieve peripheral with UUID: {device_path}") return except Exception as e: log.error(f"Failed to retrieve peripheral: {e}") return if not peripheral: log.error(f"Device {device_path} not found") return log.info(f"Connecting to device {device_path}...") self._set_state(ConnectionState.CONNECTING) self._device_address = device_path if self._central_manager: self._central_manager.connectPeripheral_options_(peripheral, None) # Start connection timeout from gi.repository import GLib self._cancel_connection_timeout() # Cancel any existing timeout self._connection_timeout_id = GLib.timeout_add_seconds( _BLE_CONNECTION_TIMEOUT_S, self._on_connection_timeout ) log.info(f"Connection timeout scheduled ({_BLE_CONNECTION_TIMEOUT_S} seconds)") def connect_by_address(self, address: str): """Connect to a device by its MAC address. On macOS, we use UUID instead of MAC address. This attempts to retrieve a known peripheral by UUID. """ if not COREBLUETOOTH_AVAILABLE: log.error("CoreBluetooth not available") return if not self._central_manager: log.warning("CoreBluetooth manager not initialized yet - cannot connect by address") return try: from Foundation import NSUUID uuid = NSUUID.alloc().initWithUUIDString_(address) if not uuid: log.error(f"Invalid UUID: {address}") return # Retrieve peripheral by identifier peripherals = self._central_manager.retrievePeripheralsWithIdentifiers_([uuid]) if not peripherals or len(peripherals) == 0: log.error(f"No peripheral found with UUID {address}") return peripheral = peripherals[0] log.info(f"Connecting to peripheral {peripheral.name()}...") self._set_state(ConnectionState.CONNECTING) self._device_address = address self._central_manager.connectPeripheral_options_(peripheral, None) # Start connection timeout from gi.repository import GLib self._cancel_connection_timeout() # Cancel any existing timeout self._connection_timeout_id = GLib.timeout_add_seconds( _BLE_CONNECTION_TIMEOUT_S, self._on_connection_timeout ) log.info(f"Connection timeout scheduled ({_BLE_CONNECTION_TIMEOUT_S} seconds)") except Exception as e: log.error(f"Failed to connect by address: {e}") def pair_device(self, device_path: str): """Pair with a device (handled automatically by macOS).""" # On macOS, pairing is handled automatically by the system # Just connect to the device self.connect_device(device_path) def disconnect(self): """Disconnect from the current device.""" self._cancel_connection_timeout() # Cancel any pending timeout if self._connected_peripheral and self._central_manager: self._central_manager.cancelPeripheralConnection_(self._connected_peripheral) self._set_state(ConnectionState.DISCONNECTED) def _cancel_connection_timeout(self): """Cancel any pending connection timeout.""" if self._connection_timeout_id is not None: from gi.repository import GLib GLib.source_remove(self._connection_timeout_id) self._connection_timeout_id = None log.debug("Connection timeout cancelled") def _on_connection_timeout(self): """Handle connection timeout.""" if self._state == ConnectionState.CONNECTING: log.error(f"BLE connection timeout after {_BLE_CONNECTION_TIMEOUT_S} seconds") # Emit error to UI if self.on_state_changed: from gi.repository import GLib GLib.idle_add(self._emit_error, "Connection timeout - device not responding") # Force disconnect self.disconnect() self._connection_timeout_id = None return False # Don't repeat timeout def _emit_error(self, message: str): """Emit error message via state changed callback.""" # This is a workaround since BleTransportBase has on_error callback # but we're duck-typing the interface without inheriting log.error(f"Emitting error: {message}") if self.on_state_changed: self.on_state_changed(ConnectionState.DISCONNECTED) return False def send_data(self, data: bytes): """Send data to the connected device.""" if not self.is_connected or not self._rx_characteristic: log.warning("Cannot send data: not connected") return if not self._connected_peripheral: log.warning("No connected peripheral") return # Convert bytes to NSData from Foundation import NSData ns_data = NSData.dataWithBytes_length_(data, len(data)) # Write to RX characteristic (app -> device) self._connected_peripheral.writeValue_forCharacteristic_type_( ns_data, self._rx_characteristic, CBCharacteristicWriteWithResponse ) log.debug(f"Sent {len(data)} bytes to device") def is_bluetooth_enabled(self) -> bool: """Check if Bluetooth is enabled.""" if not self._central_manager: return False state = self._central_manager.state() return state == CBManagerStatePoweredOn def has_bluetooth_adapter(self) -> bool: """Check if a Bluetooth adapter is available.""" if not self._central_manager: return False state = self._central_manager.state() return state != CBManagerStateUnsupported and state != CBManagerStateUnknown def get_paired_devices(self) -> list[BleDevice]: """Get list of paired devices that support NUS. Retrieves devices that have been successfully paired by checking: 1. Saved device UUIDs from GSettings 2. Currently connected peripherals with NUS service """ if not self._central_manager: return [] devices = [] # Get saved paired device UUIDs from GSettings try: from gi.repository import Gio settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") saved_uuids = settings.get_value("ble-paired-devices") if saved_uuids and len(saved_uuids) > 0: from Foundation import NSUUID # Convert string UUIDs to NSUUID objects uuid_objects = [] for uuid_str in saved_uuids: uuid_obj = NSUUID.alloc().initWithUUIDString_(uuid_str) if uuid_obj: uuid_objects.append(uuid_obj) # Retrieve peripherals by their identifiers if uuid_objects: peripherals = self._central_manager.retrievePeripheralsWithIdentifiers_(uuid_objects) for peripheral in peripherals: uuid = peripheral.identifier().UUIDString() name = peripheral.name() or "Unknown Device" device = BleDevice( path=uuid, address=uuid, name=name ) device._peripheral = peripheral devices.append(device) except Exception as e: log.warning(f"Failed to retrieve saved paired devices: {e}") # Also check for currently connected peripherals with NUS service try: service_uuids = [CBUUID.UUIDWithString_(NUS_SERVICE_UUID)] connected = self._central_manager.retrieveConnectedPeripheralsWithServices_(service_uuids) # Add connected devices if not already in list existing_uuids = {d.path for d in devices} for peripheral in connected: uuid = peripheral.identifier().UUIDString() if uuid not in existing_uuids: name = peripheral.name() or "Unknown Device" device = BleDevice( path=uuid, address=uuid, name=name ) device._peripheral = peripheral devices.append(device) except Exception as e: log.warning(f"Failed to retrieve connected peripherals: {e}") return devices def remove_device(self, device_path: str) -> bool: """Remove/unpair a device (managed by system on macOS).""" # macOS manages pairing at the system level # We can only disconnect, not unpair log.info("Device pairing is managed by macOS System Settings") return False # ─── CoreBluetooth Delegate Methods ──────────────────────────── def centralManagerDidUpdateState_(self, central): """Called when the central manager state changes.""" state = central.state() log.info(f"CoreBluetooth state changed: {state}") if state == CBManagerStatePoweredOn: log.info("Bluetooth is powered on and ready") if self._state == ConnectionState.SCANNING: # Resume scanning if we were scanning before pass elif state == CBManagerStatePoweredOff: log.warning("Bluetooth is powered off") elif state == CBManagerStateUnauthorized: log.error("Bluetooth access not authorized") def centralManager_didDiscoverPeripheral_advertisementData_RSSI_( self, central, peripheral, advertisement_data, rssi ): """Called when a peripheral is discovered during scanning.""" # Get peripheral UUID as identifier uuid = peripheral.identifier().UUIDString() name = peripheral.name() or "Unknown" log.info(f"Discovered peripheral: {name} ({uuid}), RSSI: {rssi}") # Create BleDevice device = BleDevice( path=uuid, address=uuid, # macOS uses UUID, not MAC address name=name ) # Store reference to peripheral for connection device._peripheral = peripheral self._discovered_devices[uuid] = device # Notify callback if self.on_device_discovered: from gi.repository import GLib GLib.idle_add(lambda: self.on_device_discovered(device) if self.on_device_discovered else None) def centralManager_didConnectPeripheral_(self, central, peripheral): """Called when a peripheral is successfully connected.""" self._cancel_connection_timeout() # Cancel timeout on successful connection log.info(f"Connected to peripheral: {peripheral.name()}") self._connected_peripheral = peripheral peripheral.setDelegate_(self) # Discover NUS service service_uuids = [CBUUID.UUIDWithString_(NUS_SERVICE_UUID)] peripheral.discoverServices_(service_uuids) def centralManager_didFailToConnectPeripheral_error_(self, central, peripheral, error): """Called when connection to a peripheral fails.""" self._cancel_connection_timeout() # Cancel timeout on connection failure log.error(f"Failed to connect to peripheral: {error}") self._set_state(ConnectionState.DISCONNECTED) def centralManager_didDisconnectPeripheral_error_(self, central, peripheral, error): """Called when a peripheral disconnects.""" log.info(f"Peripheral disconnected: {error if error else 'user initiated'}") self._connected_peripheral = None self._tx_characteristic = None self._rx_characteristic = None self._set_state(ConnectionState.DISCONNECTED) # ─── CBPeripheralDelegate Methods ────────────────────────────── def peripheral_didDiscoverServices_(self, peripheral, error): """Called when services are discovered.""" if error: log.error(f"Service discovery error: {error}") self._set_state(ConnectionState.DISCONNECTED) return # Find the NUS service nus_uuid = CBUUID.UUIDWithString_(NUS_SERVICE_UUID) nus_service = None for service in peripheral.services(): if service.UUID().isEqual_(nus_uuid): nus_service = service break if not nus_service: log.error("NUS service not found on peripheral") self._set_state(ConnectionState.DISCONNECTED) self._central_manager.cancelPeripheralConnection_(peripheral) return log.info("NUS service found, discovering characteristics...") # Discover TX and RX characteristics char_uuids = [ CBUUID.UUIDWithString_(NUS_TX_CHAR_UUID), CBUUID.UUIDWithString_(NUS_RX_CHAR_UUID) ] peripheral.discoverCharacteristics_forService_(char_uuids, nus_service) def peripheral_didDiscoverCharacteristicsForService_error_(self, peripheral, service, error): """Called when characteristics are discovered.""" if error: log.error(f"Characteristic discovery error: {error}") self._set_state(ConnectionState.DISCONNECTED) return tx_uuid = CBUUID.UUIDWithString_(NUS_TX_CHAR_UUID) rx_uuid = CBUUID.UUIDWithString_(NUS_RX_CHAR_UUID) for characteristic in service.characteristics(): if characteristic.UUID().isEqual_(tx_uuid): self._tx_characteristic = characteristic # Enable notifications for TX (device -> app) peripheral.setNotifyValue_forCharacteristic_(True, characteristic) log.info("Enabled notifications for TX characteristic") elif characteristic.UUID().isEqual_(rx_uuid): self._rx_characteristic = characteristic log.info("Found RX characteristic") # Check if we have both characteristics if self._tx_characteristic and self._rx_characteristic: log.info("NUS service ready, connection established") # Save this device as paired self._save_paired_device(peripheral) self._set_state(ConnectionState.CONNECTED) else: log.error("Failed to find required NUS characteristics") self._set_state(ConnectionState.DISCONNECTED) self._central_manager.cancelPeripheralConnection_(peripheral) def peripheral_didUpdateValueForCharacteristic_error_(self, peripheral, characteristic, error): """Called when a characteristic value is updated (notifications).""" if error: log.error(f"Characteristic update error: {error}") return # Check if this is the TX characteristic tx_uuid = CBUUID.UUIDWithString_(NUS_TX_CHAR_UUID) if not characteristic.UUID().isEqual_(tx_uuid): return # Read data from characteristic ns_data = characteristic.value() if not ns_data: return # Convert NSData to bytes data = bytes(ns_data.bytes()[:ns_data.length()]) log.debug(f"Received {len(data)} bytes from device") # Notify callback on main thread if self.on_data_received: from gi.repository import GLib GLib.idle_add(lambda: self.on_data_received(data) if self.on_data_received else None) # ─── Helper Methods ───────────────────────────────────────────── def _set_state(self, state: ConnectionState): """Update connection state and notify callback.""" if self._state != state: old = self._state self._state = state log.info(f"BLE state: {old.name} -> {state.name}") if self.on_state_changed: # Call on main thread via GLib from gi.repository import GLib GLib.idle_add(lambda: self.on_state_changed(state) if self.on_state_changed else None) def _save_paired_device(self, peripheral): """Save a successfully paired device UUID to persistent storage.""" try: from gi.repository import Gio, GLib uuid = peripheral.identifier().UUIDString() settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") # Get existing paired devices saved_uuids = list(settings.get_value("ble-paired-devices")) # Add this device if not already saved if uuid not in saved_uuids: saved_uuids.append(uuid) settings.set_value("ble-paired-devices", GLib.Variant('as', saved_uuids)) log.info(f"Saved paired device: {uuid}") else: log.debug(f"Device already in paired list: {uuid}") except Exception as e: log.error(f"Failed to save paired device: {e}") meshy/src/platforms/macos/camera_avfoundation.py000066400000000000000000000556361523412555500225100ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """AVFoundation-based QR code scanner for macOS.""" import logging try: import objc from AVFoundation import ( AVAuthorizationStatusAuthorized, AVAuthorizationStatusNotDetermined, AVCaptureDevice, AVCaptureDeviceInput, AVCaptureSession, AVCaptureVideoDataOutput, AVMediaTypeVideo, ) from Foundation import NSArray, NSObject from libdispatch import DISPATCH_QUEUE_SERIAL, dispatch_queue_create from Vision import ( VNBarcodeSymbologyQR, VNDetectBarcodesRequest, VNImageRequestHandler, ) AVFOUNDATION_AVAILABLE = True except ImportError: AVFOUNDATION_AVAILABLE = False NSObject = object log = logging.getLogger(__name__) # Camera and QR scanner constants _CAMERA_TARGET_FPS = 30 _CAMERA_FRAME_INTERVAL_S = 1.0 / _CAMERA_TARGET_FPS _QR_SCAN_HINT_DELAY_MS = 8000 _QR_SCAN_TIMEOUT_MS = 30000 _QR_PREVIEW_MAX_SIZE = 480 # Define GObject implementation at module level to avoid GType re-registration from gi.repository import Gdk, GObject class _CameraPaintableImpl(GObject.Object, Gdk.Paintable): """GObject that implements Gdk.Paintable interface for camera frames.""" __gtype_name__ = "CameraPaintable" def __init__(self, picture_widget): super().__init__() self._picture_widget = picture_widget # Store reference to Picture widget self._pixel_data = None # Raw RGBA pixel data (bytes) self._width = 640 self._height = 480 self._stride = 0 self._last_update_time = 0 self._min_frame_interval = _CAMERA_FRAME_INTERVAL_S self._frame_count = 0 # Cache CIContext for rendering from Quartz import CIContext self._ci_context = CIContext.contextWithOptions_(None) def update_frame(self, pixel_buffer): """Update paintable with new frame from AVFoundation. Args: pixel_buffer: CVPixelBuffer from camera """ import time # Check if we should stop processing (dialog closing) if hasattr(self, "_stopped") and self._stopped: return self._frame_count += 1 # Throttle updates - check FIRST before doing any work current_time = time.time() if current_time - self._last_update_time < self._min_frame_interval: return self._last_update_time = current_time try: # Convert CVPixelBuffer directly to pixel data (no texture creation) pixel_data = self._pixel_buffer_to_rgba(pixel_buffer) if pixel_data: self._pixel_data = pixel_data # Trigger redraw self.emit("invalidate-contents") # Also queue draw on Picture widget if self._picture_widget and not (hasattr(self, "_stopped") and self._stopped): try: self._picture_widget.queue_draw() except Exception: # Widget may be destroyed pass except Exception as e: log.error(f"Failed to update frame: {e}", exc_info=True) def stop(self): """Stop processing frames.""" self._stopped = True self._picture_widget = None def _pixel_buffer_to_rgba(self, pixel_buffer): """Convert CVPixelBuffer to raw BGRA pixel data for Cairo. Returns dict with pixel data info or None on error. """ from Quartz import CIImage, kCIFormatBGRA8 from Quartz.CoreGraphics import CGPoint, CGRect, CGSize from Quartz.CoreVideo import CVPixelBufferGetHeight, CVPixelBufferGetWidth try: # Get dimensions width = CVPixelBufferGetWidth(pixel_buffer) height = CVPixelBufferGetHeight(pixel_buffer) if width <= 0 or height <= 0: return None # Reduce size for performance max_size = _QR_PREVIEW_MAX_SIZE if width > max_size or height > max_size: scale = max_size / max(width, height) width = int(width * scale) height = int(height * scale) # Calculate stride (4-byte aligned) stride = width * 4 buffer_size = stride * height # Reuse pixel data buffer if same size if not hasattr(self, "_pixel_buffer") or len(self._pixel_buffer) != buffer_size: self._pixel_buffer = bytearray(buffer_size) # Create CIImage from pixel buffer ci_image = CIImage.imageWithCVPixelBuffer_(pixel_buffer) # Render to pixel buffer using cached CIContext # Use BGRA8 format to match Cairo's ARGB32 byte order render_rect = CGRect(CGPoint(0, 0), CGSize(width, height)) self._ci_context.render_toBitmap_rowBytes_bounds_format_colorSpace_( ci_image, self._pixel_buffer, stride, render_rect, kCIFormatBGRA8, None ) # Store dimensions self._width = width self._height = height self._stride = stride return { "data": bytes(self._pixel_buffer), "width": width, "height": height, "stride": stride, } except Exception as e: log.error(f"Error converting pixel buffer: {e}", exc_info=True) return None # GdkPaintable interface implementation def do_snapshot(self, snapshot, width, height): """Render the paintable using Cairo.""" if not self._pixel_data: return try: import cairo from gi.repository import Graphene # Create Cairo ImageSurface from pixel data # Use ARGB32 format which matches BGRA8 byte order image_surface = cairo.ImageSurface.create_for_data( bytearray(self._pixel_data["data"]), cairo.FORMAT_ARGB32, self._pixel_data["width"], self._pixel_data["height"], self._pixel_data["stride"], ) # Get Cairo context from snapshot cr = snapshot.append_cairo(Graphene.Rect().init(0, 0, width, height)) # Scale to fit the snapshot area scale_x = width / self._pixel_data["width"] scale_y = height / self._pixel_data["height"] cr.scale(scale_x, scale_y) # Draw the image cr.set_source_surface(image_surface, 0, 0) cr.paint() except Exception as e: log.error(f"Error in do_snapshot: {e}", exc_info=True) def do_get_intrinsic_width(self): """Return natural width.""" return self._width def do_get_intrinsic_height(self): """Return natural height.""" return self._height def do_get_flags(self): """Return paintable flags.""" from gi.repository import Gdk return Gdk.PaintableFlags.CONTENTS # Contents change over time class CameraPaintable: """Custom GdkPaintable for AVFoundation camera frames. This class implements the GdkPaintable interface to display live camera frames in a Gtk.Picture widget. It mimics gtk4paintablesink behavior from Linux/GStreamer. """ def __init__(self, picture_widget=None): """Initialize the paintable. Args: picture_widget: Optional Gtk.Picture to trigger redraws on """ # Create instance of the GObject implementation self._impl = _CameraPaintableImpl(picture_widget) def update_frame(self, ci_image): """Update paintable with new frame.""" self._impl.update_frame(ci_image) def get_paintable(self): """Get the GdkPaintable object.""" return self._impl def set_picture_widget(self, picture_widget): """Set the Picture widget for triggering redraws.""" self._impl._picture_widget = picture_widget def stop(self): """Stop processing frames.""" self._impl.stop() class QRScannerDelegate(NSObject): """Delegate for processing camera frames and detecting QR codes.""" def init(self): """Initialize the delegate.""" self = objc.super(QRScannerDelegate, self).init() if self is None: return None self._on_result = None self._session = None self._paintable = None self._last_preview_time = 0 self._min_preview_interval = _CAMERA_FRAME_INTERVAL_S return self def setCallback_(self, callback): """Set the callback to call when a QR code is detected.""" self._on_result = callback def setSession_(self, session): """Set the capture session to stop when QR code is found.""" self._session = session def setPaintable_(self, paintable): """Set the paintable to update with camera frames.""" self._paintable = paintable def captureOutput_didOutputSampleBuffer_fromConnection_( self, output, sample_buffer, connection ): """Process each camera frame to detect QR codes.""" if self._on_result is None: return try: from CoreMedia import CMSampleBufferGetImageBuffer from Quartz import CIImage from Quartz.CoreVideo import ( CVPixelBufferLockBaseAddress, CVPixelBufferUnlockBaseAddress, ) # Get pixel buffer from sample buffer pixel_buffer = CMSampleBufferGetImageBuffer(sample_buffer) if pixel_buffer is None: return # Lock the base address CVPixelBufferLockBaseAddress(pixel_buffer, 0) try: # Process paintable update WHILE buffer is locked (before unlock) # This prevents race condition where buffer gets unlocked before processing if self._paintable: # Throttle preview updates to ~30 fps import time current_time = time.time() if current_time - self._last_preview_time >= self._min_preview_interval: self._last_preview_time = current_time # Process immediately on this thread while buffer is valid pixel_data = self._paintable._impl._pixel_buffer_to_rgba(pixel_buffer) if pixel_data: # Schedule UI update on main thread with already-converted data from gi.repository import GLib data = pixel_data # Capture in closure def update_paintable(): if self._paintable and self._paintable._impl: self._paintable._impl._pixel_data = data self._paintable._impl._width = data["width"] self._paintable._impl._height = data["height"] self._paintable._impl._stride = data["stride"] self._paintable._impl.emit("invalidate-contents") if self._paintable._impl._picture_widget: self._paintable._impl._picture_widget.queue_draw() return False GLib.idle_add(update_paintable) # Create CIImage for QR detection ci_image = CIImage.imageWithCVPixelBuffer_(pixel_buffer) # Create Vision request handler handler = VNImageRequestHandler.alloc().initWithCIImage_options_(ci_image, None) # Create barcode detection request request = VNDetectBarcodesRequest.alloc().init() request.setSymbologies_(NSArray.arrayWithObject_(VNBarcodeSymbologyQR)) # Perform the request success, error = handler.performRequests_error_( NSArray.arrayWithObject_(request), None ) if success: # Get results results = request.results() if results and len(results) > 0: # Get the first QR code found barcode = results[0] qr_data = barcode.payloadStringValue() if qr_data: log.info(f"QR code detected: {qr_data[:50]}...") # Stop the session if self._session: self._session.stopRunning() # Call the callback on main thread if self._on_result: from gi.repository import GLib # Capture the callback before clearing callback = self._on_result self._on_result = None # Clear immediately to prevent double-call def callback_wrapper(): try: return callback(qr_data) or False except Exception as e: log.error(f"QR callback failed: {e}", exc_info=True) return False GLib.idle_add(callback_wrapper) finally: # Unlock the base address CVPixelBufferUnlockBaseAddress(pixel_buffer, 0) except Exception as e: log.error(f"Error processing camera frame: {e}", exc_info=True) class QRScannerDialogMacOS: """QR code scanner dialog for macOS using AVFoundation.""" def __init__(self, window, on_result): """Initialize the QR scanner dialog. Args: window: Parent GTK window on_result: Callback function(data: str) called with QR code data """ self._window = window self._on_result = on_result self._session = None self._dialog = None self._delegate = None self._paintable = None # CameraPaintable instance self._preview_image = None self._status_label = None self._scan_active = False self._hint_timer_id = None self._timeout_timer_id = None def start(self): """Start the QR scanner with camera preview.""" if not AVFOUNDATION_AVAILABLE: log.error("AVFoundation not available - QR scanner disabled") self._window.show_toast("Camera not available. AVFoundation frameworks not installed.") return # Check camera authorization auth_status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeVideo) if auth_status == AVAuthorizationStatusNotDetermined: # Request permission log.info("Requesting camera permission...") def completion_handler(granted): if granted: log.info("Camera permission granted") from gi.repository import GLib GLib.idle_add(self._setup_camera) else: log.warning("Camera permission denied") from gi.repository import GLib GLib.idle_add( lambda: self._window.show_toast( "Camera permission denied. Enable in System Settings > Privacy & Security > Camera" ) ) AVCaptureDevice.requestAccessForMediaType_completionHandler_( AVMediaTypeVideo, completion_handler ) return elif auth_status == AVAuthorizationStatusAuthorized: self._setup_camera() else: log.warning("Camera access not authorized") self._window.show_toast( "Camera access denied. Enable in System Settings > Privacy & Security > Camera" ) def _setup_camera(self): """Set up the camera capture session.""" try: from gi.repository import Adw, Gtk # Create capture session self._session = AVCaptureSession.alloc().init() # Get default video device device = AVCaptureDevice.defaultDeviceWithMediaType_(AVMediaTypeVideo) if not device: log.error("No camera device found") self._window.show_toast("No camera found") return # Create device input device_input, error = AVCaptureDeviceInput.deviceInputWithDevice_error_(device, None) if not device_input or error: log.error(f"Failed to create device input: {error}") self._window.show_toast("Failed to access camera") return # Add input to session if self._session.canAddInput_(device_input): self._session.addInput_(device_input) else: log.error("Cannot add video input to session") self._window.show_toast("Failed to configure camera") return # Create video data output video_output = AVCaptureVideoDataOutput.alloc().init() # Note: We don't set videoSettings - AVFoundation will use a default compatible format # The Vision framework can handle various pixel formats for QR detection # Create custom paintable for camera preview self._paintable = CameraPaintable() # Create and set delegate self._delegate = QRScannerDelegate.alloc().init() self._delegate.setCallback_(self._on_qr_detected) self._delegate.setSession_(self._session) self._delegate.setPaintable_(self._paintable) # Create serial dispatch queue for frame processing queue = dispatch_queue_create(b"qr_scanner_queue", DISPATCH_QUEUE_SERIAL) video_output.setSampleBufferDelegate_queue_(self._delegate, queue) # Add output to session if self._session.canAddOutput_(video_output): self._session.addOutput_(video_output) else: log.error("Cannot add video output to session") self._window.show_toast("Failed to configure camera") return # Create dialog matching Linux UI self._dialog = Adw.Dialog() self._dialog.set_title("Scan QR Code") self._dialog.set_content_width(400) self._dialog.set_content_height(400) toolbar_view = Adw.ToolbarView() header = Adw.HeaderBar() toolbar_view.add_top_bar(header) content = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=8, margin_start=8, margin_end=8, margin_bottom=8, ) # Video preview (matching Linux dimensions) self._preview_image = Gtk.Picture() paintable_obj = self._paintable.get_paintable() self._preview_image.set_paintable(paintable_obj) # Tell paintable about Picture widget for queue_draw self._paintable.set_picture_widget(self._preview_image) self._preview_image.set_size_request(360, 270) self._preview_image.set_vexpand(True) self._preview_image.set_content_fit(Gtk.ContentFit.CONTAIN) content.append(self._preview_image) # Status label self._status_label = Gtk.Label(label="Point the camera at a QR code...") self._status_label.add_css_class("dim-label") content.append(self._status_label) toolbar_view.set_content(content) self._dialog.set_child(toolbar_view) # Handle dialog close self._dialog.connect("closed", lambda d: self._cleanup()) # Start camera capture session self._session.startRunning() self._scan_active = True # Set up hint and timeout timers (matching Linux) from gi.repository import GLib self._hint_timer_id = GLib.timeout_add(_QR_SCAN_HINT_DELAY_MS, self._on_scan_hint) self._timeout_timer_id = GLib.timeout_add(_QR_SCAN_TIMEOUT_MS, self._on_scan_timeout) # Show dialog self._dialog.present(self._window) except Exception as e: log.error(f"Failed to set up camera: {e}", exc_info=True) self._window.show_toast(f"Camera error: {e}") self._cleanup() def _on_qr_detected(self, qr_data): """Called when a QR code is detected. Args: qr_data: The QR code data string """ log.info(f"QR code scanned: {qr_data[:50]}...") self._scan_active = False # Stop paintable to prevent race conditions if self._paintable: self._paintable.stop() if self._status_label: self._status_label.set_label("QR code found!") # Close dialog if self._dialog: self._dialog.close() # Call the original callback if self._on_result: self._on_result(qr_data) # Cleanup self._cleanup() def _on_scan_hint(self): """Show a hint after 8 seconds of no detection.""" self._hint_timer_id = None if self._scan_active and self._status_label: self._status_label.set_label( "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." ) return False def _on_scan_timeout(self): """Show a give-up message after 30 seconds.""" self._timeout_timer_id = None if self._scan_active and self._status_label: self._status_label.set_label( "Could not detect QR code.\n" "Try importing the contact via clipboard instead." ) return False def _cleanup(self): """Clean up camera resources.""" self._scan_active = False # Stop paintable to prevent frame processing if self._paintable: self._paintable.stop() # Remove timers if self._hint_timer_id: from gi.repository import GLib GLib.source_remove(self._hint_timer_id) self._hint_timer_id = None if self._timeout_timer_id: from gi.repository import GLib GLib.source_remove(self._timeout_timer_id) self._timeout_timer_id = None # Stop camera session if self._session and self._session.isRunning(): self._session.stopRunning() self._session = None self._delegate = None self._paintable = None self._dialog = None self._preview_image = None self._status_label = None meshy/src/platforms/macos/location_corelocation.py000066400000000000000000000125051523412555500230400ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """CoreLocation-based location service for macOS.""" import logging log = logging.getLogger(__name__) try: from Foundation import NSObject from CoreLocation import ( CLLocationManager, kCLAuthorizationStatusAuthorizedAlways, kCLAuthorizationStatusAuthorizedWhenInUse, kCLAuthorizationStatusDenied, kCLAuthorizationStatusNotDetermined, kCLAuthorizationStatusRestricted, ) CORELOCATION_AVAILABLE = True except ImportError: log.warning("CoreLocation not available") CORELOCATION_AVAILABLE = False NSObject = object class LocationDelegate(NSObject): """Delegate for CoreLocation manager.""" def init(self): """Initialize the delegate.""" from objc import super as objc_super self = objc_super(LocationDelegate, self).init() if self is None: return None self._callback = None self._manager = None return self def setCallback_(self, callback): """Set the callback to call with location results.""" self._callback = callback def setManager_(self, manager): """Set the location manager.""" self._manager = manager def locationManager_didUpdateLocations_(self, manager, locations): """Called when location is updated.""" if not locations or not self._callback: return location = locations[-1] # Most recent location coordinate = location.coordinate() log.info(f"Location updated: {coordinate.latitude}, {coordinate.longitude}") # Call callback on main thread from gi.repository import GLib GLib.idle_add( lambda: self._callback(coordinate.latitude, coordinate.longitude, None) if self._callback else None ) # Stop updating after getting one location if self._manager: self._manager.stopUpdatingLocation() def locationManager_didFailWithError_(self, manager, error): """Called when location update fails.""" log.error(f"Location error: {error}") # Call callback with error on main thread from gi.repository import GLib GLib.idle_add( lambda: self._callback(None, None, str(error)) if self._callback else None ) def locationManager_didChangeAuthorizationStatus_(self, manager, status): """Called when authorization status changes.""" if status == kCLAuthorizationStatusAuthorizedWhenInUse: log.info("Location permission granted (when in use)") manager.requestLocation() elif status == kCLAuthorizationStatusAuthorizedAlways: log.info("Location permission granted (always)") manager.requestLocation() elif status == kCLAuthorizationStatusDenied: log.warning("Location permission denied") if self._callback: from gi.repository import GLib GLib.idle_add( lambda: self._callback(None, None, "Location permission denied") ) elif status == kCLAuthorizationStatusRestricted: log.warning("Location permission restricted") if self._callback: from gi.repository import GLib GLib.idle_add( lambda: self._callback(None, None, "Location permission restricted") ) def get_location_macos(callback): """Get current location using CoreLocation. Args: callback: Function to call with (latitude, longitude, error). If successful, error is None. If failed, lat/lon are None. """ if not CORELOCATION_AVAILABLE: from gi.repository import GLib GLib.idle_add(lambda: callback(None, None, "CoreLocation not available")) return try: # Create location manager and delegate on main thread from gi.repository import GLib def _init_location(): manager = CLLocationManager.alloc().init() delegate = LocationDelegate.alloc().init() delegate.setCallback_(callback) delegate.setManager_(manager) manager.setDelegate_(delegate) # Request permission auth_status = CLLocationManager.authorizationStatus() if auth_status == kCLAuthorizationStatusNotDetermined: log.info("Requesting location permission") manager.requestWhenInUseAuthorization() elif auth_status in (kCLAuthorizationStatusAuthorizedWhenInUse, kCLAuthorizationStatusAuthorizedAlways): log.info("Location already authorized, requesting location") manager.requestLocation() elif auth_status == kCLAuthorizationStatusDenied: callback(None, None, "Location permission denied") elif auth_status == kCLAuthorizationStatusRestricted: callback(None, None, "Location permission restricted") return False # Don't repeat GLib.idle_add(_init_location) except Exception as e: log.error(f"Failed to get location: {e}") from gi.repository import GLib GLib.idle_add(lambda: callback(None, None, str(e))) meshy/src/platforms/macos/login_items.py000066400000000000000000000046601523412555500210030ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Login Items integration for macOS autostart.""" import logging log = logging.getLogger(__name__) try: from Foundation import NSURL from ServiceManagement import ( SMAppService, ) SERVICEMANAGEMENT_AVAILABLE = True except ImportError: log.warning("ServiceManagement framework not available") SERVICEMANAGEMENT_AVAILABLE = False def set_autostart_macos(enabled: bool) -> bool: """Enable or disable autostart on macOS using Login Items. Args: enabled: True to enable autostart, False to disable Returns: True if successful, False otherwise Note: This requires macOS 13+ for SMAppService API. For older macOS versions, consider using LSSharedFileList (deprecated). """ if not SERVICEMANAGEMENT_AVAILABLE: log.warning("ServiceManagement not available - autostart not supported") return False try: # Get the main app service service = SMAppService.mainAppService() if enabled: # Register the app to launch at login error = service.registerAndReturnError_(None) if error[1]: log.error(f"Failed to enable autostart: {error[1]}") return False log.info("Autostart enabled successfully") return True else: # Unregister from login items error = service.unregisterAndReturnError_(None) if error[1]: log.error(f"Failed to disable autostart: {error[1]}") return False log.info("Autostart disabled successfully") return True except Exception as e: log.error(f"Failed to set autostart: {e}") return False def is_autostart_enabled_macos() -> bool: """Check if autostart is currently enabled on macOS. Returns: True if autostart is enabled, False otherwise """ if not SERVICEMANAGEMENT_AVAILABLE: return False try: service = SMAppService.mainAppService() status = service.status() # SMAppService status values: # 0 = notRegistered # 1 = enabled # 2 = requiresApproval # 3 = notFound return status == 1 # enabled except Exception as e: log.error(f"Failed to check autostart status: {e}") return False meshy/src/platforms/macos/notification_native.py000066400000000000000000000124111523412555500225170ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Native macOS notification service using UserNotifications framework. IMPORTANT: This implementation requires proper code signing with an Apple Developer ID certificate. Ad-hoc signed builds will fail with: "Notifications are not allowed for this application" (UNErrorDomain Code=1) For development/unsigned builds, use GioNotificationService instead. This will work once the app is properly signed and notarized for distribution. """ import logging try: from Foundation import NSBundle from objc import nil import UserNotifications as UN OBJC_AVAILABLE = True except ImportError: OBJC_AVAILABLE = False from ..notification_base import NotificationService log = logging.getLogger(__name__) class MacOSNotificationService(NotificationService): """Native macOS notification service using UNUserNotificationCenter. Uses the modern UserNotifications framework (macOS 10.14+) which properly integrates with macOS Notification Center, respecting user preferences and Focus modes. **IMPORTANT:** Requires proper code signing with Developer ID. Will fail with "Notifications are not allowed" on unsigned builds. """ def __init__(self, application): """Initialize macOS notification service. Args: application: GTK application instance (unused but kept for interface compatibility) """ self._app = application self._center = None self._authorized = False if not OBJC_AVAILABLE: log.warning("PyObjC UserNotifications not available - notifications disabled") return try: # Get the shared notification center self._center = UN.UNUserNotificationCenter.currentNotificationCenter() # Request authorization for notifications self._request_authorization() log.info("MacOSNotificationService initialized") except Exception as e: log.error(f"Failed to initialize notification center: {e}") self._center = None def _request_authorization(self): """Request notification authorization from the user. Note: Will fail with UNErrorDomain Code=1 on unsigned builds. """ if not self._center: return def completion_handler(granted, error): if error: log.error(f"Notification authorization error: {error}") log.error( "This usually means the app is not properly signed with a " "Developer ID certificate. Notifications require signed builds." ) self._authorized = False else: self._authorized = granted if granted: log.info("Notification authorization granted") else: log.warning("Notification authorization denied by user") # Request authorization for alerts, sounds, and badges options = ( UN.UNAuthorizationOptionAlert | UN.UNAuthorizationOptionSound | UN.UNAuthorizationOptionBadge ) self._center.requestAuthorizationWithOptions_completionHandler_( options, completion_handler ) def send_notification(self, title: str, body: str, notification_id: str) -> None: """Send a native macOS notification. Args: title: Notification title body: Notification body text notification_id: Unique identifier for this notification """ if not self.is_supported(): log.debug("Notifications not supported, skipping") return try: # Create notification content content = UN.UNMutableNotificationContent.alloc().init() content.setTitle_(title) content.setBody_(body) content.setSound_(UN.UNNotificationSound.defaultSound()) # Set bundle identifier for proper app attribution bundle_id = NSBundle.mainBundle().bundleIdentifier() if not bundle_id: bundle_id = "page.codeberg.sesivany.Meshy" # Create a trigger (deliver immediately) trigger = nil # nil trigger = deliver immediately # Create the request request = UN.UNNotificationRequest.requestWithIdentifier_content_trigger_( notification_id, content, trigger ) # Add the request to the notification center def completion_handler(error): if error: log.error(f"Failed to send notification: {error}") else: log.debug(f"Sent notification: {title}") self._center.addNotificationRequest_withCompletionHandler_( request, completion_handler ) except Exception as e: log.error(f"Exception sending notification: {e}") def is_supported(self) -> bool: """Check if macOS notifications are supported. Returns: True if PyObjC is available and notification center is initialized """ return OBJC_AVAILABLE and self._center is not None meshy/src/platforms/notification_base.py000066400000000000000000000016001523412555500210370ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Base notification interface for platform abstraction.""" from abc import ABC, abstractmethod class NotificationService(ABC): """Abstract base class for platform-specific notification services.""" @abstractmethod def send_notification(self, title: str, body: str, notification_id: str) -> None: """Send a desktop notification. Args: title: Notification title body: Notification body text notification_id: Unique identifier for this notification """ pass @abstractmethod def is_supported(self) -> bool: """Check if notifications are supported on this platform. Returns: True if notifications are available and can be sent """ pass meshy/src/protocol.py000066400000000000000000001302351523412555500152200ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """MeshCore protocol - sync wrappers using meshcore library's packets and parsing logic. Uses meshcore.packets for constants and follows meshcore.reader's parsing format for field names and response handling. """ import hashlib import io import struct from datetime import datetime from meshy.meshcore_packets import CommandType, ControlType, PacketType from meshy.smaz import try_decode_prefixed as _smaz_decode # Re-export constants that the rest of the app uses CMD_APP_START = CommandType.APP_START.value CMD_DEVICE_QUERY = CommandType.DEVICE_QUERY.value CMD_GET_CONTACTS = CommandType.GET_CONTACTS.value CMD_GET_DEVICE_TIME = CommandType.GET_DEVICE_TIME.value CMD_SET_DEVICE_TIME = CommandType.SET_DEVICE_TIME.value CMD_SEND_SELF_ADVERT = CommandType.SEND_SELF_ADVERT.value CMD_SET_ADVERT_NAME = CommandType.SET_ADVERT_NAME.value CMD_ADD_UPDATE_CONTACT = CommandType.ADD_UPDATE_CONTACT.value CMD_SYNC_NEXT_MESSAGE = CommandType.SYNC_NEXT_MESSAGE.value CMD_SET_RADIO_PARAMS = CommandType.SET_RADIO_PARAMS.value CMD_SET_RADIO_TX_POWER = CommandType.SET_RADIO_TX_POWER.value CMD_RESET_PATH = CommandType.RESET_PATH.value CMD_SET_ADVERT_LATLON = CommandType.SET_ADVERT_LATLON.value CMD_REMOVE_CONTACT = CommandType.REMOVE_CONTACT.value CMD_EXPORT_CONTACT = CommandType.EXPORT_CONTACT.value CMD_IMPORT_CONTACT = CommandType.IMPORT_CONTACT.value CMD_GET_CONTACT_BY_KEY = CommandType.GET_CONTACT_BY_KEY.value CMD_REBOOT = 19 CMD_GET_BATT_AND_STORAGE = CommandType.GET_BATT_AND_STORAGE.value CMD_SEND_LOGIN = CommandType.SEND_LOGIN.value CMD_SEND_STATUS_REQ = CommandType.SEND_STATUS_REQ.value CMD_LOGOUT = CommandType.LOGOUT.value CMD_GET_CHANNEL = CommandType.GET_CHANNEL.value CMD_SET_CHANNEL = CommandType.SET_CHANNEL.value CMD_SEND_TRACE_PATH = CommandType.SEND_TRACE_PATH.value CMD_SET_OTHER_PARAMS = CommandType.SET_OTHER_PARAMS.value CMD_SEND_TXT_MSG = CommandType.SEND_TXT_MSG.value CMD_SEND_CHANNEL_TXT_MSG = CommandType.SEND_CHANNEL_TXT_MSG.value CMD_GET_AUTO_ADD_CONFIG = CommandType.GET_AUTOADD_CONFIG.value CMD_SET_AUTO_ADD_CONFIG = CommandType.SET_AUTOADD_CONFIG.value CMD_SEND_BINARY_REQ = CommandType.BINARY_REQ.value CMD_GET_CUSTOM_VAR = CommandType.GET_CUSTOM_VARS.value CMD_SET_CUSTOM_VAR = CommandType.SET_CUSTOM_VAR.value CMD_GET_STATS = 56 CMD_SET_DEVICE_PIN = CommandType.SET_DEVICE_PIN.value CMD_SET_PATH_HASH_MODE = CommandType.SET_PATH_HASH_MODE.value CMD_GET_ALLOWED_REPEAT_FREQ = CommandType.GET_ALLOWED_REPEAT_FREQ.value CMD_EXPORT_PRIVATE_KEY = CommandType.EXPORT_PRIVATE_KEY.value CMD_IMPORT_PRIVATE_KEY = CommandType.IMPORT_PRIVATE_KEY.value # Response/push codes from meshcore.packets.PacketType RESP_CODE_OK = PacketType.OK.value RESP_CODE_ERR = PacketType.ERROR.value RESP_CODE_CONTACTS_START = PacketType.CONTACT_START.value RESP_CODE_CONTACT = PacketType.CONTACT.value RESP_CODE_END_OF_CONTACTS = PacketType.CONTACT_END.value RESP_CODE_SELF_INFO = PacketType.SELF_INFO.value RESP_CODE_SENT = PacketType.MSG_SENT.value RESP_CODE_CONTACT_MSG_RECV = PacketType.CONTACT_MSG_RECV.value RESP_CODE_CHANNEL_MSG_RECV = PacketType.CHANNEL_MSG_RECV.value RESP_CODE_CURR_TIME = PacketType.CURRENT_TIME.value RESP_CODE_NO_MORE_MESSAGES = PacketType.NO_MORE_MSGS.value RESP_CODE_EXPORT_CONTACT = PacketType.CONTACT_URI.value RESP_CODE_BINARY_RESPONSE = PacketType.BINARY_RESPONSE.value RESP_CODE_BATT_AND_STORAGE = PacketType.BATTERY.value RESP_CODE_DEVICE_INFO = PacketType.DEVICE_INFO.value RESP_CODE_CONTACT_MSG_RECV_V3 = 16 RESP_CODE_CHANNEL_MSG_RECV_V3 = 17 RESP_CODE_CHANNEL_INFO = PacketType.CHANNEL_INFO.value RESP_CODE_AUTO_ADD_CONFIG = PacketType.AUTOADD_CONFIG.value RESP_CODE_CHANNEL_DATA_RECV = PacketType.CHANNEL_DATA_RECV.value RESP_CODE_DEFAULT_FLOOD_SCOPE = PacketType.DEFAULT_FLOOD_SCOPE.value RESP_CODE_STATS = PacketType.STATS.value RESP_CODE_ADVERT_PATH = PacketType.ADVERT_PATH.value RESP_CODE_PRIVATE_KEY = PacketType.PRIVATE_KEY.value RESP_CODE_DISABLED = PacketType.DISABLED.value RESP_CODE_ALLOWED_REPEAT_FREQ = PacketType.ALLOWED_REPEAT_FREQ.value # 26 PUSH_CODE_ADVERT = PacketType.ADVERTISEMENT.value PUSH_CODE_PATH_UPDATED = PacketType.PATH_UPDATE.value PUSH_CODE_SEND_CONFIRMED = PacketType.ACK.value PUSH_CODE_MSG_WAITING = PacketType.MESSAGES_WAITING.value PUSH_CODE_NEW_ADVERT = PacketType.PUSH_CODE_NEW_ADVERT.value PUSH_CODE_TRACE_DATA = PacketType.TRACE_DATA.value PUSH_CODE_LOG_DATA = PacketType.LOG_DATA.value # 0x88 - raw radio packet PUSH_CODE_TELEMETRY_RESPONSE = PacketType.TELEMETRY_RESPONSE.value PUSH_CODE_PATH_DISCOVERY_RESPONSE = PacketType.PATH_DISCOVERY_RESPONSE.value # 0x8D PUSH_CODE_LOGIN_SUCCESS = PacketType.LOGIN_SUCCESS.value # 0x85 PUSH_CODE_LOGIN_FAILED = PacketType.LOGIN_FAILED.value # 0x86 PUSH_CODE_STATUS_RESPONSE = PacketType.STATUS_RESPONSE.value # 0x87 PUSH_CODE_CONTACT_DELETED = PacketType.CONTACT_DELETED.value # 0x8F # Text types TXT_TYPE_PLAIN = 0 TXT_TYPE_CLI_DATA = 1 TXT_TYPE_SIGNED = 2 # Contact types ADV_TYPE_CHAT = 1 ADV_TYPE_REPEATER = 2 ADV_TYPE_ROOM = 3 ADV_TYPE_SENSOR = 4 # Sizes PUB_KEY_SIZE = 32 MAX_PATH_SIZE = 64 MAX_NAME_SIZE = 32 MAX_FRAME_SIZE = 172 MAX_TEXT_PAYLOAD = 160 # firmware payload limit for text messages APP_PROTOCOL_VERSION = 4 # Message overhead: cmd(1) + type(1) + attempt(1) + ts(4) + pubkey(6) + null(1) + safety(2) _DM_OVERHEAD = 16 # Channel overhead: cmd(1) + type(1) + chan_idx(1) + ts(4) + null(1) + safety(2) _CHAN_OVERHEAD = 10 def max_dm_text_bytes() -> int: """Max UTF-8 bytes for a direct message.""" return min(MAX_FRAME_SIZE - _DM_OVERHEAD, MAX_TEXT_PAYLOAD) def max_channel_text_bytes(sender_name: str) -> int: """Max UTF-8 bytes for a channel message (firmware prepends 'name: ').""" prefix_len = len(sender_name.encode("utf-8")) + 2 # ": " appended return min(MAX_TEXT_PAYLOAD - prefix_len, MAX_FRAME_SIZE - _CHAN_OVERHEAD) # Binary request types REQ_TYPE_STATUS = 0x01 REQ_TYPE_TELEMETRY = 0x03 REQ_TYPE_ACL = 0x05 REQ_TYPE_GET_NEIGHBORS = 0x06 # Control types (for zero-hop discovery) CTRL_NODE_DISCOVER_REQ = ControlType.NODE_DISCOVER_REQ.value # 0x80 CTRL_NODE_DISCOVER_RESP = ControlType.NODE_DISCOVER_RESP.value # 0x90 # Response code for control data RESP_CODE_CONTROL_DATA = PacketType.CONTROL_DATA.value # 0x8E CMD_SEND_CONTROL_DATA = CommandType.SEND_CONTROL_DATA.value # 55 # Auto-add flags AUTO_ADD_OVERWRITE_OLDEST = 1 << 0 AUTO_ADD_CHAT = 1 << 1 AUTO_ADD_REPEATER = 1 << 2 AUTO_ADD_ROOM_SERVER = 1 << 3 AUTO_ADD_SENSOR = 1 << 4 # Stats sub-types STATS_TYPE_CORE = 0 STATS_TYPE_RADIO = 1 STATS_TYPE_PACKETS = 2 # Error codes from companion firmware (MyMesh.cpp) ERROR_CODES = { 0x01: "Unsupported command", 0x02: "Not found", 0x03: "Table full", 0x04: "Bad state", 0x05: "File I/O error", 0x06: "Illegal argument", } def _now_ts() -> int: return int(datetime.now().timestamp()) def _decode_cstring(data: bytes) -> str: try: idx = data.index(0) return data[:idx].decode("utf-8", errors="replace") except ValueError: return data.decode("utf-8", errors="replace") def _parse_path_length_byte(plen: int) -> tuple[int, int]: if plen == 255: return (-1, 1) return (plen & 0x3F, ((plen >> 6) & 0x03) + 1) def _parse_snr(data: bytes) -> float: return int.from_bytes(data, byteorder="little", signed=True) / 4.0 def _parse_latlon(dbuf) -> tuple[float, float]: lat = int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6 lon = int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6 return (lat, lon) def _build_scope_key(scope: str) -> bytes: if not scope or scope in ("0", "None", "*", ""): return bytes(16) if not scope.startswith("#"): scope = "#" + scope return hashlib.sha256(scope.encode("utf-8")).digest()[:16] # ─── Frame Builders ─────────────────────────────────────────────── # Following meshcore_py commands/* patterns for frame construction def build_app_start(app_name: str = "Meshy", app_version: int = APP_PROTOCOL_VERSION) -> bytes: return bytes([CMD_APP_START, app_version]) + bytes(6) + app_name.encode("utf-8") + b"\x00" def build_device_query(app_version: int = APP_PROTOCOL_VERSION) -> bytes: return bytes([CMD_DEVICE_QUERY, app_version]) def build_get_contacts(since: int | None = None) -> bytes: frame = bytes([CMD_GET_CONTACTS]) if since is not None: frame += struct.pack(" bytes: return bytes([CMD_GET_DEVICE_TIME]) def build_set_device_time(timestamp: int | None = None) -> bytes: if timestamp is None: timestamp = _now_ts() return bytes([CMD_SET_DEVICE_TIME]) + struct.pack(" bytes: return bytes([CMD_GET_BATT_AND_STORAGE]) def build_sync_next_message() -> bytes: return bytes([CMD_SYNC_NEXT_MESSAGE]) def build_send_text_msg( recipient_pub_key: bytes, text: str, attempt: int = 0, timestamp: int | None = None ) -> bytes: if timestamp is None: timestamp = _now_ts() frame = bytes([CMD_SEND_TXT_MSG, TXT_TYPE_PLAIN, min(attempt, 255)]) frame += struct.pack(" bytes: if timestamp is None: timestamp = _now_ts() frame = bytes([CMD_SEND_CHANNEL_TXT_MSG, TXT_TYPE_PLAIN, channel_index]) frame += struct.pack(" bytes: if timestamp is None: timestamp = _now_ts() frame = bytes([CMD_SEND_TXT_MSG, TXT_TYPE_CLI_DATA, min(attempt, 255)]) frame += struct.pack(" bytes: if flood: return bytes([CMD_SEND_SELF_ADVERT, 1]) return bytes([CMD_SEND_SELF_ADVERT]) def build_set_advert_name(name: str) -> bytes: return bytes([CMD_SET_ADVERT_NAME]) + name.encode("utf-8")[: MAX_NAME_SIZE - 1] def build_set_advert_latlon(lat: float, lon: float) -> bytes: frame = bytes([CMD_SET_ADVERT_LATLON]) frame += struct.pack(" bytes: return bytes([CMD_REMOVE_CONTACT]) + pub_key def build_add_update_contact( pub_key: bytes, contact_type: int = ADV_TYPE_CHAT, flags: int = 0, path_len: int = 0, path: bytes = b"", name: str = "", lat: float | None = None, lon: float | None = None, last_modified: int | None = None, ) -> bytes: frame = bytes([CMD_ADD_UPDATE_CONTACT]) frame += pub_key frame += bytes([contact_type, flags, path_len]) path_padded = bytearray(MAX_PATH_SIZE) path_padded[: len(path)] = path[:MAX_PATH_SIZE] frame += bytes(path_padded) name_bytes = name.encode("utf-8")[: MAX_NAME_SIZE - 1] frame += name_bytes + b"\x00" * (MAX_NAME_SIZE - len(name_bytes)) frame += struct.pack(" bytes: return bytes([CMD_GET_CONTACT_BY_KEY]) + pub_key def build_reset_path(pub_key: bytes) -> bytes: return bytes([CMD_RESET_PATH]) + pub_key def build_export_contact(pub_key: bytes) -> bytes: return bytes([CMD_EXPORT_CONTACT]) + pub_key def build_import_contact(contact_frame: bytes) -> bytes: return bytes([CMD_IMPORT_CONTACT]) + contact_frame def build_get_channel(channel_index: int) -> bytes: return bytes([CMD_GET_CHANNEL, channel_index]) def build_set_channel(channel_index: int, name: str, psk: bytes) -> bytes: name_bytes = name.encode("utf-8")[:32] name_bytes = name_bytes.ljust(32, b"\x00") psk_padded = bytearray(16) psk_padded[: len(psk)] = psk[:16] return bytes([CMD_SET_CHANNEL, channel_index]) + name_bytes + bytes(psk_padded) def build_set_radio_params( freq_hz: int, bw_hz: int, sf: int, cr: int, client_repeat: bool | None = None ) -> bytes: # freq_hz and bw_hz are in kHz (matching meshcore_py: freq*1000, bw*1000) frame = bytes([CMD_SET_RADIO_PARAMS]) frame += struct.pack(" bytes: return bytes([CMD_SET_RADIO_TX_POWER, power_dbm & 0xFF]) def build_reboot() -> bytes: return bytes([CMD_REBOOT]) + b"reboot" def build_factory_reset() -> bytes: return bytes([CommandType.FACTORY_RESET.value]) + b"reset" def build_set_path_hash_mode(mode: int) -> bytes: """Set path hash mode: 0=1-byte, 1=2-byte, 2=3-byte.""" return bytes([CMD_SET_PATH_HASH_MODE, 0, mode]) def build_set_device_pin(pin: int) -> bytes: return bytes([CMD_SET_DEVICE_PIN]) + struct.pack(" bytes: """Set other params. Format: [0x26][manual_add_contacts][telemetry_mode][advert_loc_policy][multi_acks] """ return bytes( [CMD_SET_OTHER_PARAMS, manual_add_contacts, telemetry_flags, advert_loc_policy, multi_acks] ) def build_get_custom_vars() -> bytes: return bytes([CMD_GET_CUSTOM_VAR]) def build_set_custom_var(key_value: str) -> bytes: """Set a custom variable. Format: 'key:value' (e.g. 'gps:1').""" return bytes([CMD_SET_CUSTOM_VAR]) + key_value.encode("utf-8") + b"\x00" RESP_CODE_CUSTOM_VARS = PacketType.CUSTOM_VARS.value def parse_custom_vars(data: bytes) -> dict: """Parse CUSTOM_VARS response. Format: 'key1:val1,key2:val2,...'""" if len(data) < 2 or data[0] != RESP_CODE_CUSTOM_VARS: return {} raw = data[1:].decode("utf-8", "ignore").rstrip("\x00") if not raw: return {} result = {} for pair in raw.split(","): parts = pair.split(":", 1) if len(parts) == 2: result[parts[0]] = parts[1] return result def build_set_flood_scope(scope: str = "", unscoped: bool = False) -> bytes: """Set session flood scope (cmd 54). Empty string or '*' to disable, '#name' for hashtag scope. When *unscoped* is True (fw v12+), sends with byte[1]=1 to explicitly override the default scope for this message. """ cmd = CommandType.SET_FLOOD_SCOPE.value flags = 1 if unscoped else 0 return bytes([cmd, flags]) + _build_scope_key(scope) def build_set_default_flood_scope(scope: str = "") -> bytes: """Set persistent default flood scope (cmd 63). Empty to clear, '#name' for hashtag scope.""" cmd = CommandType.SET_DEFAULT_FLOOD_SCOPE.value scope_key = _build_scope_key(scope) if scope_key == bytes(16): return bytes([cmd]) if not scope.startswith("#"): scope = "#" + scope scope_name = scope.encode("utf-8")[:31].ljust(31, b"\x00") return bytes([cmd]) + scope_name + scope_key def build_get_advert_path(pub_key: bytes) -> bytes: """Get the advertisement path cached for a contact. Uses full 32-byte key.""" return bytes([CommandType.GET_ADVERT_PATH.value, 0]) + pub_key[:PUB_KEY_SIZE] def build_path_discovery(pub_key: bytes) -> bytes: """Send a path discovery request for a contact.""" return bytes([CommandType.PATH_DISCOVERY.value, 0]) + pub_key def parse_advert_path_response(data: bytes) -> dict | None: """Parse RESP_CODE_ADVERT_PATH (0x16). Format: [code][timestamp×4 LE][path_len_byte][path_bytes...] """ if len(data) < 6 or data[0] != PacketType.ADVERT_PATH.value: return None timestamp = struct.unpack_from("> 6) & 0x03) + 1 path_byte_len = hop_count * hash_size path_data = data[6 : 6 + path_byte_len] if path_byte_len > 0 else b"" return { "path_length": hop_count, "path_hash_size": hash_size, "path": path_data, "timestamp": timestamp, "hops": [ path_data[i : i + hash_size].hex().upper() for i in range(0, len(path_data), hash_size) ] if path_data else [], } def build_trace_path(tag: int, path_bytes: bytes, flags: int = 0) -> bytes: """Build a SEND_TRACE_PATH request. Format: [cmd][tag×4 LE][auth×4 LE][flag][path_hops...] flags encodes path hash size: 0=1-byte, 1=2-byte, 2=4-byte, 3=8-byte. """ frame = bytes([CMD_SEND_TRACE_PATH]) frame += struct.pack(" dict | None: """Parse PUSH_CODE_TRACE_DATA (0x89) response. Format: [code][reserved][path_byte_len][flags][tag×4][auth×4][path_data...][snr_values...] flags lower 2 bits encode hash size: 0→1B, 1→2B, 2→4B, 3→8B. """ if len(data) < 12 or data[0] != PUSH_CODE_TRACE_DATA: return None path_byte_len = data[2] flags = data[3] path_hash_size = 1 << (flags & 3) hop_count = path_byte_len // path_hash_size if path_hash_size else path_byte_len tag = data[4:8] # path data starts at offset 12 offset = 12 if offset + path_byte_len > len(data): return None path_data = data[offset : offset + path_byte_len] snr_raw = data[offset + path_byte_len :] snr_values = [_parse_snr(bytes([b])) for b in snr_raw] # Split path into per-hop hashes path_hops = [ path_data[i : i + path_hash_size] for i in range(0, hop_count * path_hash_size, path_hash_size) ] return { "tag": tag, "path_hops": path_hops, "path_hash_size": path_hash_size, "snr_values": snr_values, } def build_send_login(recipient_pub_key: bytes, password: str) -> bytes: return bytes([CMD_SEND_LOGIN]) + recipient_pub_key + password.encode("utf-8") + b"\x00" def build_logout(pub_key: bytes) -> bytes: return bytes([CMD_LOGOUT]) + pub_key def build_send_status_req(recipient_pub_key: bytes) -> bytes: return bytes([CMD_SEND_STATUS_REQ]) + recipient_pub_key def build_get_auto_add_config() -> bytes: return bytes([CMD_GET_AUTO_ADD_CONFIG]) def build_set_auto_add_config( chat: bool = False, repeater: bool = False, room: bool = False, sensor: bool = False, overwrite_oldest: bool = False, max_hops: int = 0, ) -> bytes: flags = 0 if chat: flags |= AUTO_ADD_CHAT if repeater: flags |= AUTO_ADD_REPEATER if room: flags |= AUTO_ADD_ROOM_SERVER if sensor: flags |= AUTO_ADD_SENSOR if overwrite_oldest: flags |= AUTO_ADD_OVERWRITE_OLDEST return bytes([CMD_SET_AUTO_ADD_CONFIG, flags, min(max_hops, 64)]) def build_send_discover_req( type_filter: int = 0xFF, prefix_only: bool = True, tag: int | None = None ) -> bytes: """Send a zero-hop NODE_DISCOVER_REQ control packet. Args: type_filter: bitmask of ADV_TYPE_* bits (0xFF = all types) prefix_only: if True, responders send 8-byte key prefix; if False, full 32-byte key tag: random correlation tag (auto-generated if None) """ import random if tag is None: tag = random.randint(0, 0xFFFFFFFF) flags = CTRL_NODE_DISCOVER_REQ # 0x80 = upper nibble is sub_type 8 if prefix_only: flags |= 0x01 # bit 0 = prefix_only frame = bytes([CMD_SEND_CONTROL_DATA, flags, type_filter]) frame += struct.pack(" dict | None: """Parse a NODE_DISCOVER_RESP from a CONTROL_DATA push (0x8E). Companion protocol format: 0x8E + 2 prefix bytes + path_len(1) + path + flags(1) + snr(1) + tag(4) + pubkey """ if len(data) < 8: return None # data[0] = 0x8E, data[1:3] = prefix bytes, data[3] = path_length offset = 3 # skip code + 2 prefix bytes path_len_byte = data[offset] hop_count = path_len_byte & 0x3F hash_size = ((path_len_byte >> 6) & 0x03) + 1 offset += 1 offset += hop_count * hash_size # skip path hops if offset + 6 > len(data): # need flags + snr + tag(4) return None flags = data[offset] sub_type = (flags >> 4) & 0x0F if sub_type != 0x09: # DISCOVER_RESP sub_type = 9 return None node_type = flags & 0x0F snr = _parse_snr(data[offset + 1 : offset + 2]) tag = int.from_bytes(data[offset + 2 : offset + 6], "little", signed=False) pub_key_hex = data[offset + 6 :].hex() return { "node_type": node_type, "snr": snr, "tag": tag, "pub_key_hex": pub_key_hex, } def build_get_neighbors( repeater_pub_key: bytes, max_results: int = 10, offset: int = 0, key_len: int = 4 ) -> bytes: """Build a binary request to get neighbors from a repeater.""" frame = bytes([CMD_SEND_BINARY_REQ]) frame += repeater_pub_key frame += bytes( [ REQ_TYPE_GET_NEIGHBORS, 0x00, # version/reserved max_results, # max number of neighbors ] ) frame += struct.pack(" bytes: """Build a binary request to get ACL from a repeater.""" frame = bytes([CMD_SEND_BINARY_REQ]) frame += repeater_pub_key frame += bytes([REQ_TYPE_ACL, 0x00, 0x00]) return frame def build_get_telemetry(repeater_pub_key: bytes) -> bytes: """Build a binary request to get telemetry from a repeater/sensor.""" frame = bytes([CMD_SEND_BINARY_REQ]) frame += repeater_pub_key frame += bytes([REQ_TYPE_TELEMETRY]) return frame def build_get_telemetry_chat(pub_key: bytes = None) -> bytes: """Build a telemetry request for a chat companion node. Uses CMD_SEND_TELEMETRY_REQ (0x27) instead of CMD_SEND_BINARY_REQ. Response arrives as PUSH_CODE_TELEMETRY_RESPONSE (0x8B). If pub_key is None, requests self-telemetry from the connected device. """ cmd = CommandType.SEND_TELEMETRY_REQ.value frame = bytes([cmd]) if pub_key and len(pub_key) >= 32: frame += bytes(3) # reserved frame += pub_key[:32] else: frame += bytes(3) # reserved (self-telemetry) return frame ANON_REQ_REGIONS = 0x01 def build_anon_req_regions(pub_key: bytes, path_length: int, path: bytes) -> bytes: """Build an anonymous request for regions from a repeater. Format: [CMD_SEND_ANON_REQ][pub_key_32][req_type][path_len][path_reversed] """ cmd = CommandType.SEND_ANON_REQ.value frame = bytes([cmd]) frame += pub_key[:32] frame += bytes([ANON_REQ_REGIONS]) frame += bytes([path_length]) frame += bytes(reversed(path[:path_length])) if path_length > 0 else b"" return frame def parse_status_response(data: bytes) -> dict | None: """Parse PUSH_CODE_STATUS_RESPONSE (0x87) — 52/56-byte binary status. Frame: [code(1)][reserved(1)][sender_prefix(6)][status_data(52+)] """ if len(data) < 60: # 8 header + 52 payload return None # Skip 8 bytes: code(1) + reserved(1) + sender_prefix(6) d = data[8:] return { "battery_mv": struct.unpack("= 46 else 0, "dup_flood": struct.unpack("= 48 else 0, "rx_air_secs": struct.unpack("= 52 else 0, "recv_errors": struct.unpack("= 56 else None, } def parse_neighbors_response(data: bytes, key_len: int = 4) -> dict: """Parse a neighbors binary response (0x8C). Frame: [code(1)][reserved(1)][tag(4)][total_count(2 LE)][results_count(2 LE)][entries...] Each entry: key_prefix(key_len) + last_heard(4 LE) + snr(int8). Returns dict with 'total', 'neighbors' list. """ if len(data) < 10: return {"total": 0, "neighbors": []} # Skip 6-byte header: code(1) + reserved(1) + tag(4) total_count = struct.unpack(" bytes: return bytes([CMD_GET_ALLOWED_REPEAT_FREQ]) def parse_allowed_repeat_freq(data: bytes) -> list[tuple[int, int]]: """Parse allowed repeat frequency ranges: pairs of (lower_khz, upper_khz) u32 LE.""" ranges = [] offset = 1 while offset + 8 <= len(data): lower = struct.unpack_from(" bytes: """Build a stats request. sub_type: 0=Core, 1=Radio, 2=Packets.""" return bytes([CMD_GET_STATS, sub_type]) def parse_stats_response(data: bytes) -> dict | None: """Parse stats response (code 0x18). Byte 1 is sub-type.""" if len(data) < 4: return None sub_type = data[1] result = {"sub_type": sub_type} if sub_type == STATS_TYPE_CORE and len(data) >= 11: result["battery_mv"] = int.from_bytes(data[2:4], "little", signed=False) result["uptime_secs"] = int.from_bytes(data[4:8], "little", signed=False) result["errors"] = int.from_bytes(data[8:10], "little", signed=False) result["queue_len"] = data[10] elif sub_type == STATS_TYPE_RADIO and len(data) >= 14: result["noise_floor"] = int.from_bytes(data[2:4], "little", signed=True) result["last_rssi"] = int.from_bytes(data[4:5], "little", signed=True) result["last_snr"] = _parse_snr(data[5:6]) result["tx_air_secs"] = int.from_bytes(data[6:10], "little", signed=False) result["rx_air_secs"] = int.from_bytes(data[10:14], "little", signed=False) elif sub_type == STATS_TYPE_PACKETS and len(data) >= 26: result["recv"] = int.from_bytes(data[2:6], "little", signed=False) result["sent"] = int.from_bytes(data[6:10], "little", signed=False) result["flood_tx"] = int.from_bytes(data[10:14], "little", signed=False) result["direct_tx"] = int.from_bytes(data[14:18], "little", signed=False) result["flood_rx"] = int.from_bytes(data[18:22], "little", signed=False) result["direct_rx"] = int.from_bytes(data[22:26], "little", signed=False) if len(data) >= 30: result["recv_errors"] = int.from_bytes(data[26:30], "little", signed=False) return result def parse_error_response(data: bytes) -> dict | None: """Parse error response (code 0x01). Byte 1 is error code.""" if len(data) < 2: return {"code": 0, "message": "Unknown error"} code = data[1] return { "code": code, "message": ERROR_CODES.get(code, f"Unknown error (0x{code:02x})"), } # ─── Frame Parsers ──────────────────────────────────────────────── # Sync parsing following meshcore.reader.MessageReader.handle_rx field names def identify_response(data: bytes) -> int | None: if not data: return None return data[0] def parse_contact_frame(data: bytes) -> dict | None: """Parse contact frame, matching meshcore reader field names.""" if len(data) < 100: return None code = data[0] if code not in (RESP_CODE_CONTACT, PUSH_CODE_NEW_ADVERT): return None dbuf = io.BytesIO(data[1:]) c = {} c["public_key"] = dbuf.read(32).hex() c["type"] = dbuf.read(1)[0] c["flags"] = dbuf.read(1)[0] plen = int.from_bytes(dbuf.read(1), signed=False, byteorder="little") c["out_path_len"], c["path_hash_size"] = _parse_path_length_byte(plen) raw_path = dbuf.read(64) # Extract meaningful path bytes based on hop count and hash size hop_count = max(c["out_path_len"], 0) hash_size = c["path_hash_size"] path_byte_len = hop_count * hash_size c["out_path"] = raw_path[:path_byte_len].hex() if path_byte_len > 0 else "" c["adv_name"] = dbuf.read(32).decode("utf-8", "ignore").replace("\0", "") c["last_advert"] = int.from_bytes(dbuf.read(4), byteorder="little") c["adv_lat"], c["adv_lon"] = _parse_latlon(dbuf) lastmod_bytes = dbuf.read(4) if lastmod_bytes and len(lastmod_bytes) == 4: c["lastmod"] = int.from_bytes(lastmod_bytes, byteorder="little") return c def parse_self_info(data: bytes) -> dict | None: """Parse SELF_INFO, matching meshcore reader field names.""" if len(data) < 36 or data[0] != RESP_CODE_SELF_INFO: return None dbuf = io.BytesIO(data[1:]) si = {} si["adv_type"] = dbuf.read(1)[0] si["tx_power"] = int.from_bytes(dbuf.read(1), signed=True, byteorder="little") si["max_tx_power"] = dbuf.read(1)[0] si["public_key"] = dbuf.read(32).hex() si["adv_lat"], si["adv_lon"] = _parse_latlon(dbuf) si["multi_acks"] = dbuf.read(1)[0] si["adv_loc_policy"] = dbuf.read(1)[0] telemetry_mode = dbuf.read(1)[0] si["telemetry_mode_env"] = (telemetry_mode >> 4) & 0b11 si["telemetry_mode_loc"] = (telemetry_mode >> 2) & 0b11 si["telemetry_mode_base"] = telemetry_mode & 0b11 si["manual_add_contacts"] = dbuf.read(1)[0] > 0 si["radio_freq"] = int.from_bytes(dbuf.read(4), byteorder="little") / 1000 si["radio_bw"] = int.from_bytes(dbuf.read(4), byteorder="little") / 1000 si["radio_sf"] = dbuf.read(1)[0] si["radio_cr"] = dbuf.read(1)[0] si["name"] = dbuf.read().decode("utf-8", "ignore").rstrip("\x00") return si def parse_device_info(data: bytes) -> dict | None: """Parse DEVICE_INFO, matching meshcore reader field names.""" if len(data) < 4 or data[0] != RESP_CODE_DEVICE_INFO: return None dbuf = io.BytesIO(data[1:]) res = {} fw_ver = dbuf.read(1)[0] res["fw_ver"] = fw_ver if fw_ver >= 3: res["max_contacts"] = dbuf.read(1)[0] * 2 res["max_channels"] = dbuf.read(1)[0] res["ble_pin"] = int.from_bytes(dbuf.read(4), byteorder="little") res["fw_build"] = dbuf.read(12).decode("utf-8", "ignore").replace("\0", "") res["model"] = dbuf.read(40).decode("utf-8", "ignore").replace("\0", "") res["ver"] = dbuf.read(20).decode("utf-8", "ignore").replace("\0", "") if fw_ver >= 9: rpt = dbuf.read(1) if len(rpt) > 0: res["repeat"] = rpt[0] != 0 if fw_ver >= 10: phm = dbuf.read(1) if len(phm) > 0: res["path_hash_mode"] = phm[0] return res def parse_batt_and_storage(data: bytes) -> dict | None: """Parse battery/storage, matching meshcore reader field names.""" if len(data) < 3 or data[0] != RESP_CODE_BATT_AND_STORAGE: return None dbuf = io.BytesIO(data[1:]) result = {} result["level"] = int.from_bytes(dbuf.read(2), byteorder="little") if len(data) > 3: result["used_kb"] = int.from_bytes(dbuf.read(4), byteorder="little") result["total_kb"] = int.from_bytes(dbuf.read(4), byteorder="little") return result def parse_contact_message(data: bytes) -> dict | None: """Parse incoming DM, matching meshcore reader field names.""" if len(data) < 10: return None code = data[0] if code not in (RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CONTACT_MSG_RECV_V3): return None dbuf = io.BytesIO(data[1:]) res = {} res["type"] = "PRIV" if code == RESP_CODE_CONTACT_MSG_RECV_V3: res["SNR"] = _parse_snr(dbuf.read(1)) dbuf.read(2) # reserved res["pubkey_prefix"] = dbuf.read(6).hex() plen = dbuf.read(1)[0] res["path_len"], res["path_hash_size"] = _parse_path_length_byte(plen) txt_type = dbuf.read(1)[0] res["txt_type"] = txt_type res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little") if txt_type == 2: res["signature"] = dbuf.read(4).hex() res["text"] = _smaz_decode(dbuf.read().decode("utf-8", "ignore").rstrip("\x00")) return res def parse_channel_message(data: bytes) -> dict | None: """Parse incoming channel message, matching meshcore reader field names.""" if len(data) < 8: return None code = data[0] if code not in (RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3): return None dbuf = io.BytesIO(data[1:]) res = {} res["type"] = "CHAN" if code == RESP_CODE_CHANNEL_MSG_RECV_V3: res["SNR"] = _parse_snr(dbuf.read(1)) dbuf.read(2) # reserved res["channel_idx"] = dbuf.read(1)[0] plen = dbuf.read(1)[0] res["path_len"], res["path_hash_size"] = _parse_path_length_byte(plen) res["txt_type"] = dbuf.read(1)[0] res["path_bytes"] = "" # Path bytes come via LOG_DATA (0x88), not here res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little", signed=False) text = dbuf.read().strip(b"\0") res["text"] = text.decode("utf-8", "ignore") # Channel messages have format "name: message" if ": " in res["text"]: res["sender_name"], res["msg_text"] = res["text"].split(": ", 1) else: res["sender_name"] = "" res["msg_text"] = res["text"] res["msg_text"] = _smaz_decode(res["msg_text"]) return res def parse_channel_data(data: bytes) -> dict | None: """Parse CHANNEL_DATA_RECV (binary data on group channels, companion v1.15.0+). Frame: [code][SNR(i8)][reserved(2)][chan_idx][path_len][data_type(u16LE)][data_len][payload] """ if len(data) < 9 or data[0] != RESP_CODE_CHANNEL_DATA_RECV: return None dbuf = io.BytesIO(data[1:]) res = {} res["SNR"] = _parse_snr(dbuf.read(1)) dbuf.read(2) # reserved res["channel_idx"] = dbuf.read(1)[0] plen = dbuf.read(1)[0] res["path_len"], res["path_hash_size"] = _parse_path_length_byte(plen) res["data_type"] = int.from_bytes(dbuf.read(2), byteorder="little") res["data_len"] = dbuf.read(1)[0] res["payload"] = dbuf.read(res["data_len"]).hex() return res def parse_channel_info(data: bytes) -> dict | None: """Parse channel info, matching meshcore reader field names.""" if len(data) < 50 or data[0] != RESP_CODE_CHANNEL_INFO: return None dbuf = io.BytesIO(data[1:]) res = {} res["channel_idx"] = dbuf.read(1)[0] name_bytes = dbuf.read(32) null_pos = name_bytes.find(0) if null_pos >= 0: res["channel_name"] = name_bytes[:null_pos].decode("utf-8", "ignore") else: res["channel_name"] = name_bytes.decode("utf-8", "ignore") res["channel_secret"] = dbuf.read(16) return res def parse_sent_response(data: bytes) -> dict | None: """Parse RESP_CODE_SENT, matching meshcore reader field names.""" if len(data) < 2 or data[0] != RESP_CODE_SENT: return None dbuf = io.BytesIO(data[1:]) res = {} res["type"] = dbuf.read(1)[0] res["expected_ack"] = dbuf.read(4) res["suggested_timeout"] = int.from_bytes(dbuf.read(4), byteorder="little") return res def parse_push_send_confirmed(data: bytes) -> dict | None: """Parse ACK push.""" if len(data) < 5 or data[0] != PUSH_CODE_SEND_CONFIRMED: return None res = {} res["code"] = data[1:5].hex() if len(data) >= 9: res["trip_time_ms"] = struct.unpack_from(" dict | None: """Parse auto-add config response.""" if len(data) < 2 or data[0] != RESP_CODE_AUTO_ADD_CONFIG: return None config = data[1] max_hops = data[2] if len(data) >= 3 else 0 return {"config": config, "max_hops": max_hops} def parse_default_flood_scope(data: bytes) -> dict | None: """Parse DEFAULT_FLOOD_SCOPE response (PacketType 28). 48-byte frame = scope set: [code][scope_name(31)][scope_key(16)]. 1-byte frame = no scope configured. """ if not data or data[0] != RESP_CODE_DEFAULT_FLOOD_SCOPE: return None res = {} if len(data) >= 48: res["scope_name"] = data[1:32].decode("utf-8", "ignore").replace("\0", "") res["scope_key"] = data[32:48].hex() return res def parse_curr_time(data: bytes) -> int | None: if len(data) < 5 or data[0] != RESP_CODE_CURR_TIME: return None return struct.unpack_from(" list[dict]: """Parse Cayenne LPP encoded telemetry data. Returns list of dicts with 'channel', 'type', 'name', 'value', 'unit' keys. """ results = [] buf = io.BytesIO(data) while buf.tell() < len(data) - 1: channel = buf.read(1) type_byte = buf.read(1) if len(channel) < 1 or len(type_byte) < 1: break ch = channel[0] tp = type_byte[0] if ch == 0 and tp == 0: break name = LPP_TYPE_NAMES.get(tp, f"Type {tp}") unit = LPP_TYPE_UNITS.get(tp, "") try: if tp in (LPP_DIGITAL_INPUT, LPP_DIGITAL_OUTPUT, LPP_PRESENCE, LPP_SWITCH): val = buf.read(1)[0] elif tp in (LPP_ANALOG_INPUT, LPP_ANALOG_OUTPUT): val = struct.unpack(">h", buf.read(2))[0] / 100.0 elif tp == LPP_GENERIC_SENSOR: val = struct.unpack(">I", buf.read(4))[0] elif tp == LPP_LUMINOSITY: val = struct.unpack(">H", buf.read(2))[0] elif tp == LPP_TEMPERATURE: val = struct.unpack(">h", buf.read(2))[0] / 10.0 elif tp == LPP_HUMIDITY: val = buf.read(1)[0] / 2.0 elif tp == LPP_PERCENTAGE: val = buf.read(1)[0] elif tp in (LPP_ACCELEROMETER, LPP_GYROMETER): raw = buf.read(6) scale = 0.001 if tp == LPP_ACCELEROMETER else 0.01 val = { "x": struct.unpack(">h", raw[0:2])[0] * scale, "y": struct.unpack(">h", raw[2:4])[0] * scale, "z": struct.unpack(">h", raw[4:6])[0] * scale, } elif tp == LPP_PRESSURE: val = struct.unpack(">H", buf.read(2))[0] / 10.0 elif tp == LPP_VOLTAGE: val = struct.unpack(">h", buf.read(2))[0] / 100.0 elif tp == LPP_CURRENT: val = struct.unpack(">h", buf.read(2))[0] / 1000.0 elif tp in (LPP_FREQUENCY, LPP_UNIX_TIME): val = struct.unpack(">I", buf.read(4))[0] elif tp == LPP_ALTITUDE: val = struct.unpack(">h", buf.read(2))[0] elif tp in (LPP_CONCENTRATION, LPP_POWER, LPP_DIRECTION): val = struct.unpack(">H", buf.read(2))[0] elif tp in (LPP_DISTANCE, LPP_ENERGY): val = struct.unpack(">I", buf.read(4))[0] / 1000.0 elif tp == LPP_COLOUR: raw = buf.read(3) val = {"r": raw[0], "g": raw[1], "b": raw[2]} elif tp == LPP_GPS: raw = buf.read(9) lat = int.from_bytes(raw[0:3], "big", signed=True) / 10000.0 lon = int.from_bytes(raw[3:6], "big", signed=True) / 10000.0 alt = int.from_bytes(raw[6:9], "big", signed=True) / 100.0 val = {"lat": lat, "lon": lon, "alt": alt} else: # Unknown type — can't determine size, stop parsing break results.append( { "channel": ch, "type": tp, "name": name, "value": val, "unit": unit, } ) except (struct.error, IndexError): break return results def build_export_private_key() -> bytes: return bytes([CMD_EXPORT_PRIVATE_KEY]) def build_import_private_key(key: bytes) -> bytes: return bytes([CMD_IMPORT_PRIVATE_KEY]) + key[:64] meshy/src/qr_scanner.py000066400000000000000000000272341523412555500155160ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """QR code scanner using XDG Camera Portal + PipeWire + GStreamer.""" import logging import os try: from pyzbar import pyzbar as _pyzbar except ImportError: _pyzbar = None import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Gst", "1.0") from gi.repository import Adw, Gio, GLib, Gst, Gtk log = logging.getLogger(__name__) Gst.init(None) PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" PORTAL_CAMERA_IFACE = "org.freedesktop.portal.Camera" PORTAL_REQUEST_IFACE = "org.freedesktop.portal.Request" class QRScannerDialog: """Dialog that opens the camera via XDG portal and scans for QR codes.""" def __init__(self, window, on_result): self._window = window self._on_result = on_result self._pipeline = None self._dialog = None self._signal_id = None def start(self): """Request camera access via the XDG portal.""" try: self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) except GLib.Error as e: log.error(f"Cannot connect to session bus: {e.message}") self._show_error(_("Cannot connect to session bus: {}").format(e.message)) return # Check if camera portal is available try: result = self._bus.call_sync( PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, "org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", (PORTAL_CAMERA_IFACE, "IsCameraPresent")), GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, 5000, None, ) is_present = result.get_child_value(0).get_variant().get_boolean() if not is_present: self._show_error(_("No camera detected on this system.")) return except GLib.Error as e: log.warning(f"Could not check IsCameraPresent: {e.message}") # Continue anyway, the portal might still work self._access_camera() def _access_camera(self): """Call AccessCamera on the portal.""" token = f"meshy_{os.getpid()}" sender = self._bus.get_unique_name().replace(".", "_").lstrip(":") request_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}" log.info(f"AccessCamera: subscribing to {request_path}") # Subscribe to the Response signal before making the call self._signal_id = self._bus.signal_subscribe( PORTAL_BUS_NAME, PORTAL_REQUEST_IFACE, "Response", request_path, None, Gio.DBusSignalFlags.NO_MATCH_RULE, self._on_access_response, ) params = GLib.Variant( "(a{sv})", ( { "handle_token": GLib.Variant("s", token), }, ), ) self._bus.call( PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, PORTAL_CAMERA_IFACE, "AccessCamera", params, None, # accept any reply type Gio.DBusCallFlags.NONE, 30000, None, self._on_access_call_done, ) def _on_access_call_done(self, bus, result): try: res = bus.call_finish(result) log.info(f"AccessCamera returned: {res}") except GLib.Error as e: log.error(f"AccessCamera call failed: {e.message}") self._cleanup_signal() self._show_error(_("Could not access camera: {}").format(e.message)) def _on_access_response(self, bus, sender, path, iface, signal, params): log.info(f"AccessCamera response received: {params}") self._cleanup_signal() response = params.get_child_value(0).get_uint32() if response != 0: log.warning(f"Camera access denied, response={response}") GLib.idle_add(lambda: self._show_error(_("Camera access was denied.")) or False) return # Permission granted, open PipeWire remote GLib.idle_add(lambda: self._open_pipewire_remote() or False) def _cleanup_signal(self): if self._signal_id: self._bus.signal_unsubscribe(self._signal_id) self._signal_id = None def _open_pipewire_remote(self): """Get a PipeWire fd from the portal.""" try: msg = Gio.DBusMessage.new_method_call( PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, PORTAL_CAMERA_IFACE, "OpenPipeWireRemote", ) msg.set_body(GLib.Variant("(a{sv})", ({},))) result = self._bus.send_message_with_reply_sync( msg, Gio.DBusSendMessageFlags.NONE, 5000, None, ) # send_message_with_reply_sync returns (reply_msg, out_serial) reply_msg = result[0] if isinstance(result, tuple) else result if reply_msg.get_message_type() == Gio.DBusMessageType.ERROR: error_name = reply_msg.get_error_name() log.error(f"OpenPipeWireRemote error: {error_name}") self._show_error(_("Could not open camera stream.")) return fd_list = reply_msg.get_unix_fd_list() if not fd_list or fd_list.get_length() == 0: log.error("OpenPipeWireRemote: no fd in reply") self._show_error(_("No camera file descriptor received.")) return pw_fd = fd_list.get(0) log.info(f"Got PipeWire fd: {pw_fd}") self._start_scanning(pw_fd) except GLib.Error as e: log.error(f"OpenPipeWireRemote failed: {e.message}") self._show_error(_("Could not open camera: {}").format(e.message)) except Exception as e: log.error(f"OpenPipeWireRemote unexpected error: {e}") self._show_error(_("Could not open camera: {}").format(e)) def _start_scanning(self, pw_fd): """Build GStreamer pipeline and show the camera dialog.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/qr-scanner-dialog.ui" ) self._dialog = builder.get_object("dialog") self._picture = builder.get_object("picture") self._status_label = builder.get_object("status_label") self._dialog.connect("closed", self._on_dialog_closed) # Build GStreamer pipeline pipeline_str = ( f"pipewiresrc fd={pw_fd} ! " "videoconvert ! " "video/x-raw,format=RGB ! " "tee name=t " "t. ! queue ! videoconvert ! gtk4paintablesink name=sink " "t. ! queue max-size-buffers=1 leaky=downstream ! " "videoscale ! video/x-raw,width=640,height=480 ! " "videoconvert ! video/x-raw,format=GRAY8 ! " "appsink name=qrsink emit-signals=true max-buffers=1 drop=true" ) try: self._pipeline = Gst.parse_launch(pipeline_str) except GLib.Error as e: log.error(f"Failed to create GStreamer pipeline: {e.message}") self._show_error(_("Camera pipeline error: {}").format(e.message)) return # Connect the paintable sink to the Picture widget sink = self._pipeline.get_by_name("sink") paintable = sink.get_property("paintable") self._picture.set_paintable(paintable) # Connect to appsink for QR decoding appsink = self._pipeline.get_by_name("qrsink") appsink.connect("new-sample", self._on_new_sample) # Watch for pipeline errors bus = self._pipeline.get_bus() bus.add_signal_watch() bus.connect("message::error", self._on_pipeline_error) self._pipeline.set_state(Gst.State.PLAYING) self._dialog.present(self._window) self._scan_active = True self._scan_start_time = GLib.get_monotonic_time() self._hint_shown = False self._timeout_shown = False self._hint_timer_id = GLib.timeout_add(8000, self._on_scan_hint) self._timeout_timer_id = GLib.timeout_add(30000, self._on_scan_timeout) def _on_new_sample(self, appsink): """Pull a frame from appsink and try to decode QR codes.""" if not self._scan_active: return Gst.FlowReturn.OK sample = appsink.emit("pull-sample") if not sample: return Gst.FlowReturn.OK buf = sample.get_buffer() caps = sample.get_caps() struct = caps.get_structure(0) width = struct.get_value("width") height = struct.get_value("height") struct.get_string("format") success, mapinfo = buf.map(Gst.MapFlags.READ) if not success: return Gst.FlowReturn.OK if _pyzbar is None: buf.unmap(mapinfo) return Gst.FlowReturn.OK try: results = _pyzbar.decode( (bytes(mapinfo.data), width, height), symbols=[_pyzbar.ZBarSymbol.QRCODE], ) except Exception as e: log.debug(f"QR decode error: {e}") buf.unmap(mapinfo) return Gst.FlowReturn.OK buf.unmap(mapinfo) for qr in results: data = qr.data.decode("utf-8", errors="ignore").strip() log.info(f"QR code detected: {data[:80]}...") self._scan_active = False GLib.idle_add(self._on_qr_found, data) return Gst.FlowReturn.OK return Gst.FlowReturn.OK def _on_scan_hint(self): """Show a hint after 8 seconds of no detection.""" self._hint_timer_id = None if self._scan_active: self._status_label.set_label( _( "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." ) ) return False def _on_scan_timeout(self): """Show a give-up message after 30 seconds.""" self._timeout_timer_id = None if self._scan_active: self._status_label.set_label( _("Could not detect QR code.\n" "Try importing the contact via clipboard instead.") ) return False def _on_qr_found(self, data): """Handle a detected meshcore:// QR code.""" self._status_label.set_label(_("QR code found!")) self._stop_pipeline() if self._dialog: self._dialog.close() self._on_result(data) return False def _on_pipeline_error(self, bus, msg): err, debug = msg.parse_error() log.error(f"GStreamer error: {err.message} ({debug})") GLib.idle_add(lambda: self._show_error(_("Camera error: {}").format(err.message)) or False) def _on_dialog_closed(self, dialog): self._stop_pipeline() def _stop_pipeline(self): self._scan_active = False if getattr(self, "_hint_timer_id", None): GLib.source_remove(self._hint_timer_id) self._hint_timer_id = None if getattr(self, "_timeout_timer_id", None): GLib.source_remove(self._timeout_timer_id) self._timeout_timer_id = None if self._pipeline: self._pipeline.set_state(Gst.State.NULL) self._pipeline = None def _show_error(self, message): dialog = Adw.AlertDialog(heading=_("Camera Error"), body=message) dialog.add_response("ok", _("OK")) dialog.present(self._window) meshy/src/repeater_helpers.py000066400000000000000000000035261523412555500167120ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Pure logic extracted from RepeaterView for testability without GTK.""" ACL_ROLES = { 3: "Admin", 2: "Read-Write", 1: "Read Only", } ROLE_PERMS = {0: 3, 1: 2, 2: 1} def parse_acl_response(data): """Parse raw ACL binary response into list of {'key': hex, 'perm': int}. Response format: [code(1)][reserved(1)][tag(4)][payload...] Payload: 7-byte entries: [pubkey_prefix(6)][perm(1)] """ buf = data[6:] entries = [] i = 0 while i + 7 <= len(buf): key = buf[i : i + 6].hex() perm = buf[i + 6] if key != "000000000000": entries.append({"key": key, "perm": perm}) i += 7 return entries def parse_region_text(region_text, default_text=""): """Parse region tree text and default scope into structured data. Returns (entries, default_scope_name) where each entry is: {"name": str, "indent": int, "home": bool, "flood": bool} """ default_name = "" if "default scope is" in default_text: val = default_text.split("default scope is", 1)[1].strip() if val and val != "": default_name = val entries = [] for line in region_text.split("\n"): if not line.strip(): continue indent = len(line) - len(line.lstrip(" ")) stripped = line.strip() flood = stripped.endswith(" F") if flood: stripped = stripped[:-2].strip() home = stripped.endswith("^") if home: stripped = stripped[:-1] entries.append( { "name": stripped, "indent": indent, "home": home, "flood": flood, } ) return entries, default_name meshy/src/smaz.py000066400000000000000000000111031523412555500143210ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """SMAZ decompression for interoperability with meshcore-open clients. Meshcore-open can compress short text messages using the SMAZ algorithm (dictionary-based compression for short ASCII strings). Compressed messages are sent with an 's:' prefix followed by base64-encoded SMAZ data. """ import base64 _VERBATIM_SINGLE = 254 _VERBATIM_RUN = 255 _CODEBOOK = [ " ", "the", "e", "t", "a", "of", "o", "and", "i", "n", "s", "e ", "r", " th", " t", "in", "he", "th", "h", "he ", "to", "\r\n", "l", "s ", "d", " a", "an", "er", "c", " o", "d ", "on", " of", "re", "of ", "t ", ", ", "is", "u", "at", " ", "n ", "or", "which", "f", "m", "as", "it", "that", "\n", "was", "en", " ", " w", "es", " an", " i", "\r", "f ", "g", "p", "nd", " s", "nd ", "ed ", "w", "ed", "http://", "for", "te", "ing", "y ", "The", " c", "ti", "r ", "his", "st", " in", "ar", "nt", ",", " to", "y", "ng", " h", "with", "le", "al", "to ", "b", "ou", "be", "were", " b", "se", "o ", "ent", "ha", "ng ", "their", '"', "hi", "from", " f", "in ", "de", "ion", "me", "v", ".", "ve", "all", "re ", "ri", "ro", "is ", "co", "f t", "are", "ea", ". ", "her", " m", "er ", " p", "es ", "by", "they", "di", "ra", "ic", "not", "s, ", "d t", "at ", "ce", "la", "h ", "ne", "as ", "tio", "on ", "n t", "io", "we", " a ", "om", ", a", "s o", "ur", "li", "ll", "ch", "had", "this", "e t", "g ", "e\r\n", " wh", "ere", " co", "e o", "a ", "us", " d", "ss", "\n\r\n", "\r\n\r", '="', " be", " e", "s a", "ma", "one", "t t", "or ", "but", "el", "so", "l ", "e s", "s,", "no", "ter", " wa", "iv", "ho", "e a", " r", "hat", "s t", "ns", "ch ", "wh", "tr", "ut", "/", "have", "ly ", "ta", " ha", " on", "tha", "-", " l", "ati", "en ", "pe", " re", "there", "ass", "si", " fo", "wa", "ec", "our", "who", "its", "z", "fo", "rs", ">", "ot", "un", "<", "im", "th ", "nc", "ate", "><", "ver", "ad", " we", "ly", "ee", " n", "id", " cl", "ac", "il", " bytes: out = bytearray() i = 0 while i < len(data): code = data[i] if code == _VERBATIM_SINGLE: if i + 1 >= len(data): break out.append(data[i + 1]) i += 2 elif code == _VERBATIM_RUN: if i + 1 >= len(data): break length = data[i + 1] + 1 end = i + 2 + length if end > len(data): break out.extend(data[i + 2 : end]) i = end elif code < len(_CODEBOOK_BYTES): out.extend(_CODEBOOK_BYTES[code]) i += 1 else: break return bytes(out) def try_decode_prefixed(text: str) -> str: """If text starts with 's:', decode base64 + SMAZ. Otherwise return as-is.""" stripped = text.lstrip() if not stripped.startswith("s:") or len(stripped) <= 2: return text encoded = stripped[2:] try: compressed = base64.b64decode(encoded, validate=True) except Exception: try: normalized = encoded.replace("-", "+").replace("_", "/") pad = len(normalized) % 4 if pad: normalized += "=" * (4 - pad) compressed = base64.b64decode(normalized) except Exception: return text try: return _decompress(compressed).decode("utf-8", errors="replace") except Exception: return text meshy/src/storage.py000066400000000000000000001243611523412555500150260ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """SQLite storage for contacts, messages, and channels.""" import contextlib import hashlib import os import sqlite3 import time from datetime import datetime from meshy.models import Channel, ChannelMessage, Contact, Message, MessageStatus, NotificationLevel class Storage: @staticmethod def db_path_for_device(device_address: str) -> str: data_dir = os.path.join( os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "meshy" ) safe_addr = device_address.replace(":", "_").replace("/", "_") return os.path.join(data_dir, f"device_{safe_addr}.db") @staticmethod def delete_device_db(device_address: str): path = Storage.db_path_for_device(device_address) for suffix in ("", "-wal", "-shm"): with contextlib.suppress(FileNotFoundError): os.remove(path + suffix) def __init__(self, db_path: str | None = None, device_address: str | None = None): if db_path is None: if device_address: db_path = self.db_path_for_device(device_address) os.makedirs(os.path.dirname(db_path), exist_ok=True) else: data_dir = os.path.join( os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "meshy" ) os.makedirs(data_dir, exist_ok=True) db_path = os.path.join(data_dir, "meshy.db") self._db = sqlite3.connect(db_path) self._db.row_factory = sqlite3.Row self._db.execute("PRAGMA journal_mode=WAL") self._batch_depth = 0 self._create_tables() def _create_tables(self): self._db.executescript(""" CREATE TABLE IF NOT EXISTS contacts ( public_key_hex TEXT PRIMARY KEY, name TEXT NOT NULL, type INTEGER NOT NULL DEFAULT 1, flags INTEGER NOT NULL DEFAULT 0, path_length INTEGER NOT NULL DEFAULT -1, path BLOB NOT NULL DEFAULT X'', latitude REAL, longitude REAL, last_seen REAL, last_message_at REAL, is_active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, contact_key_hex TEXT NOT NULL, sender_key_hex TEXT NOT NULL, text TEXT NOT NULL, timestamp REAL NOT NULL, is_outgoing INTEGER NOT NULL, is_cli INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, message_id TEXT, FOREIGN KEY (contact_key_hex) REFERENCES contacts(public_key_hex) ); CREATE TABLE IF NOT EXISTS channels ( idx INTEGER PRIMARY KEY, name TEXT NOT NULL DEFAULT '', psk BLOB NOT NULL DEFAULT X'00000000000000000000000000000000', unread_count INTEGER NOT NULL DEFAULT 0, notification_level INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_index INTEGER NOT NULL, sender_name TEXT NOT NULL DEFAULT '', text TEXT NOT NULL, timestamp REAL NOT NULL, is_outgoing INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (channel_index) REFERENCES channels(idx) ); CREATE TABLE IF NOT EXISTS channel_message_paths ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_index INTEGER NOT NULL, sender_timestamp INTEGER NOT NULL, msg_text_hash TEXT NOT NULL, snr REAL, path_len INTEGER NOT NULL DEFAULT -1, path_bytes TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS regions ( name TEXT PRIMARY KEY ); CREATE TABLE IF NOT EXISTS discovered_contacts ( public_key_hex TEXT PRIMARY KEY, name TEXT NOT NULL, type INTEGER NOT NULL DEFAULT 1, flags INTEGER NOT NULL DEFAULT 0, path_length INTEGER NOT NULL DEFAULT -1, path BLOB NOT NULL DEFAULT X'', path_hash_size INTEGER NOT NULL DEFAULT 1, latitude REAL, longitude REAL, last_seen REAL ); CREATE INDEX IF NOT EXISTS idx_messages_contact ON messages(contact_key_hex, timestamp); CREATE INDEX IF NOT EXISTS idx_channel_messages_channel ON channel_messages(channel_index, timestamp); CREATE INDEX IF NOT EXISTS idx_channel_message_paths ON channel_message_paths(channel_index, sender_timestamp, msg_text_hash); """) self._db.commit() self._migrate() def _migrate(self): """Add columns that may not exist in older databases.""" cursor = self._db.execute("PRAGMA table_info(channels)") ch_columns = {row[1] for row in cursor.fetchall()} if "notification_level" not in ch_columns: self._db.execute( "ALTER TABLE channels ADD COLUMN notification_level INTEGER NOT NULL DEFAULT 0" ) if "last_read_at" not in ch_columns: self._db.execute("ALTER TABLE channels ADD COLUMN last_read_at REAL") if "region" not in ch_columns: self._db.execute("ALTER TABLE channels ADD COLUMN region TEXT NOT NULL DEFAULT ''") if "retention_days" not in ch_columns: self._db.execute( "ALTER TABLE channels ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 0" ) cursor = self._db.execute("PRAGMA table_info(contacts)") ct_columns = {row[1] for row in cursor.fetchall()} if "last_read_at" not in ct_columns: self._db.execute("ALTER TABLE contacts ADD COLUMN last_read_at REAL") if "path_hash_size" not in ct_columns: self._db.execute( "ALTER TABLE contacts ADD COLUMN path_hash_size INTEGER NOT NULL DEFAULT 1" ) if "device_lastmod" not in ct_columns: self._db.execute("ALTER TABLE contacts ADD COLUMN device_lastmod REAL") if "force_flood" not in ct_columns: self._db.execute( "ALTER TABLE contacts ADD COLUMN force_flood INTEGER NOT NULL DEFAULT 0" ) if "auto_reset_path" not in ct_columns: self._db.execute( "ALTER TABLE contacts ADD COLUMN auto_reset_path INTEGER NOT NULL DEFAULT 1" ) if "smart_route_selection" not in ct_columns: self._db.execute( "ALTER TABLE contacts ADD COLUMN smart_route_selection INTEGER NOT NULL DEFAULT -1" ) else: self._db.execute( "UPDATE contacts SET smart_route_selection = -1 WHERE smart_route_selection = 0" ) cursor = self._db.execute("PRAGMA table_info(messages)") msg_columns = {row[1] for row in cursor.fetchall()} if "room_sender_name" not in msg_columns: self._db.execute( "ALTER TABLE messages ADD COLUMN room_sender_name TEXT NOT NULL DEFAULT ''" ) cursor = self._db.execute("PRAGMA table_info(discovered_contacts)") dc_columns = {row[1] for row in cursor.fetchall()} if "received_at" not in dc_columns: self._db.execute("ALTER TABLE discovered_contacts ADD COLUMN received_at REAL") self._db.execute(""" CREATE TABLE IF NOT EXISTS room_passwords ( room_key_hex TEXT PRIMARY KEY, password TEXT NOT NULL ) """) self._db.execute(""" CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ) """) self._db.execute(""" CREATE TABLE IF NOT EXISTS discovered_contacts ( public_key_hex TEXT PRIMARY KEY, name TEXT NOT NULL, type INTEGER NOT NULL DEFAULT 1, flags INTEGER NOT NULL DEFAULT 0, path_length INTEGER NOT NULL DEFAULT -1, path BLOB NOT NULL DEFAULT X'', path_hash_size INTEGER NOT NULL DEFAULT 1, latitude REAL, longitude REAL, last_seen REAL ) """) self._db.execute(""" CREATE TABLE IF NOT EXISTS path_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, contact_key_hex TEXT NOT NULL, path_bytes TEXT NOT NULL, path_hash_size INTEGER NOT NULL DEFAULT 1, hop_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, failure_count INTEGER NOT NULL DEFAULT 0, last_trip_ms INTEGER, route_weight REAL NOT NULL DEFAULT 2.5, last_success_at REAL, UNIQUE(contact_key_hex, path_bytes) ) """) self._db.execute(""" CREATE TABLE IF NOT EXISTS blocked_channel_senders ( sender_name TEXT PRIMARY KEY ) """) self._db.execute(""" CREATE INDEX IF NOT EXISTS idx_messages_contact_outgoing ON messages(contact_key_hex, is_outgoing, timestamp) """) self._db.execute(""" CREATE INDEX IF NOT EXISTS idx_channel_messages_unread ON channel_messages(channel_index, is_outgoing, timestamp) """) self._db.commit() self._cleanup_old_data() def _cleanup_old_data(self): """Remove stale data that is no longer relevant.""" import time week_ago = time.time() - 7 * 86400 self._db.execute( "DELETE FROM channel_message_paths WHERE sender_timestamp < ?", (int(week_ago),) ) ninety_days_ago = time.time() - 90 * 86400 self._db.execute("DELETE FROM discovered_contacts WHERE last_seen < ?", (ninety_days_ago,)) now = time.time() self._db.execute( """DELETE FROM channel_messages WHERE EXISTS ( SELECT 1 FROM channels WHERE channels.idx = channel_messages.channel_index AND channels.retention_days > 0 AND channel_messages.timestamp < ? - channels.retention_days * 86400 )""", (now,), ) self._db.commit() def _commit(self): if self._batch_depth == 0: self._db.commit() @contextlib.contextmanager def batch(self): self._batch_depth += 1 try: yield finally: self._batch_depth -= 1 if self._batch_depth == 0: self._commit() def close(self): self._db.close() # ─── Contacts ────────────────────────────────────────────── def save_contact(self, contact: Contact): self._db.execute( """ INSERT INTO contacts (public_key_hex, name, type, flags, path_length, path, path_hash_size, force_flood, auto_reset_path, smart_route_selection, latitude, longitude, last_seen, device_lastmod, last_message_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(public_key_hex) DO UPDATE SET name = excluded.name, type = excluded.type, flags = excluded.flags, path_length = excluded.path_length, path = excluded.path, path_hash_size = excluded.path_hash_size, force_flood = excluded.force_flood, auto_reset_path = excluded.auto_reset_path, smart_route_selection = excluded.smart_route_selection, latitude = excluded.latitude, longitude = excluded.longitude, last_seen = excluded.last_seen, device_lastmod = excluded.device_lastmod, last_message_at = COALESCE(excluded.last_message_at, contacts.last_message_at), is_active = excluded.is_active """, ( contact.public_key_hex, contact.name, contact.type, contact.flags, contact.path_length, contact.path, contact.path_hash_size, int(contact.force_flood), int(contact.auto_reset_path), -1 if contact.smart_route_selection is None else int(contact.smart_route_selection), contact.latitude, contact.longitude, contact.last_seen.timestamp() if contact.last_seen else None, contact.device_lastmod.timestamp() if contact.device_lastmod else None, contact.last_message_at.timestamp() if contact.last_message_at else None, int(contact.is_active), ), ) self._commit() def deactivate_contact(self, public_key_hex: str): """Mark a contact as inactive (no longer on the device).""" self._db.execute( "UPDATE contacts SET is_active = 0 WHERE public_key_hex = ?", (public_key_hex,) ) self._commit() def get_contacts(self) -> list[Contact]: rows = self._db.execute( "SELECT * FROM contacts WHERE is_active = 1 ORDER BY last_seen DESC" ).fetchall() return [self._row_to_contact(r) for r in rows] def get_max_device_lastmod(self) -> int | None: """Return the highest device_lastmod timestamp among active contacts.""" row = self._db.execute( "SELECT MAX(device_lastmod) FROM contacts WHERE is_active = 1" ).fetchone() val = row[0] if row else None return int(val) if val else None def get_contact(self, public_key_hex: str) -> Contact | None: row = self._db.execute( "SELECT * FROM contacts WHERE public_key_hex = ?", (public_key_hex,) ).fetchone() return self._row_to_contact(row) if row else None def remove_contact(self, public_key_hex: str): self._db.execute("DELETE FROM contacts WHERE public_key_hex = ?", (public_key_hex,)) self._db.execute("DELETE FROM messages WHERE contact_key_hex = ?", (public_key_hex,)) self._commit() def find_contact_by_prefix(self, prefix: bytes) -> Contact | None: """Find a contact by the first 6 bytes of its public key.""" prefix_hex = prefix.hex() upper = prefix_hex[:-1] + chr(ord(prefix_hex[-1]) + 1) row = self._db.execute( "SELECT * FROM contacts WHERE public_key_hex >= ? AND public_key_hex < ?", (prefix_hex, upper), ).fetchone() return self._row_to_contact(row) if row else None def _row_to_contact(self, row) -> Contact: return Contact( public_key=bytes.fromhex(row["public_key_hex"]), name=row["name"], type=row["type"], flags=row["flags"], path_length=row["path_length"], path=bytes(row["path"]), path_hash_size=row["path_hash_size"], force_flood=bool(row["force_flood"]), auto_reset_path=bool(row["auto_reset_path"]), smart_route_selection=None if row["smart_route_selection"] == -1 else bool(row["smart_route_selection"]), latitude=row["latitude"], longitude=row["longitude"], last_seen=datetime.fromtimestamp(row["last_seen"]) if row["last_seen"] else None, device_lastmod=datetime.fromtimestamp(row["device_lastmod"]) if row["device_lastmod"] else None, last_message_at=datetime.fromtimestamp(row["last_message_at"]) if row["last_message_at"] else None, is_active=bool(row["is_active"]), ) def get_oldest_unread_contact_timestamp(self, contact_key_hex: str) -> float | None: """Return the timestamp of the oldest unread incoming message for a contact.""" row = self._db.execute( """ SELECT MIN(timestamp) as ts FROM messages WHERE contact_key_hex = ? AND is_outgoing = 0 AND timestamp > COALESCE( (SELECT last_read_at FROM contacts WHERE public_key_hex = ?), 0) """, (contact_key_hex, contact_key_hex), ).fetchone() return row["ts"] if row and row["ts"] else None def get_contact_last_read_at(self, public_key_hex: str) -> float | None: row = self._db.execute( "SELECT last_read_at FROM contacts WHERE public_key_hex = ?", (public_key_hex,) ).fetchone() return row["last_read_at"] if row and row["last_read_at"] else None def set_contact_last_read_at(self, public_key_hex: str, timestamp: float): self._db.execute( "UPDATE contacts SET last_read_at = ? WHERE public_key_hex = ?", (timestamp, public_key_hex), ) self._commit() # ─── Discovered Contacts ───────────────────────────────── def save_discovered_contact(self, contact: Contact): self._db.execute( """ INSERT INTO discovered_contacts (public_key_hex, name, type, flags, path_length, path, path_hash_size, latitude, longitude, last_seen, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(public_key_hex) DO UPDATE SET name = excluded.name, type = excluded.type, flags = excluded.flags, path_length = excluded.path_length, path = excluded.path, path_hash_size = excluded.path_hash_size, latitude = excluded.latitude, longitude = excluded.longitude, last_seen = excluded.last_seen, received_at = excluded.received_at """, ( contact.public_key_hex, contact.name, contact.type, contact.flags, contact.path_length, contact.path, contact.path_hash_size, contact.latitude, contact.longitude, contact.last_seen.timestamp() if contact.last_seen else None, contact.received_at.timestamp() if contact.received_at else None, ), ) self._commit() def get_discovered_contacts(self) -> list[Contact]: rows = self._db.execute( "SELECT * FROM discovered_contacts ORDER BY last_seen DESC" ).fetchall() return [self._row_to_discovered_contact(r) for r in rows] def _row_to_discovered_contact(self, row) -> Contact: return Contact( public_key=bytes.fromhex(row["public_key_hex"]), name=row["name"], type=row["type"], flags=row["flags"], path_length=row["path_length"], path=bytes(row["path"]), path_hash_size=row["path_hash_size"], latitude=row["latitude"], longitude=row["longitude"], last_seen=datetime.fromtimestamp(row["last_seen"]) if row["last_seen"] else None, received_at=datetime.fromtimestamp(row["received_at"]) if row["received_at"] else None, is_active=True, ) def count_unread_messages(self, contact_key_hex: str) -> int: """Count incoming messages newer than last_read_at for a contact.""" row = self._db.execute( """ SELECT COUNT(*) as cnt FROM messages WHERE contact_key_hex = ? AND is_outgoing = 0 AND timestamp > COALESCE( (SELECT last_read_at FROM contacts WHERE public_key_hex = ?), 0) """, (contact_key_hex, contact_key_hex), ).fetchone() return row["cnt"] if row else 0 def count_unread_channel_messages(self, channel_index: int) -> int: """Count channel messages newer than last_read_at.""" row = self._db.execute( """ SELECT COUNT(*) as cnt FROM channel_messages WHERE channel_index = ? AND is_outgoing = 0 AND timestamp > COALESCE( (SELECT last_read_at FROM channels WHERE idx = ?), 0) """, (channel_index, channel_index), ).fetchone() return row["cnt"] if row else 0 # ─── Messages ────────────────────────────────────────────── def save_message(self, contact_key_hex: str, message: Message): self._db.execute( """ INSERT INTO messages (contact_key_hex, sender_key_hex, text, timestamp, is_outgoing, is_cli, status, message_id, room_sender_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( contact_key_hex, message.sender_key_hex, message.text, message.timestamp.timestamp(), int(message.is_outgoing), int(message.is_cli), int(message.status), message.message_id, message.room_sender_name, ), ) # Update contact's last_message_at self._db.execute( """ UPDATE contacts SET last_message_at = ? WHERE public_key_hex = ? """, (message.timestamp.timestamp(), contact_key_hex), ) self._commit() def get_messages(self, contact_key_hex: str, limit: int = 30, offset: int = 0) -> list[Message]: rows = self._db.execute( """ SELECT * FROM messages WHERE contact_key_hex = ? ORDER BY timestamp DESC LIMIT ? OFFSET ? """, (contact_key_hex, limit, offset), ).fetchall() return [self._row_to_message(r) for r in reversed(rows)] def has_duplicate_message(self, contact_key_hex: str, timestamp: float, text: str) -> bool: """Check if an incoming message is a duplicate. Two layers: first, an exact timestamp+text match catches firmware retransmissions that slipped through. Second, a check against the last incoming message from the contact within 30 minutes catches manual resends (new timestamp, same text, no reply from us in between). """ # Layer 1: exact sender_timestamp match (firmware duplicate) exact = self._db.execute( """ SELECT 1 FROM messages WHERE contact_key_hex = ? AND text = ? AND is_outgoing = 0 AND timestamp = ? LIMIT 1 """, (contact_key_hex, text, timestamp), ).fetchone() if exact is not None: return True # Layer 2: manual resend (same text, last incoming, within 30 min) row = self._db.execute( """ SELECT text, timestamp FROM messages WHERE contact_key_hex = ? AND is_outgoing = 0 ORDER BY timestamp DESC LIMIT 1 """, (contact_key_hex,), ).fetchone() if row is None: return False last_text, last_ts = row if last_text != text or (time.time() - last_ts) > 1800: return False # If we sent a reply after the last incoming message, the # conversation has moved on and this is a new response. outgoing = self._db.execute( """ SELECT 1 FROM messages WHERE contact_key_hex = ? AND is_outgoing = 1 AND timestamp > ? LIMIT 1 """, (contact_key_hex, last_ts), ).fetchone() return outgoing is None def message_exists(self, contact_key_hex: str, timestamp: float, text: str) -> bool: """Check if a specific message already exists (for backup restore).""" row = self._db.execute( """ SELECT 1 FROM messages WHERE contact_key_hex = ? AND text = ? AND is_outgoing = 0 AND abs(timestamp - ?) <= 5 LIMIT 1 """, (contact_key_hex, text, timestamp), ).fetchone() return row is not None def delete_message(self, message_id: str): self._db.execute("DELETE FROM messages WHERE message_id = ?", (message_id,)) self._commit() def update_message_status(self, message_id: str, status: MessageStatus): self._db.execute( "UPDATE messages SET status = ? WHERE message_id = ?", (int(status), message_id) ) self._commit() def fail_orphaned_messages(self): """Mark any PENDING or SENT outgoing messages as FAILED. Called on disconnect to clean up messages that were in flight when the connection dropped. """ self._db.execute( "UPDATE messages SET status = ? WHERE is_outgoing = 1 AND status IN (?, ?)", (int(MessageStatus.FAILED), int(MessageStatus.PENDING), int(MessageStatus.SENT)), ) self._commit() def _row_to_message(self, row) -> Message: return Message( sender_key=bytes.fromhex(row["sender_key_hex"]), text=row["text"], timestamp=datetime.fromtimestamp(row["timestamp"]), is_outgoing=bool(row["is_outgoing"]), is_cli=bool(row["is_cli"]), status=MessageStatus(row["status"]), message_id=row["message_id"], room_sender_name=row["room_sender_name"], ) # ─── Room Passwords ─────────────────────────────────────── def save_room_password(self, room_key_hex: str, password: str): self._db.execute( "INSERT OR REPLACE INTO room_passwords (room_key_hex, password) VALUES (?, ?)", (room_key_hex, password), ) self._commit() def get_room_password(self, room_key_hex: str) -> str | None: row = self._db.execute( "SELECT password FROM room_passwords WHERE room_key_hex = ?", (room_key_hex,) ).fetchone() return row["password"] if row else None def remove_room_password(self, room_key_hex: str): self._db.execute("DELETE FROM room_passwords WHERE room_key_hex = ?", (room_key_hex,)) self._commit() # ─── Channels ────────────────────────────────────────────── def save_channel(self, channel: Channel): self._db.execute( """ INSERT INTO channels (idx, name, psk, unread_count, notification_level, region, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(idx) DO UPDATE SET name = excluded.name, psk = excluded.psk, unread_count = excluded.unread_count, notification_level = excluded.notification_level, region = excluded.region, retention_days = excluded.retention_days """, ( channel.index, channel.name, channel.psk, channel.unread_count, int(channel.notification_level), channel.region, channel.retention_days, ), ) self._commit() def get_channels(self) -> list[Channel]: rows = self._db.execute("SELECT * FROM channels ORDER BY idx").fetchall() return [self._row_to_channel(r) for r in rows] def get_channel(self, index: int) -> Channel | None: row = self._db.execute("SELECT * FROM channels WHERE idx = ?", (index,)).fetchone() return self._row_to_channel(row) if row else None def _row_to_channel(self, row) -> Channel: return Channel( index=row["idx"], name=row["name"], psk=bytes(row["psk"]), unread_count=row["unread_count"], notification_level=NotificationLevel(row["notification_level"]), region=row["region"].split(",")[0].strip(), retention_days=row["retention_days"], ) def clear_channel_messages(self, index: int): self._db.execute("DELETE FROM channel_messages WHERE channel_index = ?", (index,)) self._commit() def remove_channel(self, index: int): self._db.execute("DELETE FROM channels WHERE idx = ?", (index,)) self._db.execute("DELETE FROM channel_messages WHERE channel_index = ?", (index,)) self._commit() def set_channel_notification_level(self, index: int, level: NotificationLevel): self._db.execute( "UPDATE channels SET notification_level = ? WHERE idx = ?", (int(level), index) ) self._commit() def set_channel_region(self, index: int, region: str): self._db.execute("UPDATE channels SET region = ? WHERE idx = ?", (region, index)) self._commit() def set_channel_retention_days(self, index: int, days: int): self._db.execute("UPDATE channels SET retention_days = ? WHERE idx = ?", (days, index)) self._commit() # ─── Regions ────────────────────────────────────────────── def get_regions(self) -> list[str]: rows = self._db.execute("SELECT name FROM regions ORDER BY name").fetchall() return [r["name"] for r in rows] def add_region(self, name: str): self._db.execute("INSERT OR IGNORE INTO regions (name) VALUES (?)", (name,)) self._commit() def remove_region(self, name: str): self._db.execute("DELETE FROM regions WHERE name = ?", (name,)) # Clear region from channels that used it self._db.execute("UPDATE channels SET region = '' WHERE region = ?", (name,)) # Clear default scope if it was this region row = self._db.execute("SELECT value FROM settings WHERE key = 'default_scope'").fetchone() if row and row["value"] == name: self._db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('default_scope', '')" ) self._commit() def get_default_scope(self) -> str: row = self._db.execute("SELECT value FROM settings WHERE key = 'default_scope'").fetchone() return row["value"] if row else "" def set_default_scope(self, scope: str): self._db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('default_scope', ?)", (scope,) ) self._commit() def get_oldest_unread_channel_timestamp(self, channel_index: int) -> float | None: """Return the timestamp of the oldest unread incoming channel message.""" row = self._db.execute( """ SELECT MIN(timestamp) as ts FROM channel_messages WHERE channel_index = ? AND is_outgoing = 0 AND timestamp > COALESCE( (SELECT last_read_at FROM channels WHERE idx = ?), 0) """, (channel_index, channel_index), ).fetchone() return row["ts"] if row and row["ts"] else None def get_channel_last_read_at(self, index: int) -> float | None: row = self._db.execute( "SELECT last_read_at FROM channels WHERE idx = ?", (index,) ).fetchone() return row["last_read_at"] if row and row["last_read_at"] else None def get_max_channel_message_timestamp(self, channel_index: int) -> float | None: """Return the newest message timestamp for a channel.""" row = self._db.execute( "SELECT MAX(timestamp) as ts FROM channel_messages WHERE channel_index = ?", (channel_index,), ).fetchone() return row["ts"] if row and row["ts"] else None def set_channel_last_read_at(self, index: int, timestamp: float): self._db.execute("UPDATE channels SET last_read_at = ? WHERE idx = ?", (timestamp, index)) self._commit() # ─── Channel Messages ────────────────────────────────────── def save_channel_message(self, msg: ChannelMessage): self._db.execute( """ INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) VALUES (?, ?, ?, ?, ?) """, ( msg.channel_index, msg.sender_name, msg.text, msg.timestamp.timestamp(), int(msg.is_outgoing), ), ) self._commit() def delete_channel_message(self, channel_index: int, timestamp: float, text: str): self._db.execute( """ DELETE FROM channel_messages WHERE channel_index = ? AND abs(timestamp - ?) < 1 AND text = ? """, (channel_index, timestamp, text), ) self._commit() def get_channel_messages( self, channel_index: int, limit: int = 30, offset: int = 0 ) -> list[ChannelMessage]: rows = self._db.execute( """ SELECT * FROM channel_messages WHERE channel_index = ? ORDER BY timestamp DESC LIMIT ? OFFSET ? """, (channel_index, limit, offset), ).fetchall() return [ ChannelMessage( channel_index=r["channel_index"], sender_name=r["sender_name"], text=r["text"], timestamp=datetime.fromtimestamp(r["timestamp"]), is_outgoing=bool(r["is_outgoing"]), ) for r in reversed(rows) ] # ─── Channel Message Paths ──────────────────────────────── @staticmethod def _msg_text_hash(text: str) -> str: return hashlib.md5(text.encode("utf-8")).hexdigest()[:16] def save_channel_message_path( self, channel_index: int, sender_timestamp: int, text: str, path_info: dict ): text_hash = self._msg_text_hash(text) self._db.execute( """ INSERT INTO channel_message_paths (channel_index, sender_timestamp, msg_text_hash, snr, path_len, path_bytes) VALUES (?, ?, ?, ?, ?, ?) """, ( channel_index, sender_timestamp, text_hash, path_info.get("snr"), path_info.get("path_len", -1), path_info.get("path_bytes", ""), ), ) self._commit() def get_channel_message_paths( self, channel_index: int, sender_timestamp: int, text: str ) -> list[dict]: text_hash = self._msg_text_hash(text) rows = self._db.execute( """ SELECT snr, path_len, path_bytes FROM channel_message_paths WHERE channel_index = ? AND sender_timestamp = ? AND msg_text_hash = ? """, (channel_index, sender_timestamp, text_hash), ).fetchall() return [ {"snr": r["snr"], "path_len": r["path_len"], "path_bytes": r["path_bytes"]} for r in rows ] # ─── Path History ───────────────────────────────────────── def upsert_path_record( self, contact_key_hex: str, path_bytes: str, path_hash_size: int, hop_count: int, success: bool, trip_ms: int | None = None, initial_weight: float = 2.5, weight_increment: float = 0.5, max_weight: float = 5.0, ): import time row = self._db.execute( "SELECT id, success_count, failure_count, route_weight " "FROM path_history WHERE contact_key_hex = ? AND path_bytes = ?", (contact_key_hex, path_bytes), ).fetchone() if row: new_success = row["success_count"] + (1 if success else 0) new_failure = row["failure_count"] + (0 if success else 1) new_weight = row["route_weight"] + weight_increment if new_weight <= 0: self._db.execute("DELETE FROM path_history WHERE id = ?", (row["id"],)) else: self._db.execute( """UPDATE path_history SET success_count = ?, failure_count = ?, route_weight = ?, last_trip_ms = COALESCE(?, last_trip_ms), last_success_at = CASE WHEN ? THEN ? ELSE last_success_at END WHERE id = ?""", ( new_success, new_failure, min(new_weight, max_weight), trip_ms, success, time.time() if success else None, row["id"], ), ) elif success: self._db.execute( """INSERT INTO path_history (contact_key_hex, path_bytes, path_hash_size, hop_count, success_count, failure_count, last_trip_ms, route_weight, last_success_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( contact_key_hex, path_bytes, path_hash_size, hop_count, 1, 0, trip_ms, initial_weight, time.time(), ), ) self._commit() def get_path_history(self, contact_key_hex: str) -> list: rows = self._db.execute( "SELECT * FROM path_history WHERE contact_key_hex = ? ORDER BY last_success_at DESC LIMIT 100", (contact_key_hex,), ).fetchall() return [dict(r) for r in rows] def clear_path_history(self, contact_key_hex: str): self._db.execute("DELETE FROM path_history WHERE contact_key_hex = ?", (contact_key_hex,)) self._commit() def delete_path_record(self, record_id: int): self._db.execute("DELETE FROM path_history WHERE id = ?", (record_id,)) self._commit() def import_path_history_entry( self, contact_key_hex: str, path_bytes: str, path_hash_size: int, hop_count: int, success_count: int, failure_count: int, last_trip_ms: int | None, route_weight: float, last_success_at: float | None, ): self._db.execute( """INSERT INTO path_history (contact_key_hex, path_bytes, path_hash_size, hop_count, success_count, failure_count, last_trip_ms, route_weight, last_success_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(contact_key_hex, path_bytes) DO UPDATE SET success_count = excluded.success_count, failure_count = excluded.failure_count, last_trip_ms = excluded.last_trip_ms, route_weight = excluded.route_weight, last_success_at = excluded.last_success_at""", ( contact_key_hex, path_bytes, path_hash_size, hop_count, success_count, failure_count, last_trip_ms, route_weight, last_success_at, ), ) self._commit() # ─── Blocked Channel Senders ───────────────────────────── def get_blocked_channel_senders(self) -> set[str]: rows = self._db.execute("SELECT sender_name FROM blocked_channel_senders").fetchall() return {r["sender_name"] for r in rows} def block_channel_sender(self, name: str): self._db.execute( "INSERT OR IGNORE INTO blocked_channel_senders (sender_name) VALUES (?)", (name,), ) self._commit() def unblock_channel_sender(self, name: str): self._db.execute("DELETE FROM blocked_channel_senders WHERE sender_name = ?", (name,)) self._commit() def is_channel_sender_blocked(self, name: str) -> bool: row = self._db.execute( "SELECT 1 FROM blocked_channel_senders WHERE sender_name = ?", (name,) ).fetchone() return row is not None def get_channel_senders(self, channel_index: int) -> list[dict]: rows = self._db.execute( """ SELECT sender_name, COUNT(*) as message_count, MAX(timestamp) as last_seen FROM channel_messages WHERE channel_index = ? AND sender_name != '' AND is_outgoing = 0 GROUP BY sender_name ORDER BY last_seen DESC """, (channel_index,), ).fetchall() return [dict(r) for r in rows] def get_channel_participants(self, channel_index: int) -> list[dict]: """Return channel senders merged with blocked senders who have no messages left.""" senders = self.get_channel_senders(channel_index) blocked = self.get_blocked_channel_senders() seen = {s["sender_name"] for s in senders} for s in senders: s["is_blocked"] = s["sender_name"] in blocked for name in sorted(blocked - seen): senders.append( { "sender_name": name, "message_count": 0, "last_seen": None, "is_blocked": True, } ) return senders def delete_channel_messages_by_sender(self, channel_index: int, sender_name: str): self._db.execute( "DELETE FROM channel_messages WHERE channel_index = ? AND sender_name = ?", (channel_index, sender_name), ) self._commit() meshy/src/tcp.py000066400000000000000000000106601523412555500141440ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """TCP transport for MeshCore companion devices over WiFi. Uses the same frame format as USB serial. """ import contextlib import logging import socket from gi.repository import GLib from meshy.transport_base import ( RX_MARKER, TX_MARKER, ConnectionState, FrameTransport, ) log = logging.getLogger(__name__) TCP_DEFAULT_PORT = 5000 TCP_CONNECT_TIMEOUT = 10 # seconds class TcpDevice: """Represents a TCP endpoint for connection view compatibility.""" def __init__(self, host: str, port: int): self.host = host self.port = port self.path = f"{host}:{port}" self.address = self.path self.name = f"TCP {host}:{port}" def __repr__(self): return f"TcpDevice({self.path})" class TcpTransport(FrameTransport): """TCP transport for MeshCore companion devices over WiFi.""" _LABEL = "TCP" # Accept both markers — meshcore-proxy sends responses framed with 0x3C _RX_MARKERS = frozenset((RX_MARKER, TX_MARKER)) def __init__(self): super().__init__() self._socket: socket.socket | None = None def get_paired_devices(self) -> list: """TCP doesn't have paired devices.""" return [] def connect_device(self, device_path: str): """Connect to a TCP endpoint. device_path is 'host:port'.""" if self._state == ConnectionState.CONNECTED: self.disconnect() self._set_state(ConnectionState.CONNECTING) try: host, port_str = device_path.rsplit(":", 1) port = int(port_str) except (ValueError, AttributeError): log.error(f"Invalid TCP address: {device_path}") self._set_state(ConnectionState.DISCONNECTED) return try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(TCP_CONNECT_TIMEOUT) sock.connect((host, port)) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.settimeout(None) # blocking reads in thread self._socket = sock self._connected_address = device_path log.info(f"Connected to TCP {host}:{port}") self._start_read_thread() self._set_state(ConnectionState.CONNECTED) except Exception as e: log.error(f"TCP connect failed ({device_path}): {e}") if self._socket: with contextlib.suppress(Exception): self._socket.close() self._socket = None self._set_state(ConnectionState.DISCONNECTED) def disconnect(self): """Disconnect from the TCP endpoint.""" if self._state in (ConnectionState.DISCONNECTED, ConnectionState.DISCONNECTING): return self._set_state(ConnectionState.DISCONNECTING) self._running = False if self._socket: with contextlib.suppress(Exception): self._socket.shutdown(socket.SHUT_RDWR) self._close_io() self._connected_address = None self._set_state(ConnectionState.DISCONNECTED) # ─── FrameTransport implementation ──────────────────────── def _is_io_ready(self) -> bool: return self._socket is not None def _write_bytes(self, frame: bytes): self._socket.sendall(frame) def _close_io(self): if self._socket: with contextlib.suppress(Exception): self._socket.close() self._socket = None def _read_loop(self, gen: int): """Background thread that reads from the TCP socket.""" sock = self._socket while self._running and gen == self._connect_gen and sock: try: data = sock.recv(4096) if not data: if self._running and gen == self._connect_gen: log.info("TCP: remote closed connection") GLib.idle_add(self._handle_disconnect, gen) break self._rx_buffer.extend(data) self._process_rx_buffer() except OSError as e: if self._running and gen == self._connect_gen: log.error(f"TCP read error: {e}") GLib.idle_add(self._handle_disconnect, gen) break meshy/src/theme_chooser_dialog.py000066400000000000000000000071521523412555500175230ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gtk from meshy.theme_manager import THEMES, ThemeManager @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/theme-chooser-dialog.ui") class ThemeChooserDialog(Adw.Dialog): __gtype_name__ = "MeshyThemeChooserDialog" flow_box = Gtk.Template.Child() def __init__(self, settings, on_theme_changed_cb, **kwargs): super().__init__(**kwargs) self._settings = settings self._on_theme_changed_cb = on_theme_changed_cb current = settings.get_string("custom-theme") self._buttons = {} self._tile_provider = Gtk.CssProvider() Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), self._tile_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) css_parts = [] for theme_id, theme in THEMES.items(): btn = self._create_tile(theme_id, theme, theme_id == current) self.flow_box.append(btn) self._buttons[theme_id] = btn css_parts.append(self._tile_css(theme_id, theme)) self._tile_provider.load_from_string("\n".join(css_parts)) def _create_tile(self, theme_id, theme, selected): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) color_box = Gtk.Box(hexpand=True, vexpand=True) color_box.set_size_request(100, 50) color_box.add_css_class(f"theme-preview-{theme_id}") color_box.add_css_class("theme-tile") if theme.get("accent"): accent_bar = Gtk.Box(hexpand=True, height_request=6) accent_bar.add_css_class(f"theme-accent-{theme_id}") accent_bar.set_valign(Gtk.Align.END) overlay = Gtk.Overlay() overlay.set_child(color_box) overlay.add_overlay(accent_bar) box.append(overlay) else: box.append(color_box) label = Gtk.Label(label=theme["name"]) label.add_css_class("caption") box.append(label) btn = Gtk.ToggleButton() btn.set_child(box) btn.add_css_class("flat") btn.set_active(selected) btn.connect("toggled", self._on_toggled, theme_id) return btn def _on_toggled(self, button, theme_id): if not button.get_active(): return for tid, btn in self._buttons.items(): if tid != theme_id: btn.set_active(False) ThemeManager.get_default().set_custom_theme(theme_id) if self._on_theme_changed_cb: self._on_theme_changed_cb() @staticmethod def _tile_css(theme_id, theme): if theme.get("background"): r, g, b = theme["background"][:3] bg = f"rgb({int(r*255)},{int(g*255)},{int(b*255)})" else: bg = "@window_bg_color" if theme.get("foreground"): r, g, b = theme["foreground"][:3] fg = f"rgb({int(r*255)},{int(g*255)},{int(b*255)})" else: fg = "@window_fg_color" parts = [f".theme-preview-{theme_id} {{ background-color: {bg}; color: {fg}; }}"] if theme.get("accent"): r, g, b = theme["accent"][:3] accent = f"rgb({int(r*255)},{int(g*255)},{int(b*255)})" parts.append( f".theme-accent-{theme_id} {{ background-color: {accent}; border-radius: 0 0 12px 12px; }}" ) return "\n".join(parts) meshy/src/theme_manager.py000066400000000000000000000154571523412555500161630ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gio, Gtk THEMES = { "meshcore": { "name": "MeshCore Dark", "foreground": (1.0, 1.0, 1.0, 0.9), "background": (0.067, 0.094, 0.153), "accent": (0.231, 0.510, 0.965), "is_dark": True, }, "meshcore-light": { "name": "MeshCore Light", "foreground": (0.067, 0.094, 0.153, 0.85), "background": (0.945, 0.961, 0.976), "accent": (0.231, 0.510, 0.965), "is_dark": False, }, "spring": { "name": "Spring", "foreground": (0.250, 0.368, 0.501), "background": (0.972, 1.0, 0.949), "accent": (0.2, 0.65, 0.4), "is_dark": False, }, "midnight": { "name": "Midnight", "foreground": (1.0, 1.0, 1.0, 0.6), "background": (0.137, 0.207, 0.250), "accent": (0.160, 0.470, 0.650), "is_dark": True, }, "gruvbox": { "name": "Gruvbox", "foreground": (0.984, 0.945, 0.780, 0.8), "background": (0.156, 0.156, 0.156), "accent": (0.407, 0.615, 0.415), "is_dark": True, }, "ocean": { "name": "Ocean", "foreground": (0.741, 0.901, 0.984), "background": (0.117, 0.145, 0.160), "accent": (0.3, 0.7, 0.8), "is_dark": True, }, } COLOR_SCHEME_MAP = { "system": Adw.ColorScheme.DEFAULT, "light": Adw.ColorScheme.FORCE_LIGHT, "dark": Adw.ColorScheme.FORCE_DARK, } class ThemeManager: _instance = None def __init__(self): self._recoloring_provider = Gtk.CssProvider() Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), self._recoloring_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1, ) self._settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") @classmethod def get_default(cls): if cls._instance is None: cls._instance = cls() return cls._instance def apply_from_settings(self): mode = self._settings.get_string("color-scheme") if mode == "custom": theme_id = self._settings.get_string("custom-theme") self.apply_custom_theme(theme_id) else: self.apply_color_scheme(mode) def apply_color_scheme(self, mode): scheme = COLOR_SCHEME_MAP.get(mode, Adw.ColorScheme.DEFAULT) Adw.StyleManager.get_default().set_color_scheme(scheme) self._recoloring_provider.load_from_string("") def apply_custom_theme(self, theme_id): theme = THEMES.get(theme_id) if not theme: Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.DEFAULT) self._recoloring_provider.load_from_string("") return if theme["is_dark"]: Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.FORCE_DARK) else: Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.FORCE_LIGHT) css = self._generate_css(theme) self._recoloring_provider.load_from_string(css) def set_color_scheme(self, mode): self._settings.set_string("color-scheme", mode) if mode != "custom": self.apply_color_scheme(mode) def set_custom_theme(self, theme_id): self._settings.set_string("custom-theme", theme_id) self._settings.set_string("color-scheme", "custom") self.apply_custom_theme(theme_id) @staticmethod def _rgba_str(components): if len(components) == 4: r, g, b, a = components return f"rgba({int(r*255)},{int(g*255)},{int(b*255)},{a:.2f})" r, g, b = components[:3] return f"rgb({int(r*255)},{int(g*255)},{int(b*255)})" @staticmethod def _rgba_opaque_str(components): r, g, b = components[:3] return f"rgb({int(r*255)},{int(g*255)},{int(b*255)})" def _generate_css(self, theme): fg = theme["foreground"] bg = theme["background"] accent = theme["accent"] is_dark = theme["is_dark"] alt = "white" if is_dark else "black" mix_level = 0.07 if is_dark else 0.25 fg_str = self._rgba_str(fg) bg_str = self._rgba_opaque_str(bg) lines = [] lines.append(f"@define-color window_bg_color {bg_str};") lines.append(f"@define-color window_fg_color {fg_str};") lines.append(f"@define-color headerbar_bg_color mix({bg_str},white,{mix_level});") lines.append(f"@define-color headerbar_fg_color {fg_str};") lines.append(f"@define-color popover_bg_color mix({bg_str},white,{mix_level});") lines.append(f"@define-color popover_fg_color {fg_str};") lines.append( f'@define-color view_bg_color mix({bg_str},{"black" if is_dark else "white"},{0.1 if is_dark else 0.3});' ) lines.append(f"@define-color view_fg_color {fg_str};") lines.append(f"@define-color card_fg_color {fg_str};") lines.append(f"@define-color headerbar_border_color {fg_str};") lines.append(f"@define-color dialog_fg_color {fg_str};") lines.append("@define-color dark_fill_bg_color @headerbar_bg_color;") if is_dark: lines.append(f"@define-color dialog_bg_color mix({bg_str},white,0.07);") lines.append("@define-color card_bg_color alpha(white,0.08);") else: lines.append(f"@define-color dialog_bg_color {bg_str};") lines.append("@define-color card_bg_color alpha(white,0.8);") lines.append(f"@define-color sidebar_bg_color mix({bg_str},{alt},0.05);") lines.append(f"@define-color sidebar_backdrop_color mix({bg_str},{alt},0.1);") lines.append(f"@define-color sidebar_fg_color mix({fg_str},{alt},0.05);") lines.append(f"@define-color sidebar_shade_color mix({bg_str},black,0.1);") lines.append(f"@define-color sidebar_border_color mix({bg_str},black,0.1);") lines.append(f"@define-color secondary_sidebar_bg_color mix({bg_str},{alt},0.02);") lines.append(f"@define-color secondary_sidebar_backdrop_color mix({bg_str},{alt},0.05);") lines.append(f"@define-color secondary_sidebar_fg_color mix({fg_str},{alt},0.02);") lines.append(f"@define-color secondary_sidebar_shade_color mix({bg_str},black,0.1);") lines.append(f"@define-color secondary_sidebar_border_color mix({bg_str},black,0.1);") if accent: accent_str = self._rgba_opaque_str(accent) lines.append(f"@define-color accent_color {accent_str};") lines.append(f"@define-color accent_bg_color {accent_str};") lines.append("@define-color accent_fg_color white;") return "\n".join(lines) meshy/src/transport_base.py000066400000000000000000000201441523412555500164020ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared base for frame-oriented MeshCore transports (USB, TCP). Frame format: start_marker(1) + length_le(2) + payload(N) Host→Device: 0x3C + length + payload Device→Host: 0x3E + length + payload """ import logging import threading from collections.abc import Callable from enum import IntEnum, auto from gi.repository import GLib log = logging.getLogger(__name__) TX_MARKER = 0x3C # Host → Device RX_MARKER = 0x3E # Device → Host HEADER_SIZE = 3 MAX_RX_PAYLOAD = 176 # accept frames up to 176 bytes (firmware 1.16+) MAX_TX_PAYLOAD = 172 # send at most 172 bytes for backward compat with older firmware class ConnectionState(IntEnum): DISCONNECTED = auto() SCANNING = auto() CONNECTING = auto() CONNECTED = auto() DISCONNECTING = auto() class FrameTransport: """Base class for frame-oriented transports (USB serial, TCP). Subclasses must implement: - _write_bytes(frame: bytes) — send raw bytes over the wire - _close_io() — close the underlying port/socket - _read_loop(gen: int) — background read thread body - connect_device(path: str) — establish the connection """ # Subclass label for log messages _LABEL = "Transport" # Set of valid RX markers — override in TCP to also accept 0x3C _RX_MARKERS = frozenset((RX_MARKER,)) def __init__(self): self._state = ConnectionState.DISCONNECTED self._connected_address: str | None = None self._read_thread: threading.Thread | None = None self._running = False self._rx_buffer = bytearray() self._connect_gen = 0 # Callbacks — same interface as BleTransport self.on_state_changed: Callable | None = None self.on_data_received: Callable[[bytes], None] | None = None self.on_device_discovered: Callable | None = None self.on_passkey_requested: Callable | None = None # ─── Properties ─────────────────────────────────────────── @property def state(self): return self._state @property def is_connected(self) -> bool: return self._state == ConnectionState.CONNECTED @property def connected_address(self) -> str | None: return self._connected_address @property def discovered_devices(self) -> list: return [] # ─── State management ───────────────────────────────────── def _set_state(self, state): if self._state != state: old = self._state self._state = state log.info( f"{self._LABEL} state: {ConnectionState(old).name} -> " f"{ConnectionState(state).name}" ) if self.on_state_changed: self.on_state_changed(state) # ─── Sending ────────────────────────────────────────────── def send_data(self, data: bytes): """Send a protocol frame wrapped in the wire framing.""" if not self._is_io_ready() or self._state != ConnectionState.CONNECTED: log.warning(f"{self._LABEL}: cannot send, state={self._state}") return if len(data) > MAX_TX_PAYLOAD: log.error(f"Frame too large: {len(data)} > {MAX_TX_PAYLOAD}") return frame = bytearray(HEADER_SIZE + len(data)) frame[0] = TX_MARKER frame[1] = len(data) & 0xFF frame[2] = (len(data) >> 8) & 0xFF frame[3:] = data try: self._write_bytes(bytes(frame)) log.debug(f"{self._LABEL} TX [{len(data)} bytes]: {data[:20].hex()}...") except Exception as e: log.error(f"{self._LABEL} write failed: {e}") GLib.idle_add(self._handle_disconnect) # ─── Receiving ──────────────────────────────────────────── def _process_rx_buffer(self): """Parse complete frames from the receive buffer.""" while len(self._rx_buffer) >= HEADER_SIZE: marker = self._rx_buffer[0] if marker not in self._RX_MARKERS: idx = None for i, b in enumerate(self._rx_buffer): if b in self._RX_MARKERS: idx = i break if idx is None: self._rx_buffer.clear() return log.debug(f"{self._LABEL}: skipping {idx} invalid bytes") del self._rx_buffer[:idx] continue payload_len = self._rx_buffer[1] | (self._rx_buffer[2] << 8) if payload_len > MAX_RX_PAYLOAD: log.debug(f"{self._LABEL}: invalid payload length {payload_len}, skipping") del self._rx_buffer[0] continue frame_size = HEADER_SIZE + payload_len if len(self._rx_buffer) < frame_size: return payload = bytes(self._rx_buffer[HEADER_SIZE:frame_size]) del self._rx_buffer[:frame_size] log.debug(f"{self._LABEL} RX [{len(payload)} bytes]: {payload[:20].hex()}...") if self.on_data_received: GLib.idle_add(self._deliver_data, payload) def _deliver_data(self, data: bytes): """Deliver received data on the main thread.""" if self.on_data_received: self.on_data_received(data) return False # ─── Disconnect handling ────────────────────────────────── def _handle_disconnect(self, gen: int = -1): """Handle unexpected disconnect. Ignore stale callbacks.""" if gen != -1 and gen != self._connect_gen: log.debug( f"{self._LABEL}: ignoring stale disconnect " f"(gen {gen} != {self._connect_gen})" ) return if self._state in (ConnectionState.DISCONNECTED, ConnectionState.DISCONNECTING): return log.info(f"{self._LABEL} disconnected") self._running = False self._close_io() self._connected_address = None self._set_state(ConnectionState.DISCONNECTED) def _start_read_thread(self): """Start the background read thread (call after IO is ready).""" self._running = True self._rx_buffer.clear() self._connect_gen += 1 gen = self._connect_gen self._read_thread = threading.Thread( target=self._read_loop, args=(gen,), daemon=True, ) self._read_thread.start() # ─── Stubs / no-ops ─────────────────────────────────────── def connect_by_address(self, address: str): self.connect_device(address) def pair_device(self, device_path: str): self.connect_device(device_path) def start_scan(self): pass def stop_scan(self): pass # ─── Abstract (must be implemented by subclass) ─────────── def _is_io_ready(self) -> bool: """Return True if the underlying IO object is available.""" raise NotImplementedError def _write_bytes(self, frame: bytes): """Write raw bytes to the transport.""" raise NotImplementedError def _close_io(self): """Close the underlying port/socket.""" raise NotImplementedError def _read_loop(self, gen: int): """Background thread body — read data and feed _rx_buffer.""" raise NotImplementedError def connect_device(self, device_path: str): """Establish connection to the given device/endpoint.""" raise NotImplementedError def disconnect(self): """Gracefully disconnect.""" raise NotImplementedError meshy/src/usb_serial.py000066400000000000000000000114571523412555500155130ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """USB Serial transport for MeshCore companion devices. Uses pyserial to communicate with MeshCore devices over USB serial ports. """ import contextlib import glob import logging import time from gi.repository import GLib from meshy.transport_base import ConnectionState, FrameTransport log = logging.getLogger(__name__) SERIAL_BAUD_RATE = 115200 class UsbDevice: """Represents a discovered USB serial device.""" def __init__(self, path: str, name: str = ""): self.path = path self.address = path # Use path as address for storage key self.name = name or path def __repr__(self): return f"UsbDevice({self.path})" class UsbSerialTransport(FrameTransport): """USB serial transport using pyserial.""" _LABEL = "USB" def __init__(self): super().__init__() self._port = None self._connection_error: str | None = None self._is_permission_error: bool = False def get_serial_devices(self) -> list[UsbDevice]: """Scan for available USB serial devices.""" devices = [] from meshy.platforms import is_macos if is_macos(): patterns = ["/dev/cu.usbserial-*", "/dev/cu.usbmodem-*"] else: patterns = ["/dev/ttyACM*", "/dev/ttyUSB*"] for pattern in patterns: for path in sorted(glob.glob(pattern)): name = path.split("/")[-1] devices.append(UsbDevice(path=path, name=name)) return devices def get_paired_devices(self) -> list[UsbDevice]: """Return available serial devices (USB doesn't have pairing).""" return self.get_serial_devices() def connect_device(self, device_path: str): """Connect to a USB serial device.""" import serial if self._state == ConnectionState.CONNECTED: self.disconnect() self._set_state(ConnectionState.CONNECTING) self._connected_address = device_path try: self._port = serial.Serial( device_path, baudrate=SERIAL_BAUD_RATE, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, xonxoff=False, rtscts=False, timeout=None, ) self._port.rts = False self._port.reset_input_buffer() self._port.reset_output_buffer() log.info(f"Opened serial port {device_path} at {SERIAL_BAUD_RATE} baud") self._start_read_thread() self._set_state(ConnectionState.CONNECTED) except Exception as e: error_msg = str(e) log.error(f"Failed to open serial port {device_path}: {error_msg}") self._connection_error = error_msg self._is_permission_error = "Permission denied" in error_msg or "Errno 13" in error_msg self._connected_address = None self._set_state(ConnectionState.DISCONNECTED) def disconnect(self): """Disconnect from the serial device.""" if self._state in (ConnectionState.DISCONNECTED, ConnectionState.DISCONNECTING): return self._set_state(ConnectionState.DISCONNECTING) self._running = False self._close_io() self._connected_address = None self._set_state(ConnectionState.DISCONNECTED) # ─── FrameTransport implementation ──────────────────────── def _is_io_ready(self) -> bool: return self._port is not None def _write_bytes(self, frame: bytes): self._port.write(frame) def _close_io(self): if self._port: with contextlib.suppress(Exception): self._port.close() self._port = None def _read_loop(self, gen: int): """Background thread that reads from the serial port.""" port = self._port while self._running and gen == self._connect_gen and port: try: waiting = port.in_waiting if waiting > 0: data = port.read(waiting) if data: self._rx_buffer.extend(data) self._process_rx_buffer() else: time.sleep(0.05) except OSError as e: if self._running and gen == self._connect_gen: log.error(f"USB read error: {e}") GLib.idle_add(self._handle_disconnect, gen) break except Exception as e: if self._running and gen == self._connect_gen: log.error(f"USB read error: {e}") break meshy/src/utils.py000066400000000000000000000110211523412555500145060ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import gettext import locale import math import re import unicodedata def ngettext(singular, plural, n): return gettext.dngettext("meshy", singular, plural, n) from gi.repository import GLib _URL_RE = re.compile(r'(https?://[^\s<>"\')\]]+)') _SHARED_CONTACT_RE = re.compile(r"^<([0-9a-fA-F]{64}):(\d+):(.+)>$") _SHARED_LOCATION_RE = re.compile(r"^(-?\d+\.\d+),\s*(-?\d+\.\d+)$") def linkify(text: str) -> str: escaped = GLib.markup_escape_text(text) def _replace(m): url = m.group(1) trailing = "" while url and url[-1] in ".,;:!?": trailing = url[-1] + trailing url = url[:-1] return f'{url}{trailing}' return _URL_RE.sub(_replace, escaped) def parse_shared_contact(text: str) -> tuple[str, int, str] | None: m = _SHARED_CONTACT_RE.match(text.strip()) if not m: return None return (m.group(1), int(m.group(2)), m.group(3)) def parse_shared_location(text: str) -> tuple[float, float] | None: m = _SHARED_LOCATION_RE.match(text.strip()) if not m: return None lat, lon = float(m.group(1)), float(m.group(2)) if -90 <= lat <= 90 and -180 <= lon <= 180: return (lat, lon) return None def haversine_distance(lat1, lon1, lat2, lon2) -> float: """Return the great-circle distance in km between two points.""" R = 6371.0 dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) a = ( math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2 ) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) def format_number(value: float, decimals: int = 1, sign: bool = False) -> str: fmt = f"%{'+' if sign else ''}.{decimals}f" return locale.format_string(fmt, value) def parse_number(text: str) -> float: try: return locale.atof(text) except ValueError: return float(text) def format_trip_time(ms: int | None) -> str: if not ms: return "" return locale.format_string("%.1f", ms / 1000) + " " + "s" def format_duration(secs): d, secs = divmod(int(secs), 86400) h, secs = divmod(secs, 3600) m, secs = divmod(secs, 60) parts = [] if d: parts.append(_("{d}d").format(d=d)) if h: parts.append(_("{h}h").format(h=h)) if m: parts.append(_("{m}m").format(m=m)) parts.append(_("{secs}s").format(secs=secs)) return " ".join(parts) def format_time_ago(secs): secs = int(secs) if secs < 60: return ngettext("{} sec ago", "{} secs ago", secs).format(secs) if secs < 3600: mins = secs // 60 return ngettext("{} min ago", "{} min ago", mins).format(mins) if secs < 86400: hours = secs // 3600 return ngettext("{} hour ago", "{} hours ago", hours).format(hours) days = secs // 86400 return ngettext("{} day ago", "{} days ago", days).format(days) class TimeoutManager: def __init__(self): self._timers: dict[str, int] = {} def start(self, name, ms, callback, *args): self.cancel(name) self._timers[name] = GLib.timeout_add(ms, self._fire, name, callback, args) def _fire(self, name, callback, args): self._timers.pop(name, None) callback(*args) return False def cancel(self, name): tid = self._timers.pop(name, None) if tid is not None: GLib.source_remove(tid) def is_active(self, name): return name in self._timers def cancel_all(self): for tid in self._timers.values(): GLib.source_remove(tid) self._timers.clear() def clear_children(container): while child := container.get_first_child(): container.remove(child) def extract_emoji(name: str) -> str | None: if not name: return None for char in name: cat = unicodedata.category(char) if cat == "So": return char cp = ord(char) if 0x1F600 <= cp <= 0x1F64F: return char if 0x1F300 <= cp <= 0x1F5FF: return char if 0x1F680 <= cp <= 0x1F6FF: return char if 0x1F900 <= cp <= 0x1F9FF: return char if 0x1FA00 <= cp <= 0x1FAFF: return char if 0x2600 <= cp <= 0x27BF: return char if 0xFE00 <= cp <= 0xFE0F: continue if cp == 0x200D: continue return None meshy/src/views/000077500000000000000000000000001523412555500141365ustar00rootroot00000000000000meshy/src/views/__init__.py000066400000000000000000000503131523412555500162510ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared view utilities.""" import gettext import json import logging import threading import types import weakref from datetime import datetime, timedelta from urllib.request import Request, urlopen def _(msg): return gettext.dgettext("meshy", msg) import gi gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") gi.require_version("Shumate", "1.0") from gi.repository import Adw, Gio, GLib, GObject, Gtk, Shumate _OFM_BASE = "https://tiles.openfreemap.org" _OFM_STYLE_URLS = { "bright": f"{_OFM_BASE}/styles/bright", "dark": f"{_OFM_BASE}/styles/dark", } log = logging.getLogger(__name__) _source_cache = {} _source_failed = set() _map_widgets = [] _theme_listener_connected = False _custom_tile_url = None _custom_tile_type = None _UA = "Meshy/1.0 (https://codeberg.org/sesivany/meshy)" _MAX_ZOOM = 14 _RASTER_MAX_ZOOM = 19 def _format_date_text(dt): """Format a datetime as a human-friendly date string for dividers.""" today = datetime.now().date() d = dt.date() if isinstance(dt, datetime) else dt if d == today: return _("Today") if d == today - timedelta(days=1): return _("Yesterday") gdt = GLib.DateTime.new_local(d.year, d.month, d.day, 0, 0, 0) if d.year == today.year: # Translators: date format for current year, strftime-style. # %A = weekday, %B = month name, %-d = day number. return gdt.format(_("%A, %B %-d")) # Translators: date format with year, strftime-style. # %A = weekday, %B = month name, %-d = day number, %Y = year. return gdt.format(_("%A, %B %-d, %Y")) _osm_source = None def _get_osm_source(): global _osm_source if _osm_source is None: registry = Shumate.MapSourceRegistry.new_with_defaults() _osm_source = registry.get_by_id(Shumate.MAP_SOURCE_OSM_MAPNIK) return _osm_source def _make_raster_source(url_template): return Shumate.RasterRenderer.new_full_from_url( "custom-tiles", "Custom Tiles", "", "", 0, 19, 256, Shumate.MapProjection.MERCATOR, url_template, ) def _fix_interpolations(obj): """Fix ["linear", N] -> ["linear"] for Shumate compatibility.""" if isinstance(obj, list): if ( len(obj) >= 2 and obj[0] == "interpolate" and isinstance(obj[1], list) and obj[1][0] == "linear" and len(obj[1]) > 1 ): obj[1] = ["linear"] for item in obj: _fix_interpolations(item) elif isinstance(obj, dict): for v in obj.values(): _fix_interpolations(v) def _patch_style_json(style_text): """Patch a vector style JSON for Shumate compatibility. Shumate supports only one data source. When the style has multiple vector sources, we keep the one referenced by the most layers (the "main" source) and drop layers that reference other sources. """ from collections import Counter style = json.loads(style_text) sources = style.get("sources", {}) vector_sources = {k: s for k, s in sources.items() if s.get("type") == "vector"} if not vector_sources: return None layer_counts = Counter( ly.get("source") for ly in style.get("layers", []) if ly.get("source") in vector_sources ) vector_key = layer_counts.most_common(1)[0][0] if layer_counts else next(iter(vector_sources)) vector_src = vector_sources[vector_key] if "url" in vector_src and "tiles" not in vector_src: tilejson_text = ( urlopen(Request(vector_src["url"], headers={"User-Agent": _UA}), timeout=10) .read() .decode("utf-8") ) vector_src["tiles"] = json.loads(tilejson_text).get("tiles", []) vector_src.pop("url", None) style["sources"] = {vector_key: vector_src} _UNSUPPORTED_LAYER_TYPES = {"fill-extrusion", "heatmap", "hillshade", "raster"} style["layers"] = [ ly for ly in style.get("layers", []) if (ly.get("source") in (vector_key, None) or "source" not in ly) and ly.get("type") not in _UNSUPPORTED_LAYER_TYPES ] style.pop("sprite", None) style.pop("glyphs", None) for layer in style["layers"]: _fix_interpolations(layer) _fix_offset_expressions(layer) return json.dumps(style) def _is_simple_offset(val): return isinstance(val, list) and len(val) == 2 and all(isinstance(v, int | float) for v in val) def _fix_offset_expressions(layer): """Replace expression-based offsets with [0, 0] — Shumate expects plain arrays.""" for section_key in ("layout", "paint"): section = layer.get(section_key) if not section: continue for prop in ("text-offset", "icon-offset"): if prop in section and not _is_simple_offset(section[prop]): section[prop] = [0, 0] def _fetch_vector_source( style_url, renderer_id, callback, cache_key=None, license_text=None, license_uri=None ): """Fetch a vector style JSON and create a VectorRenderer asynchronously.""" log.warning( "fetch_vector_source: url=%s renderer_id=%s cache_key=%s", style_url, renderer_id, cache_key ) if cache_key and cache_key in _source_cache: log.warning("fetch_vector_source: cache hit for %s", cache_key) callback(_source_cache[cache_key]) return if cache_key and cache_key in _source_failed: log.warning("fetch_vector_source: previously failed for %s", cache_key) callback(None) return def _worker(): try: log.warning("fetch_vector_source: fetching style from %s", style_url) style_text = ( urlopen(Request(style_url, headers={"User-Agent": _UA}), timeout=15) .read() .decode("utf-8") ) log.warning("fetch_vector_source: fetched %d bytes, patching...", len(style_text)) patched = _patch_style_json(style_text) if not patched: log.warning("No vector source in style from %s", style_url) if cache_key: _source_failed.add(cache_key) GLib.idle_add(lambda: callback(None) or False) return log.warning( "fetch_vector_source: patched to %d bytes, creating renderer...", len(patched) ) def _finish(): try: renderer = Shumate.VectorRenderer.new(renderer_id, patched) log.warning("fetch_vector_source: VectorRenderer created: %s", renderer) if license_text: renderer.set_license(license_text) if license_uri: renderer.set_license_uri(license_uri) if cache_key: _source_cache[cache_key] = renderer callback(renderer) except Exception as e: log.warning("Failed to create VectorRenderer %s: %s", renderer_id, e) if cache_key: _source_failed.add(cache_key) callback(None) return False GLib.idle_add(_finish) except Exception as e: log.warning("Failed to fetch vector style from %s: %s", style_url, e) if cache_key: _source_failed.add(cache_key) GLib.idle_add(lambda: callback(None) or False) threading.Thread(target=_worker, daemon=True).start() def _fetch_ofm_source(style, callback): """Fetch an OFM vector source (bright/dark) with caching.""" _fetch_vector_source( _OFM_STYLE_URLS[style], f"ofm-{style}", callback, cache_key=style, license_text="© OpenFreeMap © OpenStreetMap (ODbL)", license_uri="https://www.openstreetmap.org/copyright", ) def _collect_live_widgets(): """Prune dead weak refs and return live map widgets.""" live = [(ref, ref()) for ref in _map_widgets] _map_widgets[:] = [ref for ref, w in live if w is not None] return [w for _, w in live if w is not None] def _apply_map_source(map_widget, source): if source: map_widget.set_map_source(source) max_zoom = _RASTER_MAX_ZOOM if _custom_tile_type == "raster" else _MAX_ZOOM map_widget.get_viewport().set_max_zoom_level(max_zoom) def _is_dark(): return Adw.StyleManager.get_default().get_dark() def _apply_default_source(widgets): """Apply the theme-appropriate OFM source (or OSM fallback) to widgets.""" style = "dark" if _is_dark() else "bright" def _apply(source): s = source if source else _get_osm_source() for w in widgets: _apply_map_source(w, s) _fetch_ofm_source(style, _apply) def _on_theme_changed(*_args): if _custom_tile_url: return widgets = _collect_live_widgets() if widgets: _apply_default_source(widgets) def create_shumate_map(zoom_level=14, lat=0.0, lon=0.0): """Create a Shumate SimpleMap with theme-aware tiles. Raises: Exception: If map creation fails (e.g., on macOS with GdkMacos backend issues) """ global _theme_listener_connected, _custom_tile_url, _custom_tile_type import logging log = logging.getLogger(__name__) try: log.debug(f"Creating Shumate map: zoom={zoom_level}, lat={lat}, lon={lon}") from gi.repository import Gio settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") _custom_tile_url = settings.get_string("custom-tile-url") or None _custom_tile_type = settings.get_string("custom-tile-type") or None map_widget = Shumate.SimpleMap() map_widget.set_vexpand(True) viewport = map_widget.get_viewport() viewport.set_zoom_level(zoom_level) viewport.set_location(lat, lon) _collect_live_widgets() _map_widgets.append(weakref.ref(map_widget)) # Apply tile source BEFORE adding marker layer so markers render on top if _custom_tile_url and _custom_tile_type == "raster": _apply_map_source(map_widget, _make_raster_source(_custom_tile_url)) else: osm_source = _get_osm_source() if osm_source: _apply_map_source(map_widget, osm_source) marker_layer = Shumate.MarkerLayer.new(viewport) map_widget.get_map().add_layer(marker_layer) def _apply_async(source): if source: _apply_map_source(map_widget, source) if _custom_tile_url and _custom_tile_type == "vector": _fetch_vector_source(_custom_tile_url, "custom-vector", _apply_async) elif not _custom_tile_url: _apply_default_source([map_widget]) if not _theme_listener_connected: Adw.StyleManager.get_default().connect("notify::dark", _on_theme_changed) _theme_listener_connected = True log.debug("Shumate map created successfully") return map_widget, marker_layer, viewport except Exception as e: log.error(f"Failed to create Shumate map widget: {e}", exc_info=True) # Re-raise so callers can handle gracefully raise def apply_custom_source(url, tile_type, on_result=None): """Switch all tracked map widgets to a custom source, or back to default. on_result(ok: bool) is called on the main thread when done (vector only). """ global _custom_tile_url, _custom_tile_type _custom_tile_url = url or None _custom_tile_type = tile_type or None widgets = _collect_live_widgets() log.warning("apply_custom_source: url=%s type=%s widgets=%d", url, tile_type, len(widgets)) if not widgets: log.warning("apply_custom_source: no live widgets — source saved for next map open") if on_result: on_result(True) return if _custom_tile_url and _custom_tile_type == "raster": for w in widgets: _apply_map_source(w, _make_raster_source(_custom_tile_url)) if on_result: on_result(True) elif _custom_tile_url and _custom_tile_type == "vector": def _apply(source): log.warning("apply_custom_source callback: source=%s widgets=%d", source, len(widgets)) if source: for w in widgets: _apply_map_source(w, source) if on_result: on_result(source is not None) _fetch_vector_source(_custom_tile_url, "custom-vector", _apply) else: _apply_default_source(widgets) if on_result: on_result(True) def deregister_map_widget(map_widget): """Remove a map widget from the theme-tracking list.""" _map_widgets[:] = [ref for ref in _map_widgets if ref() is not None and ref() is not map_widget] def open_map_picker(window, title, button_label, init_lat, init_lon, on_pick): """Open a map dialog with crosshair, call on_pick(lat, lon) on confirm.""" builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/map-picker-dialog.ui") dialog = builder.get_object("dialog") header = builder.get_object("header") overlay = builder.get_object("overlay") crosshair = builder.get_object("crosshair") win_w = window.get_width() or 800 win_h = window.get_height() or 600 dialog.set_title(title) dialog.set_content_width(max(500, win_w - 60)) dialog.set_content_height(max(400, win_h - 60)) confirm_btn = Gtk.Button(label=button_label) confirm_btn.add_css_class("suggested-action") header.pack_end(confirm_btn) init_zoom = 14 if (abs(init_lat) > 1e-6 or abs(init_lon) > 1e-6) else 2 map_widget, _ml, viewport = create_shumate_map(zoom_level=init_zoom, lat=init_lat, lon=init_lon) map_widget.set_hexpand(True) crosshair.set_markup('') overlay.set_child(map_widget) def _on_confirm(*_args): on_pick(viewport.get_latitude(), viewport.get_longitude()) dialog.close() confirm_btn.connect("clicked", _on_confirm) dialog.connect("closed", lambda _d: deregister_map_widget(map_widget)) dialog.present(window) def _add_hover_swap(row, button, badge, get_count, on_long_press): """Add hover show/hide behavior: show button and hide badge on hover, reverse on leave. Also add long-press handler for touchscreens.""" motion = Gtk.EventControllerMotion() motion.connect("enter", lambda *_: (button.set_visible(True), badge.set_visible(False))) motion.connect( "leave", lambda *_: (button.set_visible(False), badge.set_visible(get_count() > 0)) ) row.add_controller(motion) long_press = Gtk.GestureLongPress() long_press.connect("pressed", lambda g, x, y: on_long_press()) row.add_controller(long_press) _CONTACT_TYPE_ICONS = { 1: "avatar-default-symbolic", 2: "repeater-symbolic", 3: "user-home-symbolic", 4: "weather-clear-symbolic", } class _ContactPickItem(GObject.Object): def __init__(self, contact): super().__init__() self.contact = contact def _contact_pick_factory_setup(factory, list_item, button_label): row = Adw.ActionRow(focusable=False) icon = Gtk.Image() row.add_prefix(icon) btn = Gtk.Button(label=button_label, valign=Gtk.Align.CENTER) btn.add_css_class("suggested-action") btn.set_visible(False) row.add_suffix(btn) row._icon = icon row._action_btn = btn row._action_handler_id = None hover = Gtk.EventControllerMotion() hover.connect("enter", lambda *_a: btn.set_visible(True)) hover.connect("leave", lambda *_a: btn.set_visible(False)) row.add_controller(hover) list_item.set_child(row) def _contact_pick_factory_bind(factory, list_item, on_click, subtitle_func=None): row = list_item.get_child() contact = list_item.get_item().contact row.set_title(GLib.markup_escape_text(contact.name) if contact.name else _("Unknown")) subtitle = subtitle_func(contact) if subtitle_func else contact.type_label row.set_subtitle(subtitle) row._icon.set_from_icon_name(_CONTACT_TYPE_ICONS.get(contact.type, "avatar-default-symbolic")) row._action_handler_id = row._action_btn.connect("clicked", lambda _b: on_click(contact)) def _contact_pick_factory_unbind(factory, list_item): row = list_item.get_child() if row._action_handler_id is not None: row._action_btn.disconnect(row._action_handler_id) row._action_handler_id = None def show_contact_picker( window, *, title, contacts, on_add, button_label=None, subtitle_func=None, extra_top_widget=None, status_format_all=None, status_format_filtered=None, ): if button_label is None: button_label = _("Add") if status_format_all is None: status_format_all = _("{total} contacts") if status_format_filtered is None: status_format_filtered = _("{visible}/{total} contacts") builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/discover-dialog.ui") dialog = builder.get_object("discover_dialog") dialog.set_title(title) search_btn = builder.get_object("discover_search_btn") search_entry = builder.get_object("discover_search_entry") list_view = builder.get_object("discover_list_view") status_label = builder.get_object("discover_status_label") filter_btn = builder.get_object("discover_filter_btn") sort_btn = builder.get_object("discover_sort_btn") filter_btn.set_visible(False) sort_btn.set_visible(False) search_btn.bind_property("active", search_entry, "visible", GObject.BindingFlags.SYNC_CREATE) search_entry.connect("stop-search", lambda _e: search_btn.set_active(False)) store = Gio.ListStore(item_type=_ContactPickItem) for c in sorted(contacts, key=lambda c: c.name.lower()): store.append(_ContactPickItem(c)) def _filter_func(item, _data): search = search_entry.get_text().lower() return not search or search in item.contact.name.lower() custom_filter = Gtk.CustomFilter.new(_filter_func, None) filter_model = Gtk.FilterListModel(model=store, filter=custom_filter) selection = Gtk.NoSelection(model=filter_model) list_view.set_model(selection) content_box = search_entry.get_parent() if extra_top_widget: content_box.prepend(extra_top_widget) def update_status(): total = store.get_n_items() visible = filter_model.get_n_items() if total == 0: status_label.set_label(_("No contacts")) elif visible != total: status_label.set_label(status_format_filtered.format(visible=visible, total=total)) else: status_label.set_label(status_format_all.format(total=total)) def _handle_add(contact): on_add(contact) for i in range(store.get_n_items()): if store.get_item(i).contact.public_key_hex == contact.public_key_hex: store.remove(i) break update_status() if store.get_n_items() == 0: dialog.close() def _on_search_changed(*_args): custom_filter.changed(Gtk.FilterChange.DIFFERENT) update_status() search_entry.connect("search-changed", _on_search_changed) factory = Gtk.SignalListItemFactory() factory.connect("setup", lambda f, li: _contact_pick_factory_setup(f, li, button_label)) factory.connect( "bind", lambda f, li: _contact_pick_factory_bind(f, li, _handle_add, subtitle_func=subtitle_func), ) factory.connect("unbind", _contact_pick_factory_unbind) list_view.set_factory(factory) def _on_activate(lv, pos): item = lv.get_model().get_item(pos) if item: _handle_add(item.contact) list_view.connect("activate", _on_activate) dialog._refs = [store, custom_filter, filter_model, selection, factory] update_status() return types.SimpleNamespace( dialog=dialog, store=store, filter_model=filter_model, custom_filter=custom_filter, selection=selection, search_entry=search_entry, list_view=list_view, status_label=status_label, content_box=content_box, filter_btn=filter_btn, sort_btn=sort_btn, update_status=update_status, present=lambda: dialog.present(window), ) meshy/src/views/channels_view.py000066400000000000000000002710661523412555500173510ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Channels list and chat view.""" import gettext import hashlib def ngettext(singular, plural, n): return gettext.dngettext("meshy", singular, plural, n) import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Shumate", "1.0") from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango, Shumate from meshy.models import Channel, ChannelMessage, NotificationLevel from meshy.protocol import max_channel_text_bytes from meshy.utils import ( clear_children, extract_emoji, format_number, format_time_ago, linkify, parse_shared_contact, parse_shared_location, ) from meshy.views import _add_hover_swap, _format_date_text from meshy.views.chat_helpers import ( CONTACT_TYPE_ICONS, ensure_loc_map, open_location_external, share_contact_dialog, share_location, share_location_map, show_location_map, ) from meshy.views.chat_mixin import ChatScrollMixin _BATCH_SIZE = 30 class ChannelChatItem(GObject.Object): """Model object for one row in the channel chat ListView.""" KIND_MESSAGE = 0 KIND_UNREAD_DIVIDER = 1 KIND_DATE_DIVIDER = 2 def __init__(self, kind=0, message=None, date_text=""): super().__init__() self.kind = kind self.message = message self.date_text = date_text self._repeat_count_val = 0 self._resend_count_val = 0 self._max_hops_val = -1 @GObject.Property(type=int) def repeat_count(self): return self._repeat_count_val @repeat_count.setter def repeat_count(self, value): self._repeat_count_val = value @GObject.Property(type=int) def resend_count(self): return self._resend_count_val @resend_count.setter def resend_count(self, value): self._resend_count_val = value @GObject.Property(type=int) def max_hops(self): return self._max_hops_val @max_hops.setter def max_hops(self, value): self._max_hops_val = value @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/channel-chat-widget.ui") class ChannelChatWidget(ChatScrollMixin, Gtk.Box): """Channel chat content widget - shown in the content panel.""" __gtype_name__ = "MeshyChannelChatWidget" _stack = Gtk.Template.Child("stack") _scroll = Gtk.Template.Child("chat_scroll") _list_view = Gtk.Template.Child("list_view") _scroll_down_revealer = Gtk.Template.Child("scroll_down_revealer") _unread_btn_revealer = Gtk.Template.Child("unread_btn_revealer") _char_label = Gtk.Template.Child("char_label") _emoji_btn = Gtk.Template.Child("emoji_btn") _text_entry = Gtk.Template.Child("text_entry") _send_btn = Gtk.Template.Child("send_btn") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._current_channel: Channel | None = None # Track outgoing items for repeat detection: (channel_idx, timestamp, text) -> item self._outgoing_items: dict[tuple, ChannelChatItem] = {} # Repeat info per outgoing message key: list of {path_hops, path_len} self._repeats: dict[tuple, list] = {} # Track incoming message path variants: msg_key -> list of path_info dicts self._incoming_paths: dict[tuple, list] = {} self._seen_channel_msgs: set[tuple] = set() self._incoming_items: dict[tuple, ChannelChatItem] = {} # Map resend timestamps to original outgoing message keys # so echoes of resent messages can be matched back self._resend_ts_map: dict[tuple, tuple] = {} self._mention_popover = None self._mention_listbox = None # Pango attributes for char counter attrs = Pango.AttrList() attrs.insert(Pango.attr_scale_new(0.85)) self._char_label.set_attributes(attrs) # ListView model and factory self._store = Gio.ListStore(item_type=ChannelChatItem) selection = Gtk.NoSelection(model=self._store) self._factory = Gtk.SignalListItemFactory() self._factory.connect("setup", self._on_factory_setup) self._factory.connect("bind", self._on_factory_bind) self._factory.connect("unbind", self._on_factory_unbind) self._list_view.set_model(selection) self._list_view.set_factory(self._factory) # Context menus (single popover on the ListView, menu switches per item) self._context_popover = Gtk.PopoverMenu() self._context_popover.set_parent(self._list_view) self._context_popover.set_has_arrow(False) self._context_popover.connect("closed", self._on_context_popover_closed) self._context_menu_open = False self._frozen_scroll_value = 0.0 self._context_item = None self._outgoing_menu = Gio.Menu() self._outgoing_menu.append(_("Heard Repeats"), "msg.repeats") self._outgoing_menu.append(_("Send Again"), "msg.resend") self._outgoing_menu.append(_("Delete"), "msg.delete") self._incoming_menu = Gio.Menu() self._incoming_menu.append(_("Reply"), "msg.reply") self._incoming_menu.append(_("Copy Text"), "msg.copy") self._incoming_menu.append(_("View Message Paths"), "msg.paths") self._incoming_menu.append(_("Delete"), "msg.delete") ctx_group = Gio.SimpleActionGroup() for name, cb in [ ("repeats", lambda a, p: self._show_repeats_context()), ("resend", lambda a, p: self._resend_context()), ("delete", lambda a, p: self._delete_context()), ("reply", lambda a, p: self._reply_context()), ("copy", lambda a, p: self._copy_context()), ("paths", lambda a, p: self._show_paths_context()), ]: action = Gio.SimpleAction.new(name, None) action.connect("activate", cb) ctx_group.add_action(action) self._list_view.insert_action_group("msg", ctx_group) # Gesture controllers right_click = Gtk.GestureClick(button=3) right_click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) right_click.connect("pressed", self._on_list_right_click) self._list_view.add_controller(right_click) long_press = Gtk.GestureLongPress() long_press.set_touch_only(True) long_press.set_exclusive(True) long_press.connect("pressed", self._on_list_long_press) self._list_view.add_controller(long_press) self._is_sticky = True self._scroll_positions: dict[int, float] = {} self._restoring_scroll = False self._unread_pos = -1 self._messages_offset = 0 self._all_loaded = False self._loading_more = False # Signal connections self._scroll_down_revealer.get_child().connect("clicked", self._on_scroll_to_bottom_clicked) self._unread_btn_revealer.get_child().connect("clicked", self._on_scroll_to_unread_clicked) self._emoji_btn.connect("clicked", self._on_emoji_clicked) self._text_entry.connect("activate", self._on_send) self._text_entry.connect("changed", self._on_text_changed) mention_key_ctrl = Gtk.EventControllerKey() mention_key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) mention_key_ctrl.connect("key-pressed", self._on_entry_key_pressed) self._text_entry.add_controller(mention_key_ctrl) self._send_btn.connect("clicked", self._on_send) # Extra context menu on text entry extra_menu = Gio.Menu() extra_menu.append(_("Share Contact"), "entry.share-contact") extra_menu.append(_("Share Location"), "entry.share-location") extra_menu.append(_("Share Location from Map"), "entry.share-location-map") self._text_entry.set_extra_menu(extra_menu) entry_group = Gio.SimpleActionGroup() share_a = Gio.SimpleAction.new("share-contact", None) share_a.connect("activate", self._on_share_contact) entry_group.add_action(share_a) loc_a = Gio.SimpleAction.new("share-location", None) loc_a.connect("activate", self._on_share_location) entry_group.add_action(loc_a) loc_map_a = Gio.SimpleAction.new("share-location-map", None) loc_map_a.connect("activate", self._on_share_location_map) entry_group.add_action(loc_map_a) self._text_entry.insert_action_group("entry", entry_group) # Scroll state tracking adj = self._scroll.get_vadjustment() adj.connect("value-changed", self._on_scroll_value_changed) adj.connect("notify::upper", self._on_scroll_upper_changed) adj.connect("notify::page-size", self._on_scroll_upper_changed) self._scroll.connect("edge-overshot", self._on_edge_overshot) self._stack.set_visible_child_name("placeholder") # ─── Factory callbacks ─────────────────────────────────── def _on_factory_setup(self, factory, list_item): """Create the widget tree for one list item (recycled).""" list_item.set_activatable(False) list_item.set_selectable(False) list_item.set_focusable(False) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/channel-message-item.ui" ) clamp = builder.get_object("clamp") outer = builder.get_object("outer") # Cursor and gesture must be set in Python loc_map_frame = builder.get_object("loc_map_frame") loc_map_frame.set_cursor(Gdk.Cursor.new_from_name("pointer")) loc_map_gesture = Gtk.GestureClick() loc_map_frame.add_controller(loc_map_gesture) # Store widget references outer._content_box = builder.get_object("content_box") outer._avatar = builder.get_object("avatar") outer._emoji_lbl = builder.get_object("emoji_lbl") outer._avatar_stack = builder.get_object("avatar_stack") outer._msg_box = builder.get_object("msg_box") outer._sender_lbl = builder.get_object("sender_lbl") outer._text_lbl = builder.get_object("text_lbl") outer._frame = builder.get_object("frame") outer._time_lbl = builder.get_object("time_lbl") outer._resend_lbl = builder.get_object("resend_lbl") outer._repeat_lbl = builder.get_object("repeat_lbl") outer._meta_box = builder.get_object("meta_box") outer._div_box = builder.get_object("div_box") outer._div_lbl = builder.get_object("div_lbl") outer._div_line_left = builder.get_object("line_left") outer._div_line_right = builder.get_object("line_right") outer._content_stack = builder.get_object("content_stack") outer._sc_header_lbl = builder.get_object("sc_header_lbl") outer._sc_icon = builder.get_object("sc_icon") outer._sc_name_lbl = builder.get_object("sc_name_lbl") outer._sc_key_lbl = builder.get_object("sc_key_lbl") outer._sc_add_btn = builder.get_object("sc_add_btn") outer._sc_add_handler_id = None outer._loc_header_lbl = builder.get_object("loc_header_lbl") outer._loc_coords_lbl = builder.get_object("loc_coords_lbl") outer._loc_map_frame = loc_map_frame outer._loc_open_btn = builder.get_object("loc_open_btn") outer._loc_map_gesture = loc_map_gesture outer._loc_map_click_handler = None outer._loc_open_handler = None outer._loc_map_widget = None outer._loc_viewport = None outer._loc_marker = None outer._chat_item = None outer._handler_ids = [] clamp._outer = outer list_item.set_child(clamp) def _on_factory_bind(self, factory, list_item): """Populate widgets from the model item.""" item = list_item.get_item() clamp = list_item.get_child() outer = clamp._outer outer._chat_item = item if item.kind == ChannelChatItem.KIND_MESSAGE: msg = item.message outer._msg_box.set_visible(True) outer._div_box.set_visible(False) # Alignment if msg.is_outgoing: outer._content_box.set_halign(Gtk.Align.END) outer._meta_box.set_halign(Gtk.Align.END) outer._avatar_stack.set_visible(False) else: outer._content_box.set_halign(Gtk.Align.START) outer._meta_box.set_halign(Gtk.Align.START) # Avatar emoji = extract_emoji(msg.sender_name) if msg.sender_name else None if emoji: outer._emoji_lbl.set_label(emoji) outer._avatar_stack.set_visible_child_name("emoji") else: outer._avatar.set_text(msg.sender_name or "") outer._avatar_stack.set_visible_child_name("initials") outer._avatar_stack.set_visible(True) # Sender name if not msg.is_outgoing and msg.sender_name: outer._sender_lbl.set_label(msg.sender_name) outer._sender_lbl.set_visible(True) else: outer._sender_lbl.set_visible(False) # Text / shared contact sc = parse_shared_contact(msg.text) if sc: pub_key_hex, contact_type, contact_name = sc outer._content_stack.set_visible_child_name("contact") if msg.is_outgoing: outer._sc_header_lbl.set_label(_("You shared a contact:")) else: sender = msg.sender_name or "?" outer._sc_header_lbl.set_label( _("{name} shared a contact with you:").format(name=sender) ) outer._sc_icon.set_from_icon_name( CONTACT_TYPE_ICONS.get(contact_type, "avatar-default-symbolic") ) outer._sc_name_lbl.set_label(contact_name) outer._sc_key_lbl.set_label(f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}") if msg.is_outgoing: outer._sc_add_btn.set_visible(False) else: outer._sc_add_btn.set_visible(True) is_known = self._window.is_contact_known(pub_key_hex) outer._sc_add_btn.set_sensitive(not is_known) outer._sc_add_btn.set_label( _("Already added") if is_known else _("Add Contact") ) if outer._sc_add_handler_id is not None: outer._sc_add_btn.disconnect(outer._sc_add_handler_id) outer._sc_add_handler_id = outer._sc_add_btn.connect( "clicked", self._on_shared_contact_add, pub_key_hex, contact_type, contact_name, ) else: loc = parse_shared_location(msg.text) if loc: lat, lon = loc outer._content_stack.set_visible_child_name("location") if msg.is_outgoing: outer._loc_header_lbl.set_label(_("You shared a location:")) else: sender = msg.sender_name or "?" outer._loc_header_lbl.set_label( _("{name} shared a location:").format(name=sender) ) outer._loc_coords_lbl.set_label(f"{lat:.6f}, {lon:.6f}") ensure_loc_map(outer, lat, lon) outer._loc_map_click_handler = outer._loc_map_gesture.connect( "released", lambda g, n, x, y, la=lat, lo=lon: show_location_map(self._window, la, lo), ) outer._loc_open_handler = outer._loc_open_btn.connect( "clicked", open_location_external, lat, lon ) else: outer._content_stack.set_visible_child_name("text") outer._text_lbl.set_markup(linkify(msg.text)) # Frame style if msg.is_outgoing: outer._frame.add_css_class("outgoing-message") else: outer._frame.remove_css_class("outgoing-message") # Timestamp outer._time_lbl.set_label(msg.timestamp.strftime("%H:%M")) # Outgoing: resend + repeat labels if msg.is_outgoing: self._update_resend_label_widget(outer, item) self._update_repeat_label_widget(outer, item) h1 = item.connect( "notify::repeat-count", lambda obj, pspec, w=outer: self._update_repeat_label_widget(w, obj), ) h2 = item.connect( "notify::resend-count", lambda obj, pspec, w=outer: self._update_resend_label_widget(w, obj), ) outer._handler_ids = [h1, h2] else: outer._resend_lbl.set_visible(False) self._update_hops_label_widget(outer, item) h1 = item.connect( "notify::max-hops", lambda obj, pspec, w=outer: self._update_hops_label_widget(w, obj), ) outer._handler_ids = [h1] elif item.kind == ChannelChatItem.KIND_DATE_DIVIDER: outer._msg_box.set_visible(False) outer._div_box.set_visible(True) outer._div_lbl.set_label(item.date_text) outer._div_lbl.remove_css_class("unread-divider") outer._div_lbl.add_css_class("dim-label") outer._div_line_left.remove_css_class("unread-divider-line") outer._div_line_left.add_css_class("date-divider-line") outer._div_line_right.remove_css_class("unread-divider-line") outer._div_line_right.add_css_class("date-divider-line") elif item.kind == ChannelChatItem.KIND_UNREAD_DIVIDER: outer._msg_box.set_visible(False) outer._div_box.set_visible(True) outer._div_lbl.set_label(_("New Messages")) outer._div_lbl.remove_css_class("dim-label") outer._div_lbl.add_css_class("unread-divider") outer._div_line_left.remove_css_class("date-divider-line") outer._div_line_left.add_css_class("unread-divider-line") outer._div_line_right.remove_css_class("date-divider-line") outer._div_line_right.add_css_class("unread-divider-line") def _on_factory_unbind(self, factory, list_item): """Clean up signal connections when the widget is recycled.""" clamp = list_item.get_child() outer = clamp._outer for hid in outer._handler_ids: if outer._chat_item: outer._chat_item.disconnect(hid) outer._handler_ids = [] if outer._sc_add_handler_id is not None: outer._sc_add_btn.disconnect(outer._sc_add_handler_id) outer._sc_add_handler_id = None if outer._loc_map_click_handler is not None: outer._loc_map_gesture.disconnect(outer._loc_map_click_handler) outer._loc_map_click_handler = None if outer._loc_open_handler is not None: outer._loc_open_btn.disconnect(outer._loc_open_handler) outer._loc_open_handler = None outer._chat_item = None # ─── Shared contact helpers ───────────────────────────── def _on_shared_contact_add(self, btn, pub_key_hex, contact_type, name): self._window.add_contact(bytes.fromhex(pub_key_hex), contact_type, name) btn.set_sensitive(False) btn.set_label(_("Already added")) # ─── Label update helpers ──────────────────────────────── @staticmethod def _update_repeat_label_widget(outer, item): count = item.repeat_count if count > 0: outer._repeat_lbl.set_label( ngettext("\u00b7 %d repeat", "\u00b7 %d repeats", count) % count ) outer._repeat_lbl.set_visible(True) else: outer._repeat_lbl.set_label("") outer._repeat_lbl.set_visible(False) @staticmethod def _update_hops_label_widget(outer, item): hops = item.max_hops if hops == 0: outer._repeat_lbl.set_label("\u00b7 " + _("direct")) outer._repeat_lbl.set_visible(True) elif hops == 255 or hops == -1: outer._repeat_lbl.set_label("") outer._repeat_lbl.set_visible(False) elif hops > 0: outer._repeat_lbl.set_label("\u00b7 " + ngettext("%d hop", "%d hops", hops) % hops) outer._repeat_lbl.set_visible(True) else: outer._repeat_lbl.set_label("") outer._repeat_lbl.set_visible(False) @staticmethod def _update_resend_label_widget(outer, item): count = item.resend_count if count > 0: outer._resend_lbl.set_label( ngettext("\u00b7 %d resend", "\u00b7 %d resends", count) % count ) outer._resend_lbl.set_visible(True) else: outer._resend_lbl.set_label("") outer._resend_lbl.set_visible(False) # ─── Context menu ─────────────────────────────────────── def _on_list_right_click(self, gesture, n_press, x, y): outer = self._find_outer_at(x, y) if not outer: return item = outer._chat_item if item and item.kind == ChannelChatItem.KIND_MESSAGE and item.message: if gesture: gesture.set_state(Gtk.EventSequenceState.CLAIMED) self._context_item = item self._context_menu_open = True self._is_sticky = False self._frozen_scroll_value = self._scroll.get_vadjustment().get_value() if item.message.is_outgoing: self._context_popover.set_menu_model(self._outgoing_menu) else: self._context_popover.set_menu_model(self._incoming_menu) rect = Gdk.Rectangle() rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1 self._context_popover.set_pointing_to(rect) self._context_popover.popup() def _show_repeats_context(self): item = self._context_item if not item or not item.message: return msg = item.message key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) self._show_repeats_dialog(key) def _resend_context(self): item = self._context_item if not item or not item.message: return self._resend_message(item.message, item) def _delete_context(self): item = self._context_item if not item or not item.message: return self._delete_message_item(item) def _reply_context(self): item = self._context_item if not item or not item.message: return self._reply_to_message(item.message) def _copy_context(self): item = self._context_item if not item or not item.message: return self._copy_message_text(item.message) def _show_paths_context(self): item = self._context_item if not item or not item.message: return msg = item.message key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) self._show_message_paths(key, msg) # ─── Emoji ─────────────────────────────────────────────── def _on_share_contact(self, action, param): if not self._current_channel: return channel_idx = self._current_channel.index def _send(c): text = f"<{c.public_key_hex}:{c.type}:{c.name}>" self._window.send_channel_message(channel_idx, text) share_contact_dialog(self._window, self._window.contacts, _send) def _on_share_location(self, action, param): if not self._current_channel: return channel_idx = self._current_channel.index share_location( self._window, lambda text: self._window.send_channel_message(channel_idx, text) ) def _on_share_location_map(self, action, param): if not self._current_channel: return channel_idx = self._current_channel.index share_location_map( self._window, lambda text: self._window.send_channel_message(channel_idx, text) ) # ─── @Mention autocomplete ───────────────────────────────── def _get_channel_senders(self) -> list[str]: my_name = self._window.device_info.name or "" seen = set() result = [] for i in range(self._store.get_n_items() - 1, -1, -1): item = self._store.get_item(i) if item.kind == ChannelChatItem.KIND_MESSAGE and item.message: name = item.message.sender_name if name and name != my_name and name not in seen: seen.add(name) result.append(name) return result def _update_mention_popover(self, prefix: str, at_char_idx: int): senders = self._get_channel_senders() filtered = ( [n for n in senders if n.lower().startswith(prefix.lower())] if prefix else senders ) if not filtered: if self._mention_popover: self._mention_popover.popdown() return if not self._mention_popover: self._mention_popover = Gtk.Popover() self._mention_popover.set_parent(self._text_entry) self._mention_popover.set_autohide(False) self._mention_popover.set_has_arrow(False) self._mention_popover.set_position(Gtk.PositionType.TOP) self._mention_popover.set_halign(Gtk.Align.START) self._mention_listbox = Gtk.ListBox() self._mention_listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) self._mention_listbox.connect("row-activated", self._on_mention_selected) scrolled = Gtk.ScrolledWindow( propagate_natural_height=True, max_content_height=280, hscrollbar_policy=Gtk.PolicyType.NEVER, ) scrolled.set_child(self._mention_listbox) self._mention_popover.set_child(scrolled) try: delegate = self._text_entry.get_delegate() layout = delegate.get_layout() index_bytes = len(self._text_entry.get_text()[:at_char_idx].encode("utf-8")) strong_pos, _ = layout.get_cursor_pos(index_bytes) layout_x, _ = delegate.get_layout_offsets() cursor_x = layout_x + strong_pos.x // Pango.SCALE except Exception: cursor_x = 0 rect = Gdk.Rectangle() rect.x = cursor_x rect.y = 0 rect.width = 1 rect.height = self._text_entry.get_height() self._mention_popover.set_pointing_to(rect) while row := self._mention_listbox.get_first_child(): self._mention_listbox.remove(row) for name in filtered[:20]: label = Gtk.Label( label=name, xalign=0, margin_start=8, margin_end=8, margin_top=4, margin_bottom=4 ) self._mention_listbox.append(label) self._mention_popover.popup() def _check_mention_trigger(self, text: str, cursor_pos: int): text_before = text[:cursor_pos] at_idx = text_before.rfind("@") if at_idx == -1: if self._mention_popover: self._mention_popover.popdown() return if at_idx > 0 and text_before[at_idx - 1] not in (" ", "\t"): if self._mention_popover: self._mention_popover.popdown() return after_at = text_before[at_idx + 1 :] if "[" in after_at: if self._mention_popover: self._mention_popover.popdown() return self._update_mention_popover(after_at, at_idx) def _on_mention_selected(self, listbox, row): label = row.get_child() name = label.get_label() text = self._text_entry.get_text() cursor_pos = self._text_entry.get_position() text_before = text[:cursor_pos] at_idx = text_before.rfind("@") if at_idx == -1: return mention = f"@[{name}] " new_text = text[:at_idx] + mention + text[cursor_pos:] self._text_entry.set_text(new_text) self._text_entry.set_position(at_idx + len(mention)) self._mention_popover.popdown() def _on_entry_key_pressed(self, ctrl, keyval, keycode, state): if not self._mention_popover or not self._mention_popover.get_visible(): return False if keyval == Gdk.KEY_Escape: self._mention_popover.popdown() return True if keyval in (Gdk.KEY_Down, Gdk.KEY_Up): selected = self._mention_listbox.get_selected_row() if keyval == Gdk.KEY_Down: if selected: next_row = selected.get_next_sibling() if next_row: self._mention_listbox.select_row(next_row) else: first = self._mention_listbox.get_first_child() if first: self._mention_listbox.select_row(first) elif keyval == Gdk.KEY_Up and selected: prev_row = selected.get_prev_sibling() if prev_row: self._mention_listbox.select_row(prev_row) return True if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_Tab): selected = self._mention_listbox.get_selected_row() if selected: self._on_mention_selected(self._mention_listbox, selected) return True return False # ─── Channel & messages ────────────────────────────────── def get_last_message_time(self, channel_index: int) -> float | None: try: if not self._window.storage: return None return self._window.storage.get_max_channel_message_timestamp(channel_index) except Exception: return None def set_channel(self, channel: Channel): if self._mention_popover: self._mention_popover.popdown() if self._current_channel is not None: adj = self._scroll.get_vadjustment() self._scroll_positions[self._current_channel.index] = adj.get_value() self._current_channel = channel self._stack.set_visible_child_name("chat") self._load_messages(channel) def _load_messages(self, channel: Channel): self._outgoing_items.clear() self._repeats.clear() self._incoming_paths.clear() self._seen_channel_msgs.clear() self._incoming_items.clear() self._resend_ts_map.clear() self._unread_pos = -1 self._messages_offset = 0 self._all_loaded = False self._loading_more = False messages = self._window.get_channel_messages(channel.index, limit=_BATCH_SIZE) self._messages_offset = len(messages) self._all_loaded = len(messages) < _BATCH_SIZE last_read_at = self._window.get_channel_last_read_at(channel.index) self._preload_paths(messages) new_items = [] divider_inserted = False last_date = None for msg in messages: msg_date = msg.timestamp.date() if last_date is not None and msg_date != last_date: new_items.append( ChannelChatItem( kind=ChannelChatItem.KIND_DATE_DIVIDER, date_text=_format_date_text(msg.timestamp), ) ) last_date = msg_date if ( not divider_inserted and last_read_at is not None and not msg.is_outgoing and msg.timestamp.timestamp() > last_read_at ): new_items.append(ChannelChatItem(kind=ChannelChatItem.KIND_UNREAD_DIVIDER)) self._unread_pos = len(new_items) - 1 divider_inserted = True new_items.append(self._create_message_item(msg)) saved_pos = self._scroll_positions.get(channel.index) if saved_pos is not None: self._is_sticky = False self._restoring_scroll = True # Replace all items in a single atomic operation self._store.splice(0, self._store.get_n_items(), new_items) if saved_pos is not None: GLib.idle_add(lambda pos=saved_pos: self._restore_scroll_position(pos)) else: GLib.idle_add(self._scroll_to_bottom) if self._unread_pos >= 0: GLib.idle_add(lambda: self._update_unread_btn_visibility() or False) # Mark as read now self._window.mark_channel_read(channel.index) def _preload_paths(self, messages): if not self._window.storage: return for msg in messages: key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) if msg.is_outgoing: paths = self._window.storage.get_channel_message_paths( msg.channel_index, int(msg.timestamp.timestamp()), msg.text, ) if paths: self._repeats[key] = [ { "path_hops": self._split_path_hops( p.get("path_bytes", ""), p.get("path_len", 0), ), "path_len": p.get("path_len", 0), } for p in paths ] else: if key not in self._incoming_paths: paths = self._window.storage.get_channel_message_paths( msg.channel_index, int(msg.timestamp.timestamp()), msg.text, ) if paths: self._incoming_paths[key] = paths def _load_older_messages(self): if self._all_loaded or self._loading_more or not self._current_channel: return self._loading_more = True messages = self._window.get_channel_messages( self._current_channel.index, limit=_BATCH_SIZE, offset=self._messages_offset ) if not messages: self._all_loaded = True self._loading_more = False return self._preload_paths(messages) older_items = [] last_date = None for msg in messages: msg_date = msg.timestamp.date() if last_date is not None and msg_date != last_date: older_items.append( ChannelChatItem( kind=ChannelChatItem.KIND_DATE_DIVIDER, date_text=_format_date_text(msg.timestamp), ) ) last_date = msg_date older_items.append(self._create_message_item(msg)) # Add boundary date divider if the dates differ if last_date is not None: for i in range(self._store.get_n_items()): first = self._store.get_item(i) if first.kind == ChannelChatItem.KIND_MESSAGE: if first.message.timestamp.date() != last_date: older_items.append( ChannelChatItem( kind=ChannelChatItem.KIND_DATE_DIVIDER, date_text=_format_date_text(first.message.timestamp), ) ) break self._store.splice(0, 0, older_items) if self._unread_pos >= 0: self._unread_pos += len(older_items) self._messages_offset += len(messages) self._all_loaded = len(messages) < _BATCH_SIZE self._loading_more = False @staticmethod def _split_path_hops(path_bytes_hex: str, hop_count: int) -> list[str]: """Split concatenated path hex into individual hop prefixes. Supports 1-byte (2 hex), 2-byte (4 hex), and 3-byte (6 hex) hash sizes. The hash size is derived from total bytes / hop count. """ if not path_bytes_hex or hop_count <= 0: return [] total_bytes = len(path_bytes_hex) // 2 if total_bytes % hop_count == 0: hash_size = total_bytes // hop_count else: hash_size = 1 # fallback chars_per_hop = hash_size * 2 return [ path_bytes_hex[i : i + chars_per_hop].upper() for i in range(0, len(path_bytes_hex), chars_per_hop) ] def _make_msg_key(self, channel_index, timestamp, text): """Create a key for matching outgoing messages to repeats.""" ts = int(timestamp.timestamp()) if hasattr(timestamp, "timestamp") else int(timestamp) return (channel_index, ts, text) def _create_message_item(self, msg: ChannelMessage) -> ChannelChatItem: """Create a ChannelChatItem and register it in tracking dicts.""" item = ChannelChatItem(kind=ChannelChatItem.KIND_MESSAGE, message=msg) key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) if msg.is_outgoing: self._outgoing_items[key] = item if key not in self._repeats: self._repeats[key] = [] # Set initial repeat count from persisted data item._repeat_count_val = len(self._repeats.get(key, [])) else: self._incoming_items[key] = item paths = self._incoming_paths.get(key, []) if paths: item._max_hops_val = max(p.get("path_len", -1) for p in paths) return item def _add_message_item(self, msg: ChannelMessage): """Create a ChannelChatItem and add it to the store.""" item = self._create_message_item(msg) self._store.append(item) # ─── Dialogs (unchanged) ───────────────────────────────── def _show_message_paths(self, key, msg): """Show dialog with message path variants and repeater hops.""" paths = self._incoming_paths.get(key, []) # Fallback to storage if no in-memory data if not paths and self._window.storage: paths = self._window.storage.get_channel_message_paths( msg.channel_index, int(msg.timestamp.timestamp()), msg.text, ) if paths: self._incoming_paths[key] = paths if not paths: dialog = Adw.AlertDialog( heading=_("Message Paths"), body=_( "No path information available. Path data is only " "captured for messages received while connected." ), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/message-paths-dialog.ui" ) dialog = builder.get_object("dialog") content = builder.get_object("content") info_label = Gtk.Label( label=f'Message from {msg.sender_name or "unknown"} ' f'received via {len(paths)} path{"s" if len(paths) != 1 else ""}:', wrap=True, xalign=0, ) info_label.add_css_class("dim-label") content.append(info_label) for i, p in enumerate(paths, 1): snr = p.get("snr") path_len = p.get("path_len", -1) path_bytes_hex = p.get("path_bytes", "") if path_bytes_hex and path_len > 0: hops = self._split_path_hops(path_bytes_hex, path_len) num_hops = len(hops) if hops else path_len hops_str = f'{num_hops} hop{"s" if num_hops != 1 else ""}' elif path_len == 255 or path_len == -1: hops_str = "flood" elif path_len == 0: hops_str = "direct" else: hops_str = f'{path_len} hop{"s" if path_len != 1 else ""}' snr_str = f"SNR: {format_number(snr)} dB" if snr is not None else "" title_parts = [f"Path {i}: {hops_str}"] if snr_str: title_parts.append(snr_str) path_group = Adw.PreferencesGroup( title=" \u00b7 ".join(title_parts), ) if path_bytes_hex and path_len > 0: hops = self._split_path_hops(path_bytes_hex, path_len) hop_contacts = self._window.resolve_hop_prefixes(hops) for hop_idx, prefix in enumerate(hops): contact = hop_contacts[hop_idx] name = contact.name if contact else _("Unknown repeater") subtitle = f"Hop {hop_idx + 1} \u00b7 prefix {prefix}" hop_row = Adw.ActionRow( title=name, subtitle=subtitle, ) hop_row.add_prefix(Gtk.Image.new_from_icon_name("repeater-symbolic")) path_group.add(hop_row) if any(c and c.has_location for c in hop_contacts): map_btn = Gtk.Button(icon_name="map-symbolic", valign=Gtk.Align.CENTER) map_btn.add_css_class("flat") map_btn.set_tooltip_text(_("Show on Map")) map_btn.connect( "clicked", lambda b, hc=hop_contacts, hp=hops: self._show_channel_path_on_map(hc, hp), ) path_group.set_header_suffix(map_btn) elif path_len == 0: direct_row = Adw.ActionRow( title=_("Direct message"), subtitle=_("No repeaters were used"), sensitive=False, ) direct_row.add_prefix(Gtk.Image.new_from_icon_name("network-wireless-symbolic")) path_group.add(direct_row) else: no_path_row = Adw.ActionRow( title=_("No repeater details"), subtitle=_("Path data is only captured via LOG_DATA while connected"), sensitive=False, ) path_group.add(no_path_row) content.append(path_group) dialog.present(self._window) def _show_channel_path_on_map(self, hop_contacts, hop_prefixes=None): """Show a channel message path on the map.""" import gi from meshy.views.map_view import ( _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, _UNKNOWN_COLOR, MapView, ) gi.require_version("Shumate", "1.0") device = self._window.device_info self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) # Build full hop list including unknowns raw_hops = [] for i, c in enumerate(hop_contacts): prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None if c and c.has_location: raw_hops.append((c.latitude, c.longitude, c, prefix)) else: raw_hops.append((None, None, c, prefix)) if has_self: raw_hops.append((self_lat, self_lon, None, None)) # Interpolate unknown positions interp = [] for lat, lon, contact, prefix in raw_hops: interp.append((lat, lon, contact, prefix, None)) interp = MapView.interpolate_unknown_hops(interp) if not any(h[0] is not None for h in interp): return dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( self._window, title=_("Message Path") ) map_widget.get_map().remove_layer(marker_layer) path_layer = MapView.create_dashed_path_layer(viewport) map_widget.get_map().add_layer(path_layer) map_widget.get_map().add_layer(marker_layer) lats, lons = [], [] hop_num = 0 for lat, lon, contact, prefix, _snr in interp: if lat is None: continue node = Shumate.Marker() node.set_location(lat, lon) path_layer.add_node(node) if contact is None and prefix is None: # Self/device marker r, g, b = _SELF_COLOR icon_name = "network-wireless-symbolic" name = device.name or _("My Device") type_label = None badge = "" elif contact is None or not contact.has_location: # Unknown/interpolated hop r, g, b = _UNKNOWN_COLOR icon_name = "repeater-symbolic" name = prefix or _("Unknown") type_label = _("Unknown repeater") hop_num += 1 badge = str(hop_num) else: r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") name = contact.name type_label = contact.type_label hop_num += 1 badge = str(hop_num) is_interpolated = (contact is None or not contact.has_location) and prefix is not None detail_cb = (lambda c=contact: self._window.show_contact_detail(c)) if contact else None widget = MapView._build_marker_widget(r, g, b, icon_name, "", badge_text=badge) widget.set_tooltip_text(name) click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, w=widget, nm=name, la=lat, lo=lon, t=type_label, ip=is_interpolated, od=detail_cb: MapView.show_marker_popover( w, nm, la, lo, contact_type=t, interpolated=ip, on_detail=od ), ) widget.add_controller(click) hop_marker = Shumate.Marker() hop_marker.set_child(widget) hop_marker.set_location(lat, lon) marker_layer.add_marker(hop_marker) lats.append(lat) lons.append(lon) MapView.fit_viewport(viewport, lats, lons) dialog.present(self._window) def _reply_to_message(self, msg): """Insert @[sender_name] into the text entry.""" mention = f"@[{msg.sender_name}] " if msg.sender_name else "" self._text_entry.set_text(mention) self._text_entry.set_position(len(mention)) self._text_entry.grab_focus() def _copy_message_text(self, msg): """Copy message text to clipboard.""" clipboard = self._window.get_clipboard() clipboard.set(msg.text) def _show_repeats_dialog(self, key): repeats = self._repeats.get(key, []) if not repeats: dialog = Adw.AlertDialog( heading=_("Heard Repeats"), body=_("No repeats heard yet."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return dialog = Adw.AlertDialog(heading=_("Heard Repeats")) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) for i, rep in enumerate(repeats, 1): hops = rep.get("path_hops", []) if hops: hop_names = [] for prefix in hops: contact = self._window.find_contact_by_key_prefix(prefix.lower()) hop_names.append(contact.name if contact else prefix) path_str = " \u2192 ".join(hop_names) else: path_len = rep.get("path_len", 0) path_str = ( f'{path_len} hop{"s" if path_len != 1 else ""}' if path_len > 0 else "direct" ) label = Gtk.Label(label=f"{i}. {path_str}", xalign=0, wrap=True) content.append(label) dialog.set_extra_child(content) dialog.add_response("ok", _("OK")) dialog.present(self._window) # ─── Message actions ───────────────────────────────────── def _resend_message(self, msg, item): if self._current_channel: new_ts = self._window.resend_channel_message(self._current_channel.index, msg.text) original_key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) resend_key = (msg.channel_index, new_ts, msg.text) self._resend_ts_map[resend_key] = original_key item.props.resend_count = item.resend_count + 1 def _delete_message_item(self, item): msg = item.message # Find and remove from store for i in range(self._store.get_n_items()): if self._store.get_item(i) is item: self._store.remove(i) break # Clean up tracking dicts key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) self._outgoing_items.pop(key, None) self._incoming_items.pop(key, None) self._repeats.pop(key, None) self._incoming_paths.pop(key, None) self._seen_channel_msgs.discard(key) # Delete from storage self._window.delete_channel_message( msg.channel_index, msg.timestamp.timestamp(), msg.text, ) # ─── Input ─────────────────────────────────────────────── def _get_channel_limit(self) -> int: sender_name = self._window.device_info.name or "" return max_channel_text_bytes(sender_name) def _on_text_changed(self, entry): text = entry.get_text() used = len(text.encode("utf-8")) limit = self._get_channel_limit() if used == 0: self._char_label.set_label("") self._send_btn.set_sensitive(True) self._char_label.remove_css_class("error") else: self._char_label.set_label(f"{used}/{limit}") over = used > limit self._send_btn.set_sensitive(not over) if over: self._char_label.add_css_class("error") else: self._char_label.remove_css_class("error") self._check_mention_trigger(text, entry.get_position()) def _on_send(self, *args): if not self._current_channel: return text = self._text_entry.get_text().strip() if not text: return if len(text.encode("utf-8")) > self._get_channel_limit(): return self._text_entry.set_text("") self._window.send_channel_message(self._current_channel.index, text) # ─── External callbacks ────────────────────────────────── def on_channel_message_sent(self, msg: ChannelMessage, sender_ts: int): """Called when a channel message is sent by us.""" if ( self._current_channel and msg.channel_index == self._current_channel.index and self._stack.get_visible_child_name() == "chat" ): # Force sticky so notify::upper handler scrolls to the new message self._is_sticky = True self._add_message_item(msg) def _update_item_max_hops(self, key, path_info): item = self._incoming_items.get(key) if item and path_info: new_len = path_info.get("path_len", -1) if new_len > item.max_hops: item.props.max_hops = new_len def on_path_variant_received(self, msg, path_info=None): """Check if this is a duplicate message via a different path. Returns True if it's a duplicate (already seen), False if new.""" key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) if key in self._seen_channel_msgs: if path_info: self._incoming_paths.setdefault(key, []).append(path_info) self._update_item_max_hops(key, path_info) return True self._seen_channel_msgs.add(key) if path_info: self._incoming_paths.setdefault(key, []).append(path_info) return False def add_path_info(self, msg, path_info): """Append path_info to an existing message without affecting dedup.""" key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) if key in self._incoming_paths: self._incoming_paths[key].append(path_info) else: self._incoming_paths[key] = [path_info] self._update_item_max_hops(key, path_info) def on_channel_message_received(self, msg: ChannelMessage, path_info=None): if ( self._current_channel and msg.channel_index == self._current_channel.index and self._stack.get_visible_child_name() == "chat" ): # If sticky, the notify::upper handler will auto-scroll self._add_message_item(msg) def on_repeat_received(self, channel_idx, sender_ts, text, path_hops): """Called when an echo/repeat of our outgoing message is received. Returns the original message key (channel_idx, ts, text) if matched, so the caller can persist path info under the original timestamp. """ repeat_entry = { "path_hops": path_hops or [], "path_len": len(path_hops) if path_hops else 0, } # Check if this echo matches a resend timestamp resend_key = (channel_idx, sender_ts, text) matched_key = self._resend_ts_map.pop(resend_key, None) if matched_key is None: # Fall back to heuristic: same channel + timestamp within 30s + matching text for k in self._outgoing_items: if k[0] != channel_idx: continue ts_diff = abs(k[1] - sender_ts) if ts_diff > 30: continue if k[2] == text or text.startswith(k[2]) or k[2].startswith(text): matched_key = k break if matched_key is None: key = (channel_idx, sender_ts, text) if key not in self._repeats: self._repeats[key] = [] self._repeats[key].append(repeat_entry) return None if matched_key not in self._repeats: self._repeats[matched_key] = [] self._repeats[matched_key].append(repeat_entry) item = self._outgoing_items.get(matched_key) if item: item.props.repeat_count = len(self._repeats[matched_key]) return matched_key @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/channels-view.ui") class ChannelsView(Gtk.Box): """Channels list - shown in the sidebar.""" __gtype_name__ = "MeshyChannelsView" _channel_list = Gtk.Template.Child("channel_list") _search_bar = Gtk.Template.Child("search_bar") _search_entry = Gtk.Template.Child("search_entry") _status_label = Gtk.Template.Child("status_label") FILTER_ALL = "all" FILTER_PUBLIC = "public" FILTER_HASHTAG = "hashtag" FILTER_PRIVATE = "private" SORT_AZ = "az" SORT_MESSAGES = "messages" def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._chat_widget = ChannelChatWidget(window) self._unread_indices: set[int] = set() self._unread_counts: dict[int, int] = {} self._unread_dots: dict[int, Gtk.Label] = {} self._current_filter = self.FILTER_ALL settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") saved_sort = settings.get_string("channel-sort") self._current_sort = ( saved_sort if saved_sort in (self.SORT_AZ, self.SORT_MESSAGES) else self.SORT_MESSAGES ) self._total_channels = 0 self._channel_list.connect("row-activated", self._on_row_selected) self._search_bar.connect_entry(self._search_entry) self._search_bar.connect("notify::search-mode-enabled", self._on_search_mode_changed) self._search_entry.connect("search-changed", self._on_search_changed) self._channel_list.set_filter_func(self._filter_func) # Actions for the add channel menu and filter/sort action_group = Gio.SimpleActionGroup() action = Gio.SimpleAction.new("create-private", None) action.connect("activate", self._on_create_private_channel) action_group.add_action(action) action = Gio.SimpleAction.new("join-private", None) action.connect("activate", self._on_join_private_channel) action_group.add_action(action) action = Gio.SimpleAction.new("join-public", None) action.connect("activate", self._on_join_public_channel) action_group.add_action(action) action = Gio.SimpleAction.new("join-hashtag", None) action.connect("activate", self._on_join_hashtag_channel) action_group.add_action(action) filter_action = Gio.SimpleAction.new_stateful( "filter", GLib.VariantType.new("s"), GLib.Variant.new_string(self._current_filter) ) filter_action.connect("activate", self._on_filter_changed) action_group.add_action(filter_action) sort_action = Gio.SimpleAction.new_stateful( "sort", GLib.VariantType.new("s"), GLib.Variant.new_string(self._current_sort) ) sort_action.connect("activate", self._on_sort_changed) action_group.add_action(sort_action) self._action_group = action_group self.insert_action_group("channel", action_group) self._search_btn = None self._search_binding = None self.update_channels(self._window.channels) def get_chat_widget(self) -> ChannelChatWidget: return self._chat_widget def set_search_button(self, btn): if self._search_binding: self._search_binding.unbind() self._search_btn = btn self._search_binding = btn.bind_property( "active", self._search_bar, "search-mode-enabled", GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE, ) def build_filter_menu(self): menu = Gio.Menu() for filter_id, label in ( ("all", _("All")), ("public", _("Public")), ("hashtag", _("Hashtag")), ("private", _("Private")), ): item = Gio.MenuItem.new(label, None) item.set_action_and_target_value("channel.filter", GLib.Variant.new_string(filter_id)) menu.append_item(item) return menu def build_sort_menu(self): menu = Gio.Menu() for sort_id, label in ( ("az", _("A–Z")), ("messages", _("Latest Messages")), ): item = Gio.MenuItem.new(label, None) item.set_action_and_target_value("channel.sort", GLib.Variant.new_string(sort_id)) menu.append_item(item) return menu def _filter_func(self, row) -> bool: if not hasattr(row, "_channel"): return True channel = row._channel f = self._current_filter if ( f == self.FILTER_PUBLIC and not channel.is_public_channel or f == self.FILTER_HASHTAG and not channel.is_hashtag_channel or f == self.FILTER_PRIVATE and (channel.is_public_channel or channel.is_hashtag_channel) ): return False search = self._search_entry.get_text().lower() return not (search and search not in (channel.name or "").lower()) def _update_status_count(self): total = self._total_channels if self._current_filter != self.FILTER_ALL or self._search_entry.get_text(): visible = 0 row = self._channel_list.get_first_child() while row: if hasattr(row, "_channel") and self._filter_func(row): visible += 1 row = row.get_next_sibling() self._status_label.set_label( _("{visible}/{total} channels").format(visible=visible, total=total) ) else: self._status_label.set_label( _("{total} channels").format(total=total) if total != 1 else _("{total} channel").format(total=total) ) def _on_search_mode_changed(self, search_bar, pspec): if not search_bar.get_search_mode(): self._search_entry.set_text("") def _on_search_changed(self, entry): self._channel_list.invalidate_filter() GLib.idle_add(self._update_status_count) def _on_filter_changed(self, action, param): self._current_filter = param.get_string() action.set_state(param) self._channel_list.invalidate_filter() GLib.idle_add(self._update_status_count) def _on_sort_changed(self, action, param): self._current_sort = param.get_string() action.set_state(param) settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") settings.set_string("channel-sort", self._current_sort) self.update_channels(self._window.channels) def _channel_sort_key(self, channel: Channel): type_order = 0 if channel.is_public_channel else (1 if channel.is_hashtag_channel else 2) if self._current_sort == self.SORT_AZ: return (type_order, (channel.name or "").lower()) else: last_msg = self._chat_widget.get_last_message_time(channel.index) return (type_order, -(last_msg or 0)) def _on_row_selected(self, listbox, row): if row and hasattr(row, "_channel"): self._chat_widget.set_channel(row._channel) self._window._set_channel_title_widget(row._channel) self._window.split_view.set_show_content(True) self.mark_unread(row._channel.index, False) def update_channels(self, channels: list[Channel]): clear_children(self._channel_list) non_empty = [c for c in channels if not c.is_empty] self._total_channels = len(non_empty) sorted_channels = sorted(non_empty, key=self._channel_sort_key) for channel in sorted_channels: row = Adw.ActionRow( title=channel.name or _("Channel {}").format(channel.index), subtitle=channel.region, ) if channel.is_hashtag_channel: icon_name = "hashtag-symbolic" elif channel.is_public_channel: icon_name = "system-users-symbolic" else: icon_name = "padlock2-symbolic" row.add_prefix(Gtk.Image.new_from_icon_name(icon_name)) row.set_activatable(True) row._channel = channel # Unread badge — visible by default when count > 0, hidden on hover count = self._unread_counts.get(channel.index, 0) unread_badge = Gtk.Label(label=str(count) if count else "") unread_badge.add_css_class("unread-badge") unread_badge.set_valign(Gtk.Align.CENTER) unread_badge.set_visible(count > 0) row.add_suffix(unread_badge) self._unread_dots[channel.index] = unread_badge # "More" button + hover behavior + long press info_btn = Gtk.Button(icon_name="view-more-symbolic", valign=Gtk.Align.CENTER) info_btn.add_css_class("flat") info_btn.set_visible(False) on_detail = lambda *_, ch=channel: self._show_channel_detail(ch) info_btn.connect("clicked", on_detail) row.add_suffix(info_btn) _add_hover_swap( row, info_btn, unread_badge, lambda _idx=channel.index: self._unread_counts.get(_idx, 0), on_detail, ) self._channel_list.append(row) GLib.idle_add(self._update_status_count) def _get_visible_rows(self): """Return visible rows in display order.""" rows = [] row = self._channel_list.get_first_child() while row: if row.get_visible() and hasattr(row, "_channel"): rows.append(row) row = row.get_next_sibling() return rows def navigate(self, direction: int, unread_only: bool = False): """Move selection up (-1) or down (+1). If unread_only, skip to next unread.""" rows = self._get_visible_rows() if not rows: return if unread_only: rows = [r for r in rows if r._channel.index in self._unread_indices] if not rows: return selected = self._channel_list.get_selected_row() if selected and selected in rows: idx = rows.index(selected) idx = (idx + direction) % len(rows) else: idx = 0 if direction > 0 else len(rows) - 1 self._channel_list.select_row(rows[idx]) rows[idx].grab_focus() def on_path_variant_received(self, msg, path_info=None): return self._chat_widget.on_path_variant_received(msg, path_info) def add_path_info(self, msg, path_info): self._chat_widget.add_path_info(msg, path_info) def on_channel_message_received(self, msg: ChannelMessage, path_info=None): self._chat_widget.on_channel_message_received(msg, path_info) def on_channel_message_sent(self, msg: ChannelMessage, sender_ts: int): self._chat_widget.on_channel_message_sent(msg, sender_ts) def on_repeat_received(self, channel_idx, sender_ts, text, path_hops): return self._chat_widget.on_repeat_received(channel_idx, sender_ts, text, path_hops) def mark_unread(self, channel_index: int, unread: bool): """Show or hide the unread badge for a channel.""" if unread: self._unread_indices.add(channel_index) self._unread_counts[channel_index] = self._unread_counts.get(channel_index, 0) + 1 else: self._unread_indices.discard(channel_index) self._unread_counts[channel_index] = 0 if channel_index in self._unread_dots: badge = self._unread_dots[channel_index] count = self._unread_counts.get(channel_index, 0) badge.set_label(str(count) if count else "") badge.set_visible(count > 0) self._window.update_nav_unread_channels() def _show_channel_detail(self, channel: Channel): """Show channel detail dialog with rename, PSK, notifications, and remove.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/channel-detail-dialog.ui" ) dialog = builder.get_object("dialog") content = builder.get_object("content") dialog.set_title(channel.name or _("Channel {}").format(channel.index)) # Info group info_group = Adw.PreferencesGroup(title=_("Channel Info")) if channel.is_public_channel: name_entry = Adw.EntryRow(title=_("Name")) name_entry.set_text(channel.name) name_entry.set_show_apply_button(True) def _on_rename_apply(*args): new_name = name_entry.get_text().strip() if new_name: self._window.set_channel(channel.index, new_name, channel.psk) name_entry.connect("apply", _on_rename_apply) info_group.add(name_entry) else: info_group.add( Adw.ActionRow( title=_("Name"), subtitle=channel.name or _("Channel {}").format(channel.index), ) ) info_group.add( Adw.ActionRow( title=_("Type"), subtitle=channel.channel_type, ) ) # Private Key row with copy button (only for private channels) if not channel.is_public_channel and not channel.is_hashtag_channel: psk_row = Adw.ActionRow( title=_("Private Key"), subtitle=channel.psk_hex, ) psk_row.set_subtitle_selectable(True) copy_btn = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER) copy_btn.add_css_class("flat") copy_btn.set_tooltip_text("Copy private key to clipboard") def _on_copy_psk(*args): clipboard = self._window.get_clipboard() clipboard.set(channel.psk_hex) self._window.show_toast(_("Private key copied to clipboard"), timeout=2) copy_btn.connect("clicked", _on_copy_psk) psk_row.add_suffix(copy_btn) info_group.add(psk_row) content.append(info_group) # Chat group — participants participants = ( self._window.storage.get_channel_participants(channel.index) if self._window.storage else [] ) chat_group = Adw.PreferencesGroup(title=_("Chat")) sender_count = len(participants) participants_row = Adw.ActionRow( title=_("Participants"), subtitle=ngettext("{} participant", "{} participants", sender_count).format( sender_count ), ) participants_row.add_suffix(Gtk.Image(icon_name="go-next-symbolic")) participants_row.set_activatable(True) def _on_participants_activated(*_args): self._show_channel_participants(channel, dialog) participants_row.connect("activated", _on_participants_activated) chat_group.add(participants_row) retention_row = Adw.ComboRow(title=_("Message Retention")) retention_list = Gtk.StringList() retention_options = [ (1, _("1 Day")), (7, _("1 Week")), (30, _("1 Month")), (90, _("3 Months")), (0, _("Forever")), ] for _days, label in retention_options: retention_list.append(label) retention_row.set_model(retention_list) days_to_index = {days: i for i, (days, _) in enumerate(retention_options)} retention_row.set_selected(days_to_index.get(channel.retention_days, 4)) def _on_retention_changed(*_args): selected = retention_row.get_selected() days = retention_options[selected][0] channel.retention_days = days if self._window.storage: self._window.storage.set_channel_retention_days(channel.index, days) retention_row.connect("notify::selected", _on_retention_changed) chat_group.add(retention_row) notif_row = Adw.ComboRow(title=_("Notification Level")) notif_list = Gtk.StringList() for label in [_("Default"), _("All Messages"), _("Mentions Only"), _("None")]: notif_list.append(label) notif_row.set_model(notif_list) level_to_index = { NotificationLevel.DEFAULT: 0, NotificationLevel.ALL: 1, NotificationLevel.MENTIONS: 2, NotificationLevel.NONE: 3, } notif_row.set_selected(level_to_index.get(channel.notification_level, 0)) def _on_notif_changed(*args): selected = notif_row.get_selected() level = [ NotificationLevel.DEFAULT, NotificationLevel.ALL, NotificationLevel.MENTIONS, NotificationLevel.NONE, ][selected] self._window.set_channel_notification_level(channel, level) notif_row.connect("notify::selected", _on_notif_changed) chat_group.add(notif_row) content.append(chat_group) # Region group available_regions = self._window.get_regions() region_group = Adw.PreferencesGroup( title=_("Region"), description=_("Limit flood messages to a specific region"), ) if available_regions: region_row = Adw.ComboRow(title=_("Region")) region_list = Gtk.StringList() region_list.append(_("None")) for r in available_regions: region_list.append(r) region_row.set_model(region_list) if channel.region in available_regions: region_row.set_selected(available_regions.index(channel.region) + 1) else: region_row.set_selected(0) def _on_region_changed(*args): selected = region_row.get_selected() new_region = available_regions[selected - 1] if selected > 0 else "" channel.region = new_region if self._window.storage: self._window.storage.set_channel_region(channel.index, new_region) self.update_channels(self._window._channels) region_row.connect("notify::selected", _on_region_changed) region_group.add(region_row) else: hint_row = Adw.ActionRow( title=_("No regions defined"), subtitle=_("Add regions in Settings to assign them to channels"), ) hint_row.add_suffix(Gtk.Image(icon_name="go-next-symbolic")) hint_row.set_activatable(True) def _on_hint_activated(*args): dialog.connect( "closed", lambda *a: self._window._nav_buttons["settings"].set_active(True) ) dialog.close() hint_row.connect("activated", _on_hint_activated) region_group.add(hint_row) content.append(region_group) # Actions group actions_group = Adw.PreferencesGroup(title=_("Actions")) clear_row = Adw.ActionRow(title=_("Clear Messages")) clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic", valign=Gtk.Align.CENTER) clear_btn.add_css_class("destructive-action") def _on_clear(*_args): confirm = Adw.AlertDialog( heading=_("Clear Messages?"), body=_('Delete all messages in "{}"?').format( channel.name or _("Channel {}").format(channel.index) ), ) confirm.add_response("cancel", _("Cancel")) confirm.add_response("clear", _("Clear")) confirm.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE) def _on_confirm(d, response): if response != "clear": return self._window.storage.clear_channel_messages(channel.index) self._reload_channel_chat(channel) self._window.add_toast(_("Messages cleared"), timeout=2) confirm.connect("response", _on_confirm) confirm.present(self._window) clear_btn.connect("clicked", _on_clear) clear_row.add_suffix(clear_btn) clear_row.set_activatable_widget(clear_btn) actions_group.add(clear_row) remove_row = Adw.ActionRow(title=_("Remove Channel")) remove_btn = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER) remove_btn.add_css_class("destructive-action") def _on_remove(*args): confirm = Adw.AlertDialog( heading=_("Remove Channel?"), body=_('Remove "{}" and all its messages?').format( channel.name or _("Channel {}").format(channel.index) ), ) confirm.add_response("cancel", _("Cancel")) confirm.add_response("remove", _("Remove")) confirm.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) confirm.connect( "response", lambda d, r: (self._window.remove_channel(channel), dialog.close()) if r == "remove" else None, ) confirm.present(self._window) remove_btn.connect("clicked", _on_remove) remove_row.add_suffix(remove_btn) remove_row.set_activatable_widget(remove_btn) actions_group.add(remove_row) content.append(actions_group) dialog.present(self._window) def _show_channel_participants(self, channel: Channel, parent_dialog): """Show a dialog listing all senders seen in a channel with block/unblock.""" import time as _time storage = self._window.storage if not storage: return participants = storage.get_channel_participants(channel.index) # Wrapper object for the list store class _SenderItem(GObject.Object): def __init__(self, name, message_count, last_seen_ts, is_blocked): super().__init__() self.sender_name = name self.message_count = message_count self.last_seen_ts = last_seen_ts self.is_blocked = is_blocked # Build dialog builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/channel-participants-dialog.ui" ) dialog = builder.get_object("dialog") search_btn = builder.get_object("search_btn") filter_btn = builder.get_object("filter_btn") sort_btn = builder.get_object("sort_btn") search_entry = builder.get_object("search_entry") list_view = builder.get_object("list_view") status_label = builder.get_object("status_label") search_btn.bind_property( "active", search_entry, "visible", GObject.BindingFlags.SYNC_CREATE ) search_entry.connect("stop-search", lambda _e: search_btn.set_active(False)) # Filter menu filter_menu = Gio.Menu() for fid, label in (("all", _("All")), ("blocked", _("Blocked"))): filter_menu.append_item(Gio.MenuItem.new(label, f"participants.filter::{fid}")) filter_btn.set_menu_model(filter_menu) # Sort menu sort_menu = Gio.Menu() for sid, label in ( ("name", _("Name")), ("last-seen", _("Last Seen")), ("messages", _("Messages")), ): sort_menu.append_item(Gio.MenuItem.new(label, f"participants.sort::{sid}")) sort_btn.set_menu_model(sort_menu) # Actions action_group = Gio.SimpleActionGroup() filter_action = Gio.SimpleAction.new_stateful( "filter", GLib.VariantType.new("s"), GLib.Variant.new_string("all") ) sort_action = Gio.SimpleAction.new_stateful( "sort", GLib.VariantType.new("s"), GLib.Variant.new_string("name") ) action_group.add_action(filter_action) action_group.add_action(sort_action) dialog.insert_action_group("participants", action_group) # Model store = Gio.ListStore(item_type=_SenderItem) for p in participants: store.append( _SenderItem( p["sender_name"], p["message_count"], p["last_seen"], p["is_blocked"], ) ) # Filter custom_filter = Gtk.CustomFilter.new( lambda item, ud: self._participants_filter_func(item, ud), (search_entry, filter_action), ) def _on_filter_or_search_changed(*_args): custom_filter.changed(Gtk.FilterChange.DIFFERENT) _update_status() def _on_filter_activated(_action, param): filter_action.set_state(param) _on_filter_or_search_changed() search_entry.connect("search-changed", _on_filter_or_search_changed) filter_action.connect("activate", _on_filter_activated) filter_model = Gtk.FilterListModel(model=store, filter=custom_filter) # Sorter custom_sorter = Gtk.CustomSorter.new( lambda a, b, ud: self._participants_sort_func(a, b, ud), sort_action, ) def _on_sort_activated(_action, param): sort_action.set_state(param) custom_sorter.changed(Gtk.SorterChange.DIFFERENT) sort_action.connect("activate", _on_sort_activated) sort_model = Gtk.SortListModel(model=filter_model, sorter=custom_sorter) selection = Gtk.NoSelection(model=sort_model) list_view.set_model(selection) def _update_status(): total = store.get_n_items() blocked_count = sum(1 for i in range(total) if store.get_item(i).is_blocked) visible = filter_model.get_n_items() if blocked_count > 0: status_label.set_label( ngettext("{} participant", "{} participants", total).format(total) + ", " + ngettext("{} blocked", "{} blocked", blocked_count).format(blocked_count) ) else: status_label.set_label( ngettext("{} participant", "{} participants", total).format(total) ) # Factory def _on_setup(factory, list_item): row = Adw.ActionRow() icon = Gtk.Image() row.add_prefix(icon) btn = Gtk.Button(valign=Gtk.Align.CENTER) btn.set_visible(False) row.add_suffix(btn) row._icon = icon row._action_btn = btn row._action_handler_id = None hover = Gtk.EventControllerMotion() hover.connect("enter", lambda *_a: btn.set_visible(True)) hover.connect("leave", lambda *_a: btn.set_visible(False)) row.add_controller(hover) list_item.set_child(row) def _apply_blocked_style(row, item): btn = row._action_btn if item.is_blocked: row._icon.set_from_icon_name("action-unavailable-symbolic") row._icon.add_css_class("dim-label") btn.set_label(_("Unblock")) btn.remove_css_class("destructive-action") btn.add_css_class("suggested-action") else: row._icon.set_from_icon_name("avatar-default-symbolic") row._icon.remove_css_class("dim-label") btn.set_label(_("Block")) btn.remove_css_class("suggested-action") btn.add_css_class("destructive-action") def _on_bind(factory, list_item): row = list_item.get_child() item = list_item.get_item() row.set_title(GLib.markup_escape_text(item.sender_name)) subtitle = ( ngettext("{} message", "{} messages", item.message_count).format(item.message_count) + " · " + ( format_time_ago(_time.time() - item.last_seen_ts) if item.last_seen_ts else _("Never") ) ) row.set_subtitle(subtitle) _apply_blocked_style(row, item) def _on_block_clicked(_btn): if item.is_blocked: storage.unblock_channel_sender(item.sender_name) item.is_blocked = False self._window._frame_handler.update_blocked_senders() self._window.show_toast(_("Unblocked {}").format(item.sender_name), timeout=2) _apply_blocked_style(row, item) _update_status() else: self._confirm_block_sender( item, channel, row, dialog, _apply_blocked_style, _update_status ) if row._action_handler_id is not None: row._action_btn.disconnect(row._action_handler_id) row._action_handler_id = row._action_btn.connect("clicked", _on_block_clicked) def _on_unbind(factory, list_item): row = list_item.get_child() if row._action_handler_id is not None: row._action_btn.disconnect(row._action_handler_id) row._action_handler_id = None factory = Gtk.SignalListItemFactory() factory.connect("setup", _on_setup) factory.connect("bind", _on_bind) factory.connect("unbind", _on_unbind) list_view.set_factory(factory) # Prevent GC dialog._refs = ( store, custom_filter, filter_model, custom_sorter, sort_model, selection, factory, action_group, ) _update_status() dialog.present(self._window) def _confirm_block_sender( self, item, channel, row, participants_dialog, apply_style_func, update_status_func ): storage = self._window.storage confirm = Adw.AlertDialog( heading=_("Block {}?").format(item.sender_name), body=_( "Channel messages don't include sender identity. " "This block matches by name only — the sender could " "change their name to bypass it." ), ) confirm.add_response("cancel", _("Cancel")) confirm.add_response("block", _("Block")) confirm.add_response("block-delete", _("Block and Delete Messages")) confirm.set_response_appearance("block", Adw.ResponseAppearance.DESTRUCTIVE) confirm.set_response_appearance("block-delete", Adw.ResponseAppearance.DESTRUCTIVE) def _on_response(d, response): if response == "cancel": return storage.block_channel_sender(item.sender_name) item.is_blocked = True self._window._frame_handler.update_blocked_senders() apply_style_func(row, item) update_status_func() if response == "block-delete": storage.delete_channel_messages_by_sender(channel.index, item.sender_name) self._reload_channel_chat(channel) self._window.show_toast(_("Blocked {}").format(item.sender_name), timeout=2) confirm.connect("response", _on_response) confirm.present(self._window) def _reload_channel_chat(self, channel): w = self._chat_widget if w._current_channel and w._current_channel.index == channel.index: w._load_messages(channel) @staticmethod def _participants_filter_func(item, user_data): search_entry, filter_action = user_data search = search_entry.get_text().lower() if search and search not in item.sender_name.lower(): return False f = filter_action.get_state().get_string() if f == "blocked" and not item.is_blocked: return False return True @staticmethod def _participants_sort_func(a, b, user_data): sort_action = user_data mode = sort_action.get_state().get_string() if mode == "last-seen": ta = a.last_seen_ts or 0 tb = b.last_seen_ts or 0 if ta != tb: return (tb > ta) - (tb < ta) elif mode == "messages": if a.message_count != b.message_count: return (b.message_count > a.message_count) - (b.message_count < a.message_count) na = a.sender_name.lower() nb = b.sender_name.lower() return (na > nb) - (na < nb) def _find_next_empty_index(self) -> int | None: """Find the next available channel index (0-39).""" used = {ch.index for ch in self._window.channels if not ch.is_empty} for i in range(40): if i not in used: return i return None def _show_no_slots_dialog(self): dialog = Adw.AlertDialog( heading=_("No Slots Available"), body=_("All 8 channel slots are in use."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) def _on_create_private_channel(self, *args): idx = self._find_next_empty_index() if idx is None: self._show_no_slots_dialog() return dialog = Adw.AlertDialog( heading=_("Create a Private Channel"), body=_("A random PSK will be generated. Share it with others so they can join."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/create-private-channel-content.ui" ) dialog.set_extra_child(builder.get_object("content")) name_row = builder.get_object("name_row") dialog.add_response("cancel", _("Cancel")) dialog.add_response("create", _("Create")) dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "create": import os name = name_row.get_text().strip() if not name: return psk = os.urandom(16) self._window.set_channel(idx, name, psk) dialog.connect("response", _on_response) dialog.present(self._window) def _on_join_private_channel(self, *args): idx = self._find_next_empty_index() if idx is None: self._show_no_slots_dialog() return dialog = Adw.AlertDialog( heading=_("Join a Private Channel"), body=_("Enter the channel name and private key shared with you."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/join-private-channel-content.ui" ) dialog.set_extra_child(builder.get_object("content")) name_row = builder.get_object("name_row") psk_row = builder.get_object("psk_row") dialog.add_response("cancel", _("Cancel")) dialog.add_response("join", _("Join")) dialog.set_response_appearance("join", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "join": try: name = name_row.get_text().strip() psk = bytes.fromhex(psk_row.get_text().strip()) if len(psk) == 16 and name: self._window.set_channel(idx, name, psk) except ValueError: pass dialog.connect("response", _on_response) dialog.present(self._window) def _on_join_public_channel(self, *args): # Check if already on the public channel for ch in self._window.channels: if ch.is_public_channel: dialog = Adw.AlertDialog( heading=_("Already Joined"), body=_("You are already on the public channel.") ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return idx = self._find_next_empty_index() if idx is None: self._show_no_slots_dialog() return psk = bytes.fromhex(Channel.PUBLIC_CHANNEL_PSK) self._window.set_channel(idx, _("Public"), psk) def _on_join_hashtag_channel(self, *args): idx = self._find_next_empty_index() if idx is None: self._show_no_slots_dialog() return dialog = Adw.AlertDialog( heading=_("Join a Hashtag Channel"), body=_("Enter a hashtag name. Anyone using the same hashtag can communicate."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/join-hashtag-channel-content.ui" ) dialog.set_extra_child(builder.get_object("content")) hashtag_row = builder.get_object("hashtag_row") dialog.add_response("cancel", _("Cancel")) dialog.add_response("join", _("Join")) dialog.set_response_appearance("join", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "join": tag = hashtag_row.get_text().strip() if not tag: return if not tag.startswith("#"): tag = f"#{tag}" h = hashlib.sha256(tag.encode()).digest() psk = h[:16] name = tag self._window.set_channel(idx, name, psk) dialog.connect("response", _on_response) dialog.present(self._window) meshy/src/views/chat_helpers.py000066400000000000000000000140361523412555500171550ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared helpers for chat_view and channels_view.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Shumate", "1.0") from gi.repository import Adw, Gio, GObject, Gtk, Shumate from meshy.views import ( _CONTACT_TYPE_ICONS, _contact_pick_factory_bind, _contact_pick_factory_setup, _contact_pick_factory_unbind, _ContactPickItem, create_shumate_map, ) CONTACT_TYPE_ICONS = _CONTACT_TYPE_ICONS def ensure_loc_map(outer, lat, lon): """Create or update the inline location map widget on a chat item.""" if outer._loc_map_widget is not None: outer._loc_viewport.set_location(lat, lon) outer._loc_marker.set_location(lat, lon) outer._loc_map_frame.set_child(outer._loc_map_widget) return from meshy.views.map_view import MapView loc_map, loc_marker_layer, loc_viewport = create_shumate_map(zoom_level=14) loc_map.set_vexpand(False) loc_map.set_hexpand(True) loc_map.set_size_request(100, 150) loc_map.get_scale().set_visible(False) loc_map.set_show_zoom_buttons(False) loc_map.get_license().set_visible(False) loc_marker_widget = MapView._build_marker_widget( 0.26, 0.52, 0.96, "find-location-symbolic", None, show_label=False ) loc_marker = Shumate.Marker() loc_marker.set_child(loc_marker_widget) loc_marker_layer.add_marker(loc_marker) loc_viewport.set_zoom_level(14) loc_viewport.set_location(lat, lon) loc_marker.set_location(lat, lon) outer._loc_map_widget = loc_map outer._loc_viewport = loc_viewport outer._loc_marker = loc_marker outer._loc_map_frame.set_child(loc_map) def show_location_map(window, lat, lon): """Open a dialog with the shared location on a map.""" from meshy.views.map_view import MapView dialog, map_w, m_layer, vp = MapView.open_map_dialog(window, _("Shared Location")) marker = MapView.create_marker( 0.26, 0.52, 0.96, "find-location-symbolic", _("Shared"), lat, lon ) m_layer.add_marker(marker) dev = window.device_info if dev and dev.latitude is not None and dev.longitude is not None: self_marker = MapView.create_marker( 0.90, 0.16, 0.22, "avatar-default-symbolic", dev.name or _("My Device"), dev.latitude, dev.longitude, ) m_layer.add_marker(self_marker) vp.set_zoom_level(14) vp.set_location(lat, lon) dialog.present(window) def open_location_external(btn, lat, lon): """Launch external map app for the given coordinates.""" launcher = Gtk.UriLauncher.new(f"geo:{lat},{lon}") launcher.launch(None, None, None, None) def share_contact_dialog(window, contacts, send_callback): """Show a contact picker dialog and call send_callback(contact) on selection.""" if not contacts: return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/share-contact-dialog.ui" ) dialog = builder.get_object("share_dialog") search_btn = builder.get_object("share_search_btn") search_entry = builder.get_object("share_search_entry") list_view = builder.get_object("share_list_view") status_label = builder.get_object("share_status_label") search_btn.bind_property("active", search_entry, "visible", GObject.BindingFlags.SYNC_CREATE) search_entry.connect("stop-search", lambda _e: search_btn.set_active(False)) store = Gio.ListStore(item_type=_ContactPickItem) for c in sorted(contacts, key=lambda c: c.name.lower()): store.append(_ContactPickItem(c)) custom_filter = Gtk.CustomFilter.new(_share_filter_func, search_entry) def _on_search_changed(*_args): custom_filter.changed(Gtk.FilterChange.DIFFERENT) search_entry.connect("search-changed", _on_search_changed) filter_model = Gtk.FilterListModel(model=store, filter=custom_filter) selection = Gtk.NoSelection(model=filter_model) list_view.set_model(selection) def _on_share(contact): send_callback(contact) dialog.close() factory = Gtk.SignalListItemFactory() factory.connect("setup", lambda f, li: _contact_pick_factory_setup(f, li, _("Share"))) factory.connect("bind", lambda f, li: _contact_pick_factory_bind(f, li, _on_share)) factory.connect("unbind", _contact_pick_factory_unbind) list_view.set_factory(factory) def _on_activate(lv, pos): item = filter_model.get_item(pos) if item: _on_share(item.contact) list_view.connect("activate", _on_activate) total = store.get_n_items() status_label.set_label(_("{total} contacts").format(total=total) if total else _("No contacts")) dialog._refs = (store, custom_filter, filter_model, selection, factory) dialog.present(window) def _share_filter_func(item, search_entry): q = search_entry.get_text().lower() return not q or q in item.contact.name.lower() def share_location(window, send_callback): """Share the device's current GPS coordinates via send_callback(text).""" dev = window.device_info if not dev or dev.latitude is None or dev.longitude is None: dialog = Adw.AlertDialog( heading=_("Location Not Available"), body=_("Your companion does not have a GPS location."), ) dialog.add_response("ok", _("OK")) dialog.present(window) return send_callback(f"{dev.latitude:.6f},{dev.longitude:.6f}") def share_location_map(window, send_callback): """Open a map picker to choose and share a location via send_callback(text).""" from meshy.views import open_map_picker dev = window.device_info lat = dev.latitude if dev and dev.latitude is not None else 0.0 lon = dev.longitude if dev and dev.longitude is not None else 0.0 open_map_picker( window, _("Share Location"), _("Share"), lat, lon, lambda la, lo: send_callback(f"{la:.6f},{lo:.6f}"), ) meshy/src/views/chat_mixin.py000066400000000000000000000133171523412555500166400ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared behaviour for chat-style ListViews (DM chat and channel chat).""" from gi.repository import GLib, Gtk class ChatScrollMixin: """Scroll management, context-menu helpers, and emoji picker. Host class must provide: _scroll Gtk.ScrolledWindow _list_view Gtk.ListView _store Gio.ListStore _scroll_down_revealer Gtk.Revealer _unread_btn_revealer Gtk.Revealer _text_entry Gtk.Entry / Gtk.Text _context_popover Gtk.PopoverMenu _window main application window _is_sticky bool _restoring_scroll bool _context_menu_open bool _frozen_scroll_value float _unread_pos int _load_older_messages() method """ # ─── Scroll state ──────────────────────────────────────── def _is_at_bottom(self) -> bool: adj = self._scroll.get_vadjustment() return adj.get_value() + adj.get_page_size() >= adj.get_upper() - 1.0 def _on_edge_overshot(self, scrolled_window, pos): if pos == Gtk.PositionType.TOP: self._load_older_messages() def _on_scroll_value_changed(self, adj): if self._restoring_scroll: return if self._context_menu_open: adj.set_value(self._frozen_scroll_value) return at_bottom = self._is_at_bottom() self._is_sticky = at_bottom self._scroll_down_revealer.set_reveal_child(not at_bottom) self._update_unread_btn_visibility() def _on_scroll_upper_changed(self, adj, *args): if self._restoring_scroll: return if self._context_menu_open: adj.set_value(self._frozen_scroll_value) return if self._is_sticky: self._scroll_down() else: self._scroll_down_revealer.set_reveal_child(not self._is_at_bottom()) def _is_item_visible(self, position: int) -> bool: adj = self._scroll.get_vadjustment() top = adj.get_value() bottom = top + adj.get_page_size() n = self._store.get_n_items() if position < 0 or position >= n or n == 0: return False item_y = (position / n) * adj.get_upper() return top <= item_y < bottom def _update_unread_btn_visibility(self): if self._unread_pos < 0: self._unread_btn_revealer.set_reveal_child(False) return visible = self._is_item_visible(self._unread_pos) if visible: self._unread_pos = -1 self._unread_btn_revealer.set_reveal_child(False) else: self._unread_btn_revealer.set_reveal_child(True) # ─── Scroll actions ────────────────────────────────────── def _scroll_down(self): if self._context_menu_open or self._is_at_bottom(): return n = self._store.get_n_items() if n > 0: GLib.idle_add(self._do_scroll_down) def _do_scroll_down(self): if self._context_menu_open: return False n = self._store.get_n_items() if n > 0: self._list_view.scroll_to(n - 1, Gtk.ListScrollFlags.FOCUS, None) return False def _scroll_to_bottom(self): adj = self._scroll.get_vadjustment() adj.set_value(adj.get_upper()) return False def _restore_scroll_position(self, value): adj = self._scroll.get_vadjustment() upper = adj.get_upper() - adj.get_page_size() adj.set_value(min(value, upper)) self._restoring_scroll = False self._is_sticky = self._is_at_bottom() self._scroll_down_revealer.set_reveal_child(not self._is_sticky) return False def _scroll_to_item(self, position): n = self._store.get_n_items() if position < 0 or position >= n: return False self._list_view.scroll_to(position, Gtk.ListScrollFlags.FOCUS, None) return False def _on_scroll_to_bottom_clicked(self, btn): self._is_sticky = True self._scroll_down() def _on_scroll_to_unread_clicked(self, btn): if self._unread_pos >= 0: self._scroll_to_item(self._unread_pos) self._unread_pos = -1 self._unread_btn_revealer.set_reveal_child(False) # ─── Context menu helpers ───────────────────────────────── def _find_outer_at(self, x, y): target = self._list_view.pick(x, y, Gtk.PickFlags.DEFAULT) while target is not None and target is not self._list_view: if hasattr(target, "_chat_item"): return target target = target.get_parent() return None def _on_context_popover_closed(self, popover): self._context_menu_open = False self._is_sticky = self._is_at_bottom() def _on_list_long_press(self, gesture, x, y): self._on_list_right_click(None, 1, x, y) # ─── Emoji ─────────────────────────────────────────────── def _on_emoji_clicked(self, btn): chooser = Gtk.EmojiChooser() chooser.set_parent(btn) chooser.connect( "emoji-picked", lambda c, emoji: self._text_entry.insert_text(emoji, self._text_entry.get_position()), ) chooser.connect("closed", lambda c: c.unparent()) chooser.popup() meshy/src/views/chat_view.py000066400000000000000000000725421523412555500164730ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Chat conversation view - shown in the content panel.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Shumate", "1.0") from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from meshy.models import Contact, ContactType, Message, MessageStatus from meshy.protocol import max_dm_text_bytes from meshy.utils import extract_emoji, linkify, parse_shared_contact, parse_shared_location from meshy.views import _format_date_text from meshy.views.chat_helpers import ( CONTACT_TYPE_ICONS, ensure_loc_map, open_location_external, share_contact_dialog, share_location, share_location_map, show_location_map, ) from meshy.views.chat_mixin import ChatScrollMixin _BATCH_SIZE = 30 class ChatItem(GObject.Object): """Model object for one row in the chat ListView.""" KIND_MESSAGE = 0 KIND_UNREAD_DIVIDER = 1 KIND_DATE_DIVIDER = 2 def __init__(self, kind=0, message=None, date_text=""): super().__init__() self.kind = kind self.message = message self.date_text = date_text self._status_val = int(message.status) if message else int(MessageStatus.PENDING) self.status_detail = "" @GObject.Property(type=int) def status(self): return self._status_val @status.setter def status(self, value): self._status_val = value @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/chat-view.ui") class ChatView(ChatScrollMixin, Gtk.Box): __gtype_name__ = "MeshyChatView" _stack = Gtk.Template.Child("stack") _scroll = Gtk.Template.Child("scroll") _list_view = Gtk.Template.Child("list_view") _scroll_down_revealer = Gtk.Template.Child("scroll_down_revealer") _unread_btn_revealer = Gtk.Template.Child("unread_btn_revealer") _char_label = Gtk.Template.Child("char_label") _emoji_btn = Gtk.Template.Child("emoji_btn") _text_entry = Gtk.Template.Child("text_entry") _send_btn = Gtk.Template.Child("send_btn") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._contact: Contact | None = None self._item_by_msg_id: dict[str, ChatItem] = {} # Pango attributes for char counter attrs = Pango.AttrList() attrs.insert(Pango.attr_scale_new(0.85)) self._char_label.set_attributes(attrs) # ListView model and factory self._store = Gio.ListStore(item_type=ChatItem) selection = Gtk.NoSelection(model=self._store) self._factory = Gtk.SignalListItemFactory() self._factory.connect("setup", self._on_factory_setup) self._factory.connect("bind", self._on_factory_bind) self._factory.connect("unbind", self._on_factory_unbind) self._list_view.set_model(selection) self._list_view.set_factory(self._factory) # Context menu for outgoing messages self._context_popover = Gtk.PopoverMenu() self._context_popover.set_parent(self._list_view) self._context_popover.set_has_arrow(False) self._context_popover.connect("closed", self._on_context_popover_closed) self._context_menu_open = False self._frozen_scroll_value = 0.0 ctx_menu = Gio.Menu() ctx_menu.append(_("Send Again"), "msg.resend") ctx_menu.append(_("Delete"), "msg.delete") self._context_popover.set_menu_model(ctx_menu) self._context_item = None ctx_group = Gio.SimpleActionGroup() resend_a = Gio.SimpleAction.new("resend", None) resend_a.connect("activate", lambda a, p: self._resend_context_item()) ctx_group.add_action(resend_a) delete_a = Gio.SimpleAction.new("delete", None) delete_a.connect("activate", lambda a, p: self._delete_context_item()) ctx_group.add_action(delete_a) self._list_view.insert_action_group("msg", ctx_group) # Gesture controllers right_click = Gtk.GestureClick(button=3) right_click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) right_click.connect("pressed", self._on_list_right_click) self._list_view.add_controller(right_click) long_press = Gtk.GestureLongPress() long_press.set_touch_only(True) long_press.set_exclusive(True) long_press.connect("pressed", self._on_list_long_press) self._list_view.add_controller(long_press) self._is_sticky = True self._scroll_positions: dict[str, float] = {} self._restoring_scroll = False self._unread_pos = -1 self._messages_offset = 0 self._all_loaded = False self._loading_more = False # Signal connections self._scroll_down_revealer.get_child().connect("clicked", self._on_scroll_to_bottom_clicked) self._unread_btn_revealer.get_child().connect("clicked", self._on_scroll_to_unread_clicked) self._emoji_btn.connect("clicked", self._on_emoji_clicked) self._text_entry.connect("activate", self._on_send) self._text_entry.connect("changed", self._on_text_changed) self._send_btn.connect("clicked", self._on_send) # Send route override menu (dropdown on split button) send_menu = Gio.Menu() send_menu.append(_("Send Automatically"), "send.auto") send_menu.append(_("Send Direct"), "send.direct") send_menu.append(_("Send Flood"), "send.flood") self._send_btn.set_menu_model(send_menu) send_group = Gio.SimpleActionGroup() for name, override in [("auto", None), ("direct", "direct"), ("flood", "flood")]: action = Gio.SimpleAction.new(name, None) action.connect("activate", lambda _a, _p, ov=override: self._on_send_with_override(ov)) send_group.add_action(action) self._send_btn.insert_action_group("send", send_group) # Extra context menu on text entry extra_menu = Gio.Menu() extra_menu.append(_("Share Contact"), "entry.share-contact") extra_menu.append(_("Share Location"), "entry.share-location") extra_menu.append(_("Share Location from Map"), "entry.share-location-map") self._text_entry.set_extra_menu(extra_menu) entry_group = Gio.SimpleActionGroup() share_a = Gio.SimpleAction.new("share-contact", None) share_a.connect("activate", self._on_share_contact) entry_group.add_action(share_a) loc_a = Gio.SimpleAction.new("share-location", None) loc_a.connect("activate", self._on_share_location) entry_group.add_action(loc_a) loc_map_a = Gio.SimpleAction.new("share-location-map", None) loc_map_a.connect("activate", self._on_share_location_map) entry_group.add_action(loc_map_a) self._text_entry.insert_action_group("entry", entry_group) # Scroll state tracking adj = self._scroll.get_vadjustment() adj.connect("value-changed", self._on_scroll_value_changed) adj.connect("notify::upper", self._on_scroll_upper_changed) adj.connect("notify::page-size", self._on_scroll_upper_changed) self._scroll.connect("edge-overshot", self._on_edge_overshot) self._stack.set_visible_child_name("placeholder") # ─── Factory callbacks ─────────────────────────────────── def _on_factory_setup(self, factory, list_item): """Create the widget tree for one list item (recycled).""" list_item.set_activatable(False) list_item.set_selectable(False) list_item.set_focusable(False) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/chat-message-item.ui" ) clamp = builder.get_object("clamp") outer = builder.get_object("outer") # Cursor and gesture must be set in Python loc_map_frame = builder.get_object("loc_map_frame") loc_map_frame.set_cursor(Gdk.Cursor.new_from_name("pointer")) loc_map_gesture = Gtk.GestureClick() loc_map_frame.add_controller(loc_map_gesture) # Store widget references outer._content_box = builder.get_object("content_box") outer._avatar = builder.get_object("avatar") outer._emoji_lbl = builder.get_object("emoji_lbl") outer._avatar_stack = builder.get_object("avatar_stack") outer._msg_box = builder.get_object("msg_box") outer._sender_lbl = builder.get_object("sender_lbl") outer._text_lbl = builder.get_object("text_lbl") outer._frame = builder.get_object("frame") outer._time_lbl = builder.get_object("time_lbl") outer._status_lbl = builder.get_object("status_lbl") outer._meta_box = builder.get_object("meta_box") outer._div_box = builder.get_object("div_box") outer._div_lbl = builder.get_object("div_lbl") outer._div_line_left = builder.get_object("line_left") outer._div_line_right = builder.get_object("line_right") outer._content_stack = builder.get_object("content_stack") outer._sc_header_lbl = builder.get_object("sc_header_lbl") outer._sc_icon = builder.get_object("sc_icon") outer._sc_name_lbl = builder.get_object("sc_name_lbl") outer._sc_key_lbl = builder.get_object("sc_key_lbl") outer._sc_add_btn = builder.get_object("sc_add_btn") outer._sc_add_handler_id = None outer._loc_header_lbl = builder.get_object("loc_header_lbl") outer._loc_coords_lbl = builder.get_object("loc_coords_lbl") outer._loc_map_frame = loc_map_frame outer._loc_open_btn = builder.get_object("loc_open_btn") outer._loc_map_gesture = loc_map_gesture outer._loc_map_click_handler = None outer._loc_open_handler = None outer._loc_map_widget = None outer._loc_viewport = None outer._loc_marker = None outer._chat_item = None outer._status_handler_id = None clamp._outer = outer list_item.set_child(clamp) def _on_factory_bind(self, factory, list_item): """Populate widgets from the model item.""" item = list_item.get_item() clamp = list_item.get_child() outer = clamp._outer outer._chat_item = item if item.kind == ChatItem.KIND_MESSAGE: msg = item.message outer._msg_box.set_visible(True) outer._div_box.set_visible(False) # Alignment is_room_incoming = msg.room_sender_name and not msg.is_outgoing if msg.is_outgoing: outer._content_box.set_halign(Gtk.Align.END) outer._meta_box.set_halign(Gtk.Align.END) outer._avatar_stack.set_visible(False) else: outer._content_box.set_halign(Gtk.Align.START) outer._meta_box.set_halign(Gtk.Align.START) # Avatar (room messages only) if is_room_incoming: emoji = extract_emoji(msg.room_sender_name) if emoji: outer._emoji_lbl.set_label(emoji) outer._avatar_stack.set_visible_child_name("emoji") else: outer._avatar.set_text(msg.room_sender_name) outer._avatar_stack.set_visible_child_name("initials") outer._avatar_stack.set_visible(True) else: outer._avatar_stack.set_visible(False) # Sender name (room messages) if is_room_incoming: outer._sender_lbl.set_label(msg.room_sender_name) outer._sender_lbl.set_visible(True) else: outer._sender_lbl.set_visible(False) # Text / shared contact sc = parse_shared_contact(msg.text) if sc: pub_key_hex, contact_type, contact_name = sc outer._content_stack.set_visible_child_name("contact") if msg.is_outgoing: outer._sc_header_lbl.set_label(_("You shared a contact:")) else: sender = self._contact.name if self._contact else "?" outer._sc_header_lbl.set_label( _("{name} shared a contact with you:").format(name=sender) ) outer._sc_icon.set_from_icon_name( CONTACT_TYPE_ICONS.get(contact_type, "avatar-default-symbolic") ) outer._sc_name_lbl.set_label(contact_name) outer._sc_key_lbl.set_label(f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}") if msg.is_outgoing: outer._sc_add_btn.set_visible(False) else: outer._sc_add_btn.set_visible(True) is_known = self._window.is_contact_known(pub_key_hex) outer._sc_add_btn.set_sensitive(not is_known) outer._sc_add_btn.set_label( _("Already added") if is_known else _("Add Contact") ) if outer._sc_add_handler_id is not None: outer._sc_add_btn.disconnect(outer._sc_add_handler_id) outer._sc_add_handler_id = outer._sc_add_btn.connect( "clicked", self._on_shared_contact_add, pub_key_hex, contact_type, contact_name, ) else: loc = parse_shared_location(msg.text) if loc: lat, lon = loc outer._content_stack.set_visible_child_name("location") if msg.is_outgoing: outer._loc_header_lbl.set_label(_("You shared a location:")) else: sender = self._contact.name if self._contact else "?" outer._loc_header_lbl.set_label( _("{name} shared a location:").format(name=sender) ) outer._loc_coords_lbl.set_label(f"{lat:.6f}, {lon:.6f}") ensure_loc_map(outer, lat, lon) outer._loc_map_click_handler = outer._loc_map_gesture.connect( "released", lambda g, n, x, y, la=lat, lo=lon: show_location_map(self._window, la, lo), ) outer._loc_open_handler = outer._loc_open_btn.connect( "clicked", open_location_external, lat, lon ) else: outer._content_stack.set_visible_child_name("text") outer._text_lbl.set_markup(linkify(msg.text)) # Frame style if msg.is_outgoing: outer._frame.add_css_class("outgoing-message") else: outer._frame.remove_css_class("outgoing-message") # Timestamp outer._time_lbl.set_label(msg.timestamp.strftime("%H:%M")) # Status (outgoing only) if msg.is_outgoing: outer._status_lbl.set_visible(True) self._apply_status( outer._status_lbl, MessageStatus(item.status), item.status_detail, ) hid = item.connect( "notify::status", lambda obj, pspec, w=outer: self._apply_status( w._status_lbl, MessageStatus(obj.status), obj.status_detail, ), ) outer._status_handler_id = hid else: outer._status_lbl.set_visible(False) elif item.kind == ChatItem.KIND_DATE_DIVIDER: outer._msg_box.set_visible(False) outer._div_box.set_visible(True) outer._div_lbl.set_label(item.date_text) # Date divider styling outer._div_lbl.remove_css_class("unread-divider") outer._div_lbl.add_css_class("dim-label") outer._div_line_left.remove_css_class("unread-divider-line") outer._div_line_left.add_css_class("date-divider-line") outer._div_line_right.remove_css_class("unread-divider-line") outer._div_line_right.add_css_class("date-divider-line") elif item.kind == ChatItem.KIND_UNREAD_DIVIDER: outer._msg_box.set_visible(False) outer._div_box.set_visible(True) outer._div_lbl.set_label(_("New Messages")) # Unread divider styling outer._div_lbl.remove_css_class("dim-label") outer._div_lbl.add_css_class("unread-divider") outer._div_line_left.remove_css_class("date-divider-line") outer._div_line_left.add_css_class("unread-divider-line") outer._div_line_right.remove_css_class("date-divider-line") outer._div_line_right.add_css_class("unread-divider-line") def _on_factory_unbind(self, factory, list_item): """Clean up signal connections when the widget is recycled.""" clamp = list_item.get_child() outer = clamp._outer if outer._status_handler_id is not None: outer._chat_item.disconnect(outer._status_handler_id) outer._status_handler_id = None if outer._sc_add_handler_id is not None: outer._sc_add_btn.disconnect(outer._sc_add_handler_id) outer._sc_add_handler_id = None if outer._loc_map_click_handler is not None: outer._loc_map_gesture.disconnect(outer._loc_map_click_handler) outer._loc_map_click_handler = None if outer._loc_open_handler is not None: outer._loc_open_btn.disconnect(outer._loc_open_handler) outer._loc_open_handler = None outer._chat_item = None # ─── Shared contact helpers ───────────────────────────── def _on_shared_contact_add(self, btn, pub_key_hex, contact_type, name): self._window.add_contact(bytes.fromhex(pub_key_hex), contact_type, name) btn.set_sensitive(False) btn.set_label(_("Already added")) # ─── Status helpers ────────────────────────────────────── @staticmethod def _apply_status(label, status, detail=""): symbol, css = { MessageStatus.PENDING: (_("Sending"), "dim-label"), MessageStatus.SENT: (_("Sending"), "dim-label"), MessageStatus.DELIVERED: ("\u2713", "success"), MessageStatus.FAILED: ("\u2717", "error"), }.get(status, ("...", "dim-label")) text = f"{symbol} {detail}".strip() if detail else symbol label.set_label(text) for c in ("dim-label", "success", "error"): label.remove_css_class(c) label.add_css_class(css) # ─── Context menu ─────────────────────────────────────── def _on_list_right_click(self, gesture, n_press, x, y): outer = self._find_outer_at(x, y) if not outer: return item = outer._chat_item if ( item and item.kind == ChatItem.KIND_MESSAGE and item.message and item.message.is_outgoing ): gesture.set_state(Gtk.EventSequenceState.CLAIMED) self._context_item = item self._context_menu_open = True self._is_sticky = False self._frozen_scroll_value = self._scroll.get_vadjustment().get_value() rect = Gdk.Rectangle() rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1 self._context_popover.set_pointing_to(rect) self._context_popover.popup() def _resend_context_item(self): if self._context_item and self._context_item.message: self.resend_message(self._context_item.message) def _delete_context_item(self): if self._context_item and self._context_item.message: self.delete_message(self._context_item.message) def _on_share_contact(self, action, param): if not self._contact: return contacts = [ c for c in self._window.contacts if c.public_key_hex != self._contact.public_key_hex ] contact = self._contact def _send(c): text = f"<{c.public_key_hex}:{c.type}:{c.name}>" self._window.send_message(contact, text) share_contact_dialog(self._window, contacts, _send) def _on_share_location(self, action, param): if not self._contact: return contact = self._contact share_location(self._window, lambda text: self._window.send_message(contact, text)) def _on_share_location_map(self, action, param): if not self._contact: return contact = self._contact share_location_map(self._window, lambda text: self._window.send_message(contact, text)) # ─── Contact & messages ────────────────────────────────── def set_contact(self, contact: Contact): if self._contact: adj = self._scroll.get_vadjustment() self._scroll_positions[self._contact.public_key_hex] = adj.get_value() self._contact = contact self._stack.set_visible_child_name("chat") guest = contact.type == ContactType.ROOM and self._window.is_room_guest( contact.public_key_hex ) self._text_entry.set_sensitive(not guest) self._send_btn.set_sensitive(not guest) self._emoji_btn.set_sensitive(not guest) if guest: self._text_entry.set_placeholder_text(_("Read-only room")) else: self._text_entry.set_placeholder_text(_("Type a message...")) self._load_messages() def _load_messages(self): self._item_by_msg_id.clear() self._unread_pos = -1 self._messages_offset = 0 self._all_loaded = False self._loading_more = False if not self._contact: self._store.remove_all() return messages = self._window.get_messages_for_contact(self._contact, limit=_BATCH_SIZE) self._messages_offset = len(messages) self._all_loaded = len(messages) < _BATCH_SIZE last_read_at = self._window.get_contact_last_read_at(self._contact.public_key_hex) new_items = [] divider_inserted = False last_date = None for msg in messages: msg_date = msg.timestamp.date() if last_date is not None and msg_date != last_date: new_items.append( ChatItem( kind=ChatItem.KIND_DATE_DIVIDER, date_text=_format_date_text(msg.timestamp), ) ) last_date = msg_date # Insert unread divider before first unread incoming message if ( not divider_inserted and last_read_at is not None and not msg.is_outgoing and msg.timestamp.timestamp() > last_read_at ): new_items.append(ChatItem(kind=ChatItem.KIND_UNREAD_DIVIDER)) self._unread_pos = len(new_items) - 1 divider_inserted = True item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=msg) new_items.append(item) if msg.message_id: self._item_by_msg_id[msg.message_id] = item saved_pos = self._scroll_positions.get(self._contact.public_key_hex) if saved_pos is not None: self._is_sticky = False self._restoring_scroll = True # Replace all items in a single atomic operation self._store.splice(0, self._store.get_n_items(), new_items) if saved_pos is not None: GLib.idle_add(lambda pos=saved_pos: self._restore_scroll_position(pos)) else: GLib.idle_add(self._scroll_to_bottom) if self._unread_pos >= 0: GLib.idle_add(lambda: self._update_unread_btn_visibility() or False) # Mark as read now self._window.mark_contact_read(self._contact.public_key_hex) def _load_older_messages(self): if self._all_loaded or self._loading_more or not self._contact: return self._loading_more = True messages = self._window.get_messages_for_contact( self._contact, limit=_BATCH_SIZE, offset=self._messages_offset ) if not messages: self._all_loaded = True self._loading_more = False return older_items = [] last_date = None for msg in messages: msg_date = msg.timestamp.date() if last_date is not None and msg_date != last_date: older_items.append( ChatItem( kind=ChatItem.KIND_DATE_DIVIDER, date_text=_format_date_text(msg.timestamp), ) ) last_date = msg_date item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=msg) older_items.append(item) if msg.message_id: self._item_by_msg_id[msg.message_id] = item # Add boundary date divider if the dates differ if last_date is not None: for i in range(self._store.get_n_items()): first = self._store.get_item(i) if first.kind == ChatItem.KIND_MESSAGE: if first.message.timestamp.date() != last_date: older_items.append( ChatItem( kind=ChatItem.KIND_DATE_DIVIDER, date_text=_format_date_text(first.message.timestamp), ) ) break self._store.splice(0, 0, older_items) if self._unread_pos >= 0: self._unread_pos += len(older_items) self._messages_offset += len(messages) self._all_loaded = len(messages) < _BATCH_SIZE self._loading_more = False def _on_text_changed(self, entry): text = entry.get_text() used = len(text.encode("utf-8")) limit = max_dm_text_bytes() if used == 0: self._char_label.set_label("") self._send_btn.set_sensitive(True) self._char_label.remove_css_class("error") else: self._char_label.set_label(f"{used}/{limit}") over = used > limit self._send_btn.set_sensitive(not over) if over: self._char_label.add_css_class("error") else: self._char_label.remove_css_class("error") def _on_send(self, *args): if not self._contact: return text = self._text_entry.get_text().strip() if not text: return if len(text.encode("utf-8")) > max_dm_text_bytes(): return self._text_entry.set_text("") self._window.send_message(self._contact, text) def _on_send_with_override(self, route_override: str | None): if not self._contact: return text = self._text_entry.get_text().strip() if not text: return if len(text.encode("utf-8")) > max_dm_text_bytes(): return self._text_entry.set_text("") self._window.send_message(self._contact, text, route_override=route_override) def on_message_received(self, contact: Contact, message: Message): """Called when a new message is received.""" if self._contact and contact.public_key_hex == self._contact.public_key_hex: item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=message) # If sticky, the notify::upper handler will auto-scroll self._store.append(item) def on_message_sent(self, message: Message): """Called when a message is sent.""" # Force sticky so notify::upper handler scrolls to the new message self._is_sticky = True item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=message) self._store.append(item) if message.message_id: self._item_by_msg_id[message.message_id] = item def resend_message(self, message: Message): """Re-send a message to the current contact.""" if self._contact and message.message_id: self._window.resend_message(self._contact, message) def delete_message(self, message: Message): """Delete a message from the view and storage.""" if not message.message_id: return item = self._item_by_msg_id.pop(message.message_id, None) if item: found, idx = self._store.find(item) if found: self._store.remove(idx) self._window.delete_message(message.message_id) def update_message_status(self, message_id: str, status: MessageStatus, detail: str = ""): """Update the status of a sent message.""" if message_id in self._item_by_msg_id: item = self._item_by_msg_id[message_id] item.status_detail = detail # Setting status triggers notify::status -> bound widget updates item.props.status = int(status) meshy/src/views/connection_view.py000066400000000000000000000642051523412555500177100ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Connection screen - shown when no device is connected.""" import os import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Gdk", "4.0") from gi.repository import Adw, Gdk, Gio, GLib, Gtk from meshy.ble import BleDevice from meshy.host_integration import is_flatpak, run_host_command from meshy.platforms import is_linux from meshy.storage import Storage from meshy.utils import clear_children @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/connection-view.ui") class ConnectionView(Gtk.Box): """Connection screen with Bluetooth and USB connection options.""" __gtype_name__ = "MeshyConnectionView" _paired_list = Gtk.Template.Child("paired_list") _pair_btn = Gtk.Template.Child("pair_btn") _usb_list = Gtk.Template.Child("usb_list") _tcp_list = Gtk.Template.Child("tcp_list") _tcp_add_btn = Gtk.Template.Child("tcp_add_btn") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._bt_poll_id = None self._pair_btn.connect("clicked", self._on_pair_new) self._tcp_add_btn.connect("clicked", self._on_tcp_add_new) def _add_remove_context_menu(self, row, on_remove): """Add a right-click / long-press context menu with Remove option.""" menu_model = Gio.Menu() menu_model.append(_("Remove"), "row.remove") popover = Gtk.PopoverMenu(menu_model=menu_model) popover.set_parent(row) popover.set_has_arrow(False) row._context_popover = popover remove_action = Gio.SimpleAction.new("remove", None) remove_action.connect("activate", lambda *_: on_remove()) action_group = Gio.SimpleActionGroup() action_group.add_action(remove_action) row.insert_action_group("row", action_group) def _show_menu(x, y): rect = Gdk.Rectangle() rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1 popover.set_pointing_to(rect) popover.popup() right_click = Gtk.GestureClick(button=3) right_click.connect("pressed", lambda g, n, x, y: _show_menu(x, y)) row.add_controller(right_click) long_press = Gtk.GestureLongPress() long_press.connect("pressed", lambda g, x, y: _show_menu(x, y)) row.add_controller(long_press) def refresh_paired_devices(self): """Refresh the list of paired BLE devices, USB serial devices, and TCP companions.""" self._refresh_tcp_companions() # Refresh BLE paired devices self._clear_list(self._paired_list) # Check if BLE transport is available if not self._window.ble_transport: placeholder = Adw.ActionRow( title=_("Bluetooth not available"), subtitle=_("BLE requires running from app bundle on macOS"), sensitive=False, ) placeholder.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-disabled-symbolic")) self._paired_list.append(placeholder) self._pair_btn.set_sensitive(False) return bt_enabled = self._window.ble_transport.is_bluetooth_enabled() self._pair_btn.set_sensitive(bt_enabled) if not bt_enabled: placeholder = Adw.ActionRow( title=_("Bluetooth is disabled"), subtitle=_("Enable Bluetooth in system settings to connect"), sensitive=False, ) placeholder.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-disabled-symbolic")) self._paired_list.append(placeholder) else: devices = self._window.ble_transport.get_paired_devices() if not devices: placeholder = Adw.ActionRow( title=_("No paired devices"), subtitle=_("Use the button below to pair a new companion"), sensitive=False, ) placeholder.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-disabled-symbolic")) self._paired_list.append(placeholder) else: for device in devices: row = Adw.ActionRow( title=device.name, subtitle=device.address, ) row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-active-symbolic")) connect_btn = Gtk.Button( label=_("Connect"), valign=Gtk.Align.CENTER, ) connect_btn.add_css_class("suggested-action") connect_btn.connect( "clicked", lambda b, d=device: self._window.connect_to_device(d), ) row.add_suffix(connect_btn) self._add_remove_context_menu( row, lambda d=device: self._remove_ble_device(d), ) self._paired_list.append(row) self._bt_was_enabled = bt_enabled self._start_bt_polling() # Refresh USB serial devices clear_children(self._usb_list) from meshy.usb_serial import UsbSerialTransport usb_transport = UsbSerialTransport() usb_devices = usb_transport.get_serial_devices() if not usb_devices: placeholder = Adw.ActionRow( title=_("No USB devices detected"), subtitle=_("Connect a MeshCore companion via USB"), sensitive=False, ) placeholder.add_prefix(Gtk.Image.new_from_icon_name("drive-removable-media-symbolic")) self._usb_list.append(placeholder) else: for device in usb_devices: row = Adw.ActionRow( title=device.name, subtitle=device.path, ) row.add_prefix(Gtk.Image.new_from_icon_name("drive-removable-media-symbolic")) connect_btn = Gtk.Button( label=_("Connect"), valign=Gtk.Align.CENTER, ) connect_btn.add_css_class("suggested-action") connect_btn.connect( "clicked", lambda b, d=device, t=usb_transport: self._connect_usb(d, t), ) row.add_suffix(connect_btn) self._usb_list.append(row) def _remove_ble_device(self, device): """Remove (unpair) a Bluetooth device with confirmation.""" dialog = Adw.AlertDialog( heading=_("Remove Companion"), body=_("Remove {} ({})? You will need to pair again to reconnect.").format( device.name, device.address ), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("remove", _("Remove")) dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) delete_check = Gtk.CheckButton(label=_("Delete stored data")) dialog.set_extra_child(delete_check) def _on_response(d, response): if response == "remove": if delete_check.get_active(): Storage.delete_device_db(device.address) self._window.ble_transport.remove_device(device.path) self.refresh_paired_devices() dialog.connect("response", _on_response) dialog.present(self._window) def _connect_usb(self, device, transport): """Connect to a USB device with error handling.""" self._window.connect_to_device(device, transport=transport) # Check if connection failed with an error if hasattr(transport, "_connection_error") and transport._connection_error: error_msg = transport._connection_error is_perm = getattr(transport, "_is_permission_error", False) transport._connection_error = None transport._is_permission_error = False if is_perm: self._show_permission_dialog(device, transport) else: dialog = Adw.AlertDialog( heading=_("USB Connection Failed"), body=error_msg, ) dialog.add_response("ok", _("OK")) dialog.present(self._window) def _can_spawn_host(self): """Check if we can use flatpak-spawn --host (not in sandbox, or have permission).""" try: kf = GLib.KeyFile() kf.load_from_file("/.flatpak-info", GLib.KeyFileFlags.NONE) try: talk_names = kf.get_string_list("Session Bus Policy", "org.freedesktop.Flatpak") return "talk" in talk_names or "own" in talk_names except GLib.Error: return False except GLib.Error: return True def _show_permission_dialog(self, device, transport): """Show permission error with option to install udev rule (Linux only).""" if not is_linux(): # macOS doesn't need udev rules - /dev/cu.* devices are accessible by default return if self._can_spawn_host(): self._show_install_rule_dialog(device, transport) else: self._show_manual_rule_dialog(device) def _show_install_rule_dialog(self, device, transport): """Show dialog with automatic udev rule installation.""" dialog = Adw.AlertDialog( heading=_("USB Permission Denied"), body=_( "Cannot access {}.\n\n" "A udev rule is needed to grant access to USB serial " 'devices. Click "Install Rule" to install it ' "(requires administrator password)." ).format(device.path), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("install", _("Install Rule")) dialog.set_response_appearance("install", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "install": self._install_udev_rule(device, transport) dialog.connect("response", _on_response) dialog.present(self._window) def _show_manual_rule_dialog(self, device): """Show dialog with manual udev rule installation instructions.""" command = ( 'echo \'SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", MODE="0666"\n' 'SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", MODE="0666"\'' " | sudo tee /etc/udev/rules.d/60-meshy-serial.rules" " && sudo udevadm control --reload-rules" " && sudo udevadm trigger" ) dialog = Adw.AlertDialog( heading=_("USB Permission Denied"), body=_( "Cannot access {}.\n\n" "A udev rule is needed to grant access to USB serial " "devices. Run the following command in a terminal:\n\n" "{}" ).format(device.path, command), ) dialog.add_response("close", _("Close")) dialog.add_response("copy", _("Copy Command")) dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "copy": clipboard = self._window.get_clipboard() clipboard.set(command) clipboard.store_async(None, None) self._window.show_toast(_("Command copied to clipboard")) dialog.connect("response", _on_response) dialog.present(self._window) def _install_udev_rule(self, device, transport): """Install udev rule via pkexec and retry connection.""" import tempfile rule_content = ( "# Meshy - MeshCore companion USB serial access\n" 'SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", MODE="0666"\n' 'SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", MODE="0666"\n' ) tmp_path = None try: # In Flatpak, /tmp is sandbox-private and invisible to host # commands run via flatpak-spawn. Use the cache dir which is # shared at the same absolute path. tmp_dir = GLib.get_user_cache_dir() if is_flatpak() else None with tempfile.NamedTemporaryFile( mode="w", suffix=".rules", delete=False, dir=tmp_dir, ) as tmp: tmp.write(rule_content) tmp_path = tmp.name dest = "/etc/udev/rules.d/60-meshy-serial.rules" result = run_host_command( [ "pkexec", "bash", "-c", f'cp "$1" {dest}' " && udevadm control --reload-rules" " && udevadm trigger", "--", tmp_path, ], capture_output=True, text=True, timeout=60, ) if result.returncode == 0: # Wait a moment then retry connection def _retry(): self._window.connect_to_device(device, transport=transport) return False GLib.timeout_add(2000, _retry) else: dialog = Adw.AlertDialog( heading=_("Installation Failed"), body=_("Could not install udev rule:\n{}").format(result.stderr.strip()), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) except Exception as e: dialog = Adw.AlertDialog( heading=_("Installation Failed"), body=str(e), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) finally: if tmp_path and os.path.exists(tmp_path): os.unlink(tmp_path) def _clear_list(self, list_widget): child = list_widget.get_first_child() while child: next_child = child.get_next_sibling() popover = getattr(child, "_context_popover", None) if popover: popover.unparent() list_widget.remove(child) child = next_child def _refresh_tcp_companions(self): """Refresh the list of saved TCP companions.""" self._clear_list(self._tcp_list) from gi.repository import Gio settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") companions = settings.get_strv("tcp-companions") if not companions: placeholder = Adw.ActionRow( title=_("No saved companions"), subtitle=_("Use the button below to add one"), sensitive=False, ) placeholder.add_prefix(Gtk.Image.new_from_icon_name("network-wireless-symbolic")) self._tcp_list.append(placeholder) else: for entry in companions: if "|" not in entry: continue name, address = entry.split("|", 1) row = Adw.ActionRow(title=name, subtitle=address) row.add_prefix(Gtk.Image.new_from_icon_name("network-wireless-symbolic")) connect_btn = Gtk.Button( label=_("Connect"), valign=Gtk.Align.CENTER, ) connect_btn.add_css_class("suggested-action") connect_btn.connect( "clicked", lambda b, a=address: self._connect_tcp_address(a), ) row.add_suffix(connect_btn) menu_model = Gio.Menu() menu_model.append(_("Change Address"), "row.change_address") menu_model.append(_("Remove"), "row.remove") popover = Gtk.PopoverMenu(menu_model=menu_model) popover.set_parent(row) popover.set_has_arrow(False) row._context_popover = popover action_group = Gio.SimpleActionGroup() change_action = Gio.SimpleAction.new("change_address", None) change_action.connect( "activate", lambda *_, a=address: self._change_tcp_companion_address(a) ) action_group.add_action(change_action) remove_action = Gio.SimpleAction.new("remove", None) remove_action.connect( "activate", lambda *_, a=address: self._remove_tcp_companion(a) ) action_group.add_action(remove_action) row.insert_action_group("row", action_group) def _show_menu(x, y, p=popover): rect = Gdk.Rectangle() rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1 p.set_pointing_to(rect) p.popup() right_click = Gtk.GestureClick(button=3) right_click.connect("pressed", lambda g, n, x, y: _show_menu(x, y)) row.add_controller(right_click) long_press = Gtk.GestureLongPress() long_press.connect("pressed", lambda g, x, y: _show_menu(x, y)) row.add_controller(long_press) self._tcp_list.append(row) def _connect_tcp_address(self, address: str): """Connect to a saved TCP companion by address (host:port).""" from meshy.tcp import TcpDevice, TcpTransport host, port_str = address.rsplit(":", 1) transport = TcpTransport() device = TcpDevice(host, int(port_str)) self._window.connect_to_device(device, transport=transport) def _remove_tcp_companion(self, address: str): """Remove a saved TCP companion with confirmation.""" dialog = Adw.AlertDialog( heading=_("Remove Companion"), body=_("Remove TCP companion {}?").format(address), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("remove", _("Remove")) dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) delete_check = Gtk.CheckButton(label=_("Delete stored data")) dialog.set_extra_child(delete_check) def _on_response(d, response): if response == "remove": if delete_check.get_active(): Storage.delete_device_db(address) self._window.remove_tcp_companion(address) self._refresh_tcp_companions() dialog.connect("response", _on_response) dialog.present(self._window) def _change_tcp_companion_address(self, address: str): """Show dialog to change the address of a saved TCP companion.""" host, port_str = address.rsplit(":", 1) dialog = Adw.AlertDialog( heading=_("Change Address"), body=_("Enter the new address for this companion."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/tcp-change-address-content.ui" ) host_entry = builder.get_object("host_entry") host_entry.set_text(host) port_entry = builder.get_object("port_entry") port_entry.set_text(port_str) dialog.set_extra_child(builder.get_object("content")) dialog.add_response("cancel", _("Cancel")) dialog.add_response("save", _("Save")) dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response != "save": return new_host = host_entry.get_text().strip() new_port_str = port_entry.get_text().strip() or port_str if not new_host: return try: port = int(new_port_str) if not 1 <= port <= 65535: raise ValueError except ValueError: return new_address = f"{new_host}:{port}" self._window.update_tcp_companion_address(address, new_address) self._refresh_tcp_companions() dialog.connect("response", _on_response) dialog.present(self._window) def _on_tcp_add_new(self, *args): """Show dialog to add a new TCP companion.""" dialog = Adw.AlertDialog( heading=_("Add TCP Companion"), body=_("Enter the IP address and port of your MeshCore device."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/tcp-add-content.ui" ) host_entry = builder.get_object("host_entry") port_entry = builder.get_object("port_entry") dialog.set_extra_child(builder.get_object("content")) dialog.add_response("cancel", _("Cancel")) dialog.add_response("connect", _("Connect")) dialog.set_response_appearance("connect", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response != "connect": return host = host_entry.get_text().strip() port_str = port_entry.get_text().strip() or "5000" if not host: return try: port = int(port_str) if not 1 <= port <= 65535: raise ValueError except ValueError: return from meshy.tcp import TcpDevice, TcpTransport transport = TcpTransport() device = TcpDevice(host, port) self._window.connect_to_device(device, transport=transport) dialog.connect("response", _on_response) dialog.present(self._window) def _start_bt_polling(self): """Poll for Bluetooth state changes every 2 seconds.""" if self._bt_poll_id is not None: return def _check_bt(): bt_enabled = self._window.ble_transport.is_bluetooth_enabled() if bt_enabled != self._bt_was_enabled: self.refresh_paired_devices() return True self._bt_poll_id = GLib.timeout_add(2000, _check_bt) def stop_polling(self): """Stop polling for Bluetooth state.""" if self._bt_poll_id is not None: GLib.source_remove(self._bt_poll_id) self._bt_poll_id = None def _on_pair_new(self, *args): """Scan for new MeshCore devices and pair.""" # Check if BLE transport is available if not self._window.ble_transport: return builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/pair-dialog.ui") dialog = builder.get_object("dialog") list_box = builder.get_object("list_box") placeholder = builder.get_object("placeholder") list_box.set_placeholder(placeholder) seen_addresses = set() paired_addresses = { d.address.upper() for d in self._window.ble_transport.get_paired_devices() } def on_device_found(device: BleDevice): if device.address in seen_addresses: return if device.address.upper() in paired_addresses: return seen_addresses.add(device.address) row = Adw.ActionRow( title=device.name, subtitle=device.address, ) row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-active-symbolic")) pair_btn = Gtk.Button(label=_("Pair"), valign=Gtk.Align.CENTER) pair_btn.add_css_class("suggested-action") pair_btn.connect("clicked", lambda b, d=device: self._initiate_pairing(d, dialog)) row.add_suffix(pair_btn) list_box.append(row) # Save original callback and set ours orig_callback = self._window.ble_transport.on_device_discovered self._window.ble_transport.on_device_discovered = on_device_found def on_dialog_closed(d): self._window.ble_transport.on_device_discovered = orig_callback if self._window.ble_transport.state.name == "SCANNING": self._window.ble_transport.stop_scan() # Refresh paired list in case a new device was just paired self.refresh_paired_devices() dialog.connect("closed", on_dialog_closed) # Start scanning self._window.ble_transport.start_scan() dialog.present(self._window) def _initiate_pairing(self, device: BleDevice, scan_dialog: Adw.Dialog): """Start pairing with a device, showing passkey dialog if needed.""" # Stop scanning and close scan dialog if self._window.ble_transport.state.name == "SCANNING": self._window.ble_transport.stop_scan() scan_dialog.close() # Set up passkey handler def on_passkey_requested(device_path, reply_callback): GLib.idle_add(lambda: self._show_passkey_dialog(device_path, reply_callback) or False) self._window.ble_transport.on_passkey_requested = on_passkey_requested self._window.ble_transport.pair_device(device.path) def _show_passkey_dialog(self, device_path, reply_callback): """Show dialog asking user to enter the passkey displayed on the device.""" dialog = Adw.AlertDialog( heading=_("Enter Pairing PIN"), body=_("Enter the PIN shown on your MeshCore device."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/passkey-dialog-content.ui" ) dialog.set_extra_child(builder.get_object("content")) pin_entry = builder.get_object("pin_entry") dialog.add_response("cancel", _("Cancel")) dialog.add_response("pair", _("Pair")) dialog.set_response_appearance("pair", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "pair": try: passkey = int(pin_entry.get_text().strip()) reply_callback(passkey) except ValueError: reply_callback(0) else: reply_callback(0) dialog.connect("response", _on_response) dialog.present(self._window) meshy/src/views/contact_detail_dialog.py000066400000000000000000000670321523412555500210140ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Contact detail dialog — shows info, routing, telemetry, and actions for a contact.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, GLib, Gtk from meshy.models import Contact, ContactType from meshy.utils import format_trip_time @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/contact-detail-dialog.ui") class ContactDetailDialog(Adw.Dialog): __gtype_name__ = "MeshyContactDetailDialog" content_box = Gtk.Template.Child() info_group = Gtk.Template.Child() name_entry = Gtk.Template.Child() favorite_switch = Gtk.Template.Child() type_row = Gtk.Template.Child() key_row = Gtk.Template.Child() copy_btn = Gtk.Template.Child() last_seen_row = Gtk.Template.Child() routing_group = Gtk.Template.Child() routing_toggle = Gtk.Template.Child() out_path_entry = Gtk.Template.Child() reset_path_row = Gtk.Template.Child() reset_path_btn = Gtk.Template.Child() smart_paths_row = Gtk.Template.Child() auto_reset_switch = Gtk.Template.Child() flood_switch = Gtk.Template.Child() tele_group = Gtk.Template.Child() req_tele_row = Gtk.Template.Child() req_tele_btn = Gtk.Template.Child() danger_group = Gtk.Template.Child() remove_btn = Gtk.Template.Child() def __init__(self, contacts_view, contact: Contact, **kwargs): super().__init__(**kwargs) self._contacts_view = contacts_view self._window = contacts_view._window self._contact = contact self._path_map_row = None self._updating = False self.set_title(contact.name) self._populate(contact) self._add_conditional_widgets(contact) self._connect_signals(contact) def _populate(self, contact: Contact): self.name_entry.set_text(contact.name) self.favorite_switch.set_active(contact.is_favorite) self.type_row.set_subtitle(contact.type_label) self.key_row.set_subtitle(contact.public_key_hex) last_seen_str = ( contact.last_seen.strftime("%Y-%m-%d %H:%M") if contact.last_seen else _("Never") ) self.last_seen_row.set_subtitle(last_seen_str) # Routing state is_smart = contact.is_smart_routing self.routing_toggle.set_active_name("smart" if is_smart else "classic") if contact.path_hops: self.out_path_entry.set_text(",".join(h.lower() for h in contact.path_hops)) self.out_path_entry.set_visible(not is_smart) self.reset_path_row.set_visible(not is_smart) self.reset_path_row.set_activatable_widget(self.reset_path_btn) self.smart_paths_row.set_visible(is_smart) self.auto_reset_switch.set_active(contact.auto_reset_path) self.flood_switch.set_active(contact.force_flood) self.flood_switch.set_visible(not is_smart) self._update_smart_subtitle(contact) self.req_tele_row.set_activatable_widget(self.req_tele_btn) def _add_conditional_widgets(self, contact: Contact): # Location row in info group if contact.has_location: loc_row = Adw.ActionRow( title=_("Location"), subtitle=f"{contact.latitude:.6f}, {contact.longitude:.6f}", ) map_btn = Gtk.Button(icon_name="map-symbolic", valign=Gtk.Align.CENTER) map_btn.add_css_class("flat") map_btn.connect("clicked", lambda *_, c=contact: self._contacts_view._show_on_map(c)) loc_row.add_suffix(map_btn) loc_row.set_activatable_widget(map_btn) self.info_group.add(loc_row) # Show Path on Map in routing group if contact.path_hops: hop_contacts = self._window.resolve_hop_prefixes(contact.path_hops) has_located = any(c and c.has_location for c in hop_contacts) if has_located or contact.has_location: self._path_map_row = Adw.ActionRow( title=_("Show Path on Map"), subtitle=_("Visualize the message route on the map"), ) path_map_btn = Gtk.Button(icon_name="map-symbolic", valign=Gtk.Align.CENTER) path_map_btn.add_css_class("flat") path_map_btn.connect( "clicked", lambda *_, c=contact, hc=hop_contacts, hp=contact.path_hops: ( self._contacts_view._show_path_on_map(c, hc, hp) ), ) self._path_map_row.add_suffix(path_map_btn) self._path_map_row.set_activatable_widget(path_map_btn) self._path_map_row.set_visible(not contact.is_smart_routing) self.routing_group.add(self._path_map_row) # Telemetry switches (only for CHAT contacts) if contact.type == ContactType.CHAT: from meshy.models import ( CONTACT_FLAG_TELE_BASE, CONTACT_FLAG_TELE_ENV, CONTACT_FLAG_TELE_LOC, ) for title, subtitle, flag in ( ( _("Allow Telemetry Requests"), _("Allow this contact to query battery, voltage, temperature"), CONTACT_FLAG_TELE_BASE, ), ( _("Include Location"), _("Include GPS coordinates in telemetry responses"), CONTACT_FLAG_TELE_LOC, ), ( _("Include Environment Sensors"), _("Include humidity, pressure, air quality data"), CONTACT_FLAG_TELE_ENV, ), ): sw = Adw.SwitchRow(title=title, subtitle=subtitle) sw.set_active(bool(contact.flags & flag)) def _on_toggle(*args, _sw=sw, _flag=flag): if _sw.get_active(): contact.flags |= _flag else: contact.flags &= ~_flag self._window.storage.save_contact(contact) self._window.update_contact_flags(contact) sw.connect("notify::active", _on_toggle) self.tele_group.add(sw) # Actions group (only for repeaters/rooms) actions_count = 0 actions_group = Adw.PreferencesGroup(title=_("Actions")) if contact.type in (ContactType.ROOM, ContactType.REPEATER): mgmt_title = ( _("Room Management") if contact.type == ContactType.ROOM else _("Repeater Management") ) mgmt_row = Adw.ActionRow( title=mgmt_title, subtitle=_("Login and access status, CLI, neighbors, and settings"), ) mgmt_btn = Gtk.Button(icon_name="utilities-terminal-symbolic", valign=Gtk.Align.CENTER) mgmt_btn.connect( "clicked", lambda *_: ( self.close(), self._contacts_view._show_room_login(contact, repeater=True), ), ) mgmt_row.add_suffix(mgmt_btn) mgmt_row.set_activatable_widget(mgmt_btn) actions_group.add(mgmt_row) actions_count += 1 if contact.type in (ContactType.REPEATER, ContactType.ROOM): # Ping ping_row = Adw.ActionRow( title=_("Ping"), subtitle=_("Measure round-trip time to this node"), ) ping_btn = Gtk.Button( icon_name="network-transmit-receive-symbolic", valign=Gtk.Align.CENTER ) ping_btn.connect( "clicked", lambda *_: self._contacts_view._ping_contact(contact, ping_btn) ) ping_row.add_suffix(ping_btn) ping_row.set_activatable_widget(ping_btn) actions_group.add(ping_row) actions_count += 1 # Discover Paths discover_row = Adw.ActionRow( title=_("Discover Paths"), subtitle=_("Find routes to this node via flood"), ) self._contacts_view._discover_path_label = Gtk.Label(label="") self._contacts_view._discover_path_label.add_css_class("dim-label") self._contacts_view._discover_path_label.set_valign(Gtk.Align.CENTER) discover_row.add_suffix(self._contacts_view._discover_path_label) discover_btn = Gtk.Button(icon_name="system-search-symbolic", valign=Gtk.Align.CENTER) discover_btn.connect( "clicked", lambda *_: self._contacts_view._on_path_discovery(contact, discover_btn) ) discover_row.add_suffix(discover_btn) discover_row.set_activatable_widget(discover_btn) actions_group.add(discover_row) actions_count += 1 # Regions regions_row = Adw.ActionRow( title=_("Regions"), subtitle=_("Query regions from this repeater"), ) regions_label = Gtk.Label(label="") regions_label.add_css_class("dim-label") regions_label.set_valign(Gtk.Align.CENTER) regions_row.add_suffix(regions_label) regions_btn = Gtk.Button(icon_name="region-symbolic", valign=Gtk.Align.CENTER) def _on_regions_click(*_args, _contact=contact, _btn=regions_btn, _label=regions_label): _btn.set_sensitive(False) _label.set_label(_("Querying...")) def _on_response(text): def _update(): _btn.set_sensitive(True) if text: names = [] for line in text.replace(",", "\n").split("\n"): name = line.strip().strip("#") if name and name != "*": names.append(name) _label.set_label(", ".join(names) if names else _("None")) else: _label.set_label(_("No response")) GLib.idle_add(_update) responded = [False] def _handle(text): if responded[0]: return responded[0] = True _on_response(text) self._window.request_regions(_contact, lambda text: _handle(text), use_path=True) def _retry(): if responded[0]: return False self._window.request_regions( _contact, lambda text: _handle(text), use_path=True ) return False def _give_up(): _handle(None) return False GLib.timeout_add(5000, _retry) GLib.timeout_add(10000, _give_up) regions_btn.connect("clicked", _on_regions_click) regions_row.add_suffix(regions_btn) regions_row.set_activatable_widget(regions_btn) actions_group.add(regions_row) actions_count += 1 if actions_count > 0: self.content_box.insert_child_after(actions_group, self.tele_group) def _connect_signals(self, contact: Contact): # Info signals self.favorite_switch.connect("notify::active", self._on_favorite_toggled, contact) self.copy_btn.connect("clicked", self._on_copy_key, contact) self.name_entry.connect("apply", self._on_apply, contact) self.out_path_entry.connect("apply", self._on_apply, contact) # Routing signals self.routing_toggle.connect("notify::active-name", self._on_toggle_changed, contact) self.out_path_entry.connect("changed", self._on_path_changed) self.reset_path_btn.connect("clicked", self._on_reset_path, contact) self.smart_paths_row.connect("activated", self._on_smart_paths_activated, contact) self.flood_switch.connect("notify::active", self._on_flood_toggled, contact) self.flood_switch.connect( "notify::active", lambda *a: self._on_apply(None, contact) if not self._updating else None, ) self.auto_reset_switch.connect("notify::active", self._on_auto_reset_toggled, contact) # Telemetry self.req_tele_btn.connect( "clicked", lambda *_: self._contacts_view._request_telemetry(contact, self.req_tele_row), ) # Danger zone self.remove_btn.connect("clicked", self._on_remove, contact) # Initial state self._update_flood_dependents() # --- Info callbacks --- def _on_favorite_toggled(self, switch, _pspec, contact): from meshy.models import CONTACT_FLAG_FAVORITE if switch.get_active(): contact.flags |= CONTACT_FLAG_FAVORITE else: contact.flags &= ~CONTACT_FLAG_FAVORITE self._window.storage.save_contact(contact) self._window.update_contact_flags(contact) self._window.update_contacts_from_storage() def _on_copy_key(self, _btn, contact): clipboard = self._window.get_clipboard() clipboard.set(contact.public_key_hex) clipboard.store_async(None, None) self._window.show_toast(_("Public key copied")) # --- Routing callbacks --- def _on_toggle_changed(self, toggle_group, _pspec, contact): if self._updating: return is_smart = toggle_group.get_active_name() == "smart" contact.smart_route_selection = is_smart self._window.storage.save_contact(contact) is_smart = contact.is_smart_routing self.out_path_entry.set_visible(not is_smart) self.reset_path_row.set_visible(not is_smart) self.flood_switch.set_visible(not is_smart) if self._path_map_row is not None: self._path_map_row.set_visible(not is_smart) self.smart_paths_row.set_visible(is_smart) if is_smart: self._update_smart_subtitle(contact) self._update_flood_dependents() def _on_path_changed(self, *args): if self._updating: return if self.out_path_entry.get_text().strip(): self._updating = True self.flood_switch.set_active(False) self._updating = False def _on_reset_path(self, _btn, contact): self._window.reset_contact_path(contact) self.out_path_entry.set_text("") def _on_smart_paths_activated(self, _row, contact): self._show_smart_paths_dialog(contact) def _on_flood_toggled(self, switch, _pspec, contact): if self._updating: return self._update_flood_dependents() if switch.get_active(): self._updating = True self.out_path_entry.set_text("") self._updating = False contact.force_flood = switch.get_active() self._window.storage.save_contact(contact) def _on_auto_reset_toggled(self, switch, _pspec, contact): if self._updating: return contact.auto_reset_path = switch.get_active() self._window.storage.save_contact(contact) def _update_flood_dependents(self): is_flood = self.flood_switch.get_active() self.auto_reset_switch.set_sensitive(not is_flood) def _update_smart_subtitle(self, contact): count = len(self._window.storage.get_path_history(contact.public_key_hex)) self.smart_paths_row.set_subtitle( _("%d paths available") % count if count else _("No paths yet") ) # --- Apply --- def _on_apply(self, _widget, contact): new_name = self.name_entry.get_text().strip() or contact.name path_text = self.out_path_entry.get_text().strip() force_flood = self.flood_switch.get_active() if force_flood: self._window.set_contact_path(contact, 0xFF, b"", name=new_name) elif path_text: try: hops = [h.strip() for h in path_text.split(",") if h.strip()] hash_size = len(bytes.fromhex(hops[0])) path_bytes = b"".join(bytes.fromhex(h) for h in hops) hash_mode = hash_size - 1 hop_count = len(hops) path_len_byte = (hash_mode << 6) | (hop_count & 0x3F) self._window.set_contact_path(contact, path_len_byte, path_bytes, name=new_name) except ValueError: pass else: self._window.reset_contact_path(contact) if new_name != contact.name: GLib.timeout_add( 500, lambda: ( self._window.set_contact_path(contact, 0xFF, b"", name=new_name) or False ), ) if new_name != contact.name: contact.name = new_name self._window.storage.save_contact(contact) self._window.update_contacts_from_storage() self._window.refresh_chat_header() def _on_delete_path_record(self, _btn, record_id, row, paths_group, contact, dialog): self._window.storage.delete_path_record(record_id) paths_group.remove(row) count = len(self._window.storage.get_path_history(contact.public_key_hex)) if count: self.smart_paths_row.set_subtitle(_("%d paths available") % count) else: self.smart_paths_row.set_subtitle(_("No paths yet")) dialog.close() # --- Smart paths dialog --- def _show_smart_paths_dialog(self, contact: Contact): from meshy.views.map_view import _ROUTE_COLORS path_svc = self._window._msg_ctrl.path_history records = self._window.storage.get_path_history(contact.public_key_hex) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/smart-paths-dialog.ui" ) dialog = builder.get_object("dialog") header = builder.get_object("header") box = builder.get_object("content") dialog.set_title(_("Smart Routes — %s") % contact.name) if records: map_btn = Gtk.Button(icon_name="map-symbolic", tooltip_text=_("Show on Map")) map_btn.add_css_class("flat") map_btn.connect( "clicked", lambda *_, d=dialog: self._show_all_routes_on_map(contact, records, path_svc, d), ) header.pack_end(map_btn) paths_group = Adw.PreferencesGroup(title=_("Available Paths")) if records: for i, r in enumerate(sorted(records, key=path_svc.score_dict, reverse=True)): path_hex = r["path_bytes"] if path_hex: chunk = r["path_hash_size"] * 2 hops = ",".join(path_hex[j : j + chunk] for j in range(0, len(path_hex), chunk)) else: hops = _("Direct") sub_parts = [ _("Score: %.2f") % path_svc.score_dict(r), _("Weight: %.1f") % r["route_weight"], f"{r['success_count']}✓ {r['failure_count']}✗", _("%d hops") % r["hop_count"], ] trip_str = format_trip_time(r.get("last_trip_ms")) if trip_str: sub_parts.append(trip_str) row = Adw.ActionRow(title=hops, subtitle=" · ".join(sub_parts)) cr, cg, cb = _ROUTE_COLORS[i % len(_ROUTE_COLORS)] dot = Gtk.DrawingArea() dot.set_content_width(12) dot.set_content_height(12) dot.set_valign(Gtk.Align.CENTER) dot.set_draw_func( lambda _area, ctx, w, h, _r=cr, _g=cg, _b=cb: ( ctx.set_source_rgb(_r, _g, _b), ctx.arc(w / 2, h / 2, min(w, h) / 2, 0, 2 * 3.141593), ctx.fill(), ) ) row.add_prefix(dot) del_btn = Gtk.Button(icon_name="edit-delete-symbolic") del_btn.add_css_class("flat") del_btn.set_valign(Gtk.Align.CENTER) record_id = r["id"] del_btn.connect( "clicked", self._on_delete_path_record, record_id, row, paths_group, contact, dialog, ) row.add_suffix(del_btn) paths_group.add(row) else: empty_row = Adw.ActionRow( title=_("No paths recorded yet"), subtitle=_("Paths are discovered as messages are exchanged"), ) paths_group.add(empty_row) box.append(paths_group) if records: clear_btn = Gtk.Button(label=_("Clear History")) clear_btn.add_css_class("destructive-action") clear_btn.set_halign(Gtk.Align.CENTER) def _on_clear(*_args): self._window.storage.clear_path_history(contact.public_key_hex) self.smart_paths_row.set_subtitle(_("No paths yet")) dialog.close() clear_btn.connect("clicked", _on_clear) box.append(clear_btn) dialog.present(self._window) # --- Smart routes map --- def _show_all_routes_on_map(self, contact, records, path_svc, parent_dialog): from gi.repository import Shumate from meshy.views.map_view import ( _MARKER_COLORS, _MARKER_ICONS, _ROUTE_COLORS, _SELF_COLOR, _UNKNOWN_COLOR, MapView, companion_too_far, ) sorted_records = sorted(records, key=path_svc.score_dict, reverse=True) device = self._window.device_info self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) route_hops = [] for i, rec in enumerate(sorted_records): color_rgb = _ROUTE_COLORS[i % len(_ROUTE_COLORS)] path_hex = rec["path_bytes"] raw_hops = [] if path_hex: chunk = rec["path_hash_size"] * 2 hop_hexes = [path_hex[j : j + chunk] for j in range(0, len(path_hex), chunk)] hop_contacts = self._window.resolve_hop_prefixes(hop_hexes) for j, hc in enumerate(hop_contacts): prefix = hop_hexes[j] if j < len(hop_hexes) else None if hc and hc.has_location: raw_hops.append((hc.latitude, hc.longitude, hc, prefix, None)) else: raw_hops.append((None, None, hc, prefix, None)) if contact.has_location: raw_hops.append((contact.latitude, contact.longitude, contact, None, None)) if has_self and not companion_too_far(self_lat, self_lon, raw_hops): raw_hops.insert(0, (self_lat, self_lon, None, None, None)) hops = MapView.interpolate_unknown_hops(raw_hops) route_hops.append((color_rgb, hops)) all_lats, all_lons = [], [] for _color, hops in route_hops: for h in hops: if h[0] is not None: all_lats.append(h[0]) all_lons.append(h[1]) if not all_lats: self._window.show_toast(_("No location data available")) return dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( self._window, title=_("Routes to %s") % contact.name ) map_widget.get_map().remove_layer(marker_layer) for color_rgb, hops in route_hops: path_layer = MapView.create_dashed_path_layer( viewport, color_rgb=color_rgb, stroke_width=3 ) for lat, lon, _hc, _pfx, _snr2 in hops: if lat is not None: node = Shumate.Marker() node.set_location(lat, lon) path_layer.add_node(node) map_widget.get_map().add_layer(path_layer) map_widget.get_map().add_layer(marker_layer) placed = set() for _color, hops in route_hops: for lat, lon, hop_contact, prefix, _snr in hops: if lat is None: continue key = (round(lat, 6), round(lon, 6)) if key in placed: continue placed.add(key) if hop_contact is None and prefix is None: r, g, b = _SELF_COLOR icon_name = "network-wireless-symbolic" name = device.name or _("My Device") elif hop_contact is None: r, g, b = _UNKNOWN_COLOR icon_name = "repeater-symbolic" name = prefix or _("Unknown") else: r, g, b = _MARKER_COLORS.get(hop_contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(hop_contact.type, "avatar-default-symbolic") name = hop_contact.name marker = MapView.create_marker(r, g, b, icon_name, name, lat, lon, show_label=True) marker_layer.add_marker(marker) # Legend bar toolbar_view = dialog.get_child() legend_scroll = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.AUTOMATIC, vscrollbar_policy=Gtk.PolicyType.NEVER, ) legend_box = Gtk.Box( spacing=16, margin_start=8, margin_end=8, margin_top=4, margin_bottom=4, ) for i, rec in enumerate(sorted_records): cr, cg, cb = _ROUTE_COLORS[i % len(_ROUTE_COLORS)] item = Gtk.Box(spacing=4) dot = Gtk.DrawingArea() dot.set_content_width(10) dot.set_content_height(10) dot.set_valign(Gtk.Align.CENTER) dot.set_draw_func( lambda _area, ctx, w, h, _r=cr, _g=cg, _b=cb: ( ctx.set_source_rgb(_r, _g, _b), ctx.arc(w / 2, h / 2, min(w, h) / 2, 0, 2 * 3.141593), ctx.fill(), ) ) item.append(dot) path_hex = rec["path_bytes"] if path_hex: label_text = _("%d hops") % rec["hop_count"] else: label_text = _("Direct") lbl = Gtk.Label(label=label_text) lbl.add_css_class("caption") item.append(lbl) legend_box.append(item) legend_scroll.set_child(legend_box) toolbar_view.add_bottom_bar(legend_scroll) MapView.fit_viewport(viewport, all_lats, all_lons) dialog.present(parent_dialog) # --- Danger zone --- def _on_remove(self, _btn, contact): confirm = Adw.AlertDialog( heading=_("Remove Contact?"), body=_("Remove {} and all messages?").format(contact.name), ) confirm.add_response("cancel", _("Cancel")) confirm.add_response("remove", _("Remove")) confirm.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) confirm.connect( "response", lambda d, r: ( (self._window.remove_contact(contact), self.close()) if r == "remove" else None ), ) confirm.present(self._window) meshy/src/views/contacts_view.py000066400000000000000000001504211523412555500173630ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Contacts list view - shown in the sidebar.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Shumate", "1.0") from gi.repository import Adw, Gio, GLib, GObject, Gtk, Shumate from meshy.models import Contact, ContactType from meshy.protocol import build_sync_next_message from meshy.utils import clear_children, format_number, format_trip_time, ngettext from meshy.views import ( _add_hover_swap, show_contact_picker, ) SORT_AZ = "az" SORT_HEARD = "heard" SORT_MESSAGES = "messages" def contact_sort_key(c: Contact, sort_mode: str, favorites_first: bool): """Compute sort key for a contact. Usable independently of ContactsView.""" fav = favorites_first if sort_mode == SORT_AZ: return (not c.is_favorite if fav else False, c.name.lower()) elif sort_mode == SORT_MESSAGES: return ( not c.is_favorite if fav else False, -(c.last_message_at.timestamp() if c.last_message_at else 0), ) else: # SORT_HEARD return ( not c.is_favorite if fav else False, -(c.last_seen.timestamp() if c.last_seen else 0), ) @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/contacts-view.ui") class ContactsView(Gtk.Box): __gtype_name__ = "MeshyContactsView" _search_bar = Gtk.Template.Child("search_bar") _search_entry = Gtk.Template.Child("search_entry") _list_box = Gtk.Template.Child("list_box") _status_label = Gtk.Template.Child("status_label") _add_btn = Gtk.Template.Child("add_btn") # Filter constants FILTER_ALL = "all" FILTER_FAVORITES = "favorites" FILTER_USERS = "users" FILTER_REPEATERS = "repeaters" FILTER_ROOMS = "rooms" FILTER_SENSORS = "sensors" # Sort constants SORT_AZ = "az" SORT_HEARD = "heard" SORT_MESSAGES = "messages" def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._contact_rows: dict[str, Adw.ActionRow] = {} self._unread_keys: set[str] = set() self._unread_counts: dict[str, int] = {} self._unread_dots: dict[str, Gtk.Label] = {} self._current_filter = self.FILTER_ALL settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") saved_sort = settings.get_string("contact-sort") self._current_sort = ( saved_sort if saved_sort in (self.SORT_AZ, self.SORT_HEARD, self.SORT_MESSAGES) else self.SORT_HEARD ) self._favorites_first = settings.get_boolean("contact-favorites-first") self._login_dialog_open = False self._total_contacts = 0 # Register actions action_group = Gio.SimpleActionGroup() filter_action = Gio.SimpleAction.new_stateful( "filter", GLib.VariantType.new("s"), GLib.Variant.new_string(self._current_filter) ) filter_action.connect("activate", self._on_filter_changed) action_group.add_action(filter_action) sort_action = Gio.SimpleAction.new_stateful( "sort", GLib.VariantType.new("s"), GLib.Variant.new_string(self._current_sort) ) sort_action.connect("activate", self._on_sort_changed) action_group.add_action(sort_action) fav_action = Gio.SimpleAction.new_stateful( "favorites-first", None, GLib.Variant.new_boolean(self._favorites_first) ) fav_action.connect("change-state", self._on_favorites_first_toggled) action_group.add_action(fav_action) # Add contact menu (conditional QR item) add_menu = Gio.Menu() add_menu.append(_("Discover Contacts"), "contacts.discover") add_menu.append(_("Add Manually"), "contacts.add-manual") add_menu.append(_("Import from Clipboard"), "contacts.import-clipboard") import meshy as _meshy_pkg if _meshy_pkg.QR_SCANNER_ENABLED: add_menu.append(_("Scan QR Code"), "contacts.scan-qr") self._add_btn.set_menu_model(add_menu) add_action = Gio.SimpleAction.new("add-manual", None) add_action.connect("activate", self._on_add_manual) action_group.add_action(add_action) discover_action = Gio.SimpleAction.new("discover", None) discover_action.connect("activate", self._on_discover_contacts) action_group.add_action(discover_action) clipboard_action = Gio.SimpleAction.new("import-clipboard", None) clipboard_action.connect("activate", self._on_import_clipboard) action_group.add_action(clipboard_action) if _meshy_pkg.QR_SCANNER_ENABLED: qr_action = Gio.SimpleAction.new("scan-qr", None) qr_action.connect("activate", self._on_scan_qr) action_group.add_action(qr_action) self._action_group = action_group self.insert_action_group("contacts", action_group) self._search_btn = None self._search_binding = None # Signal connections self._search_bar.connect_entry(self._search_entry) self._search_bar.connect("notify::search-mode-enabled", self._on_search_mode_changed) self._search_entry.connect("search-changed", self._on_search_changed) self._list_box.set_filter_func(self._filter_func) self._list_box.connect("row-activated", self._on_row_selected) # Initial populate self.update_contacts(self._window.contacts) def set_search_button(self, btn): if self._search_binding: self._search_binding.unbind() self._search_btn = btn self._search_binding = btn.bind_property( "active", self._search_bar, "search-mode-enabled", GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE, ) def build_filter_menu(self): menu = Gio.Menu() for filter_id, label in ( ("all", _("All")), ("favorites", _("Favourites")), ("users", _("Users")), ("repeaters", _("Repeaters")), ("rooms", _("Room Servers")), ("sensors", _("Sensors")), ): item = Gio.MenuItem.new(label, None) item.set_action_and_target_value("contacts.filter", GLib.Variant.new_string(filter_id)) menu.append_item(item) return menu def build_sort_menu(self): menu = Gio.Menu() section1 = Gio.Menu() for sort_id, label in ( ("az", _("A–Z")), ("heard", _("Heard Recently")), ("messages", _("Latest Messages")), ): item = Gio.MenuItem.new(label, None) item.set_action_and_target_value("contacts.sort", GLib.Variant.new_string(sort_id)) section1.append_item(item) menu.append_section(None, section1) section2 = Gio.Menu() section2.append(_("Favorites First"), "contacts.favorites-first") menu.append_section(None, section2) return menu def _filter_func(self, row) -> bool: if not hasattr(row, "_contact"): return True contact = row._contact # Type filter f = self._current_filter if ( f == self.FILTER_FAVORITES and not contact.is_favorite or f == self.FILTER_USERS and contact.type != ContactType.CHAT or f == self.FILTER_REPEATERS and contact.type != ContactType.REPEATER or f == self.FILTER_ROOMS and contact.type != ContactType.ROOM or f == self.FILTER_SENSORS and contact.type != ContactType.SENSOR ): return False # Search filter search = self._search_entry.get_text().lower() return not (search and search not in contact.name.lower()) def _update_status_count(self): """Update the status label with visible/total contact count.""" total = self._total_contacts if self._current_filter != self.FILTER_ALL or self._search_entry.get_text(): # Count rows that pass the filter visible = sum(1 for row in self._contact_rows.values() if self._filter_func(row)) self._status_label.set_label( _("{visible}/{total} contacts").format(visible=visible, total=total) ) else: self._status_label.set_label( _("{total} contacts").format(total=total) if total != 1 else _("{total} contact").format(total=total) ) def _on_search_mode_changed(self, search_bar, pspec): if not search_bar.get_search_mode(): self._search_entry.set_text("") def _on_search_changed(self, entry): self._list_box.invalidate_filter() GLib.idle_add(self._update_status_count) def _on_filter_changed(self, action, param): self._current_filter = param.get_string() action.set_state(param) self._list_box.invalidate_filter() GLib.idle_add(self._update_status_count) def _on_sort_changed(self, action, param): self._current_sort = param.get_string() action.set_state(param) settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") settings.set_string("contact-sort", self._current_sort) self.update_contacts(self._window.contacts) def _on_favorites_first_toggled(self, action, value): action.set_state(value) self._favorites_first = value.get_boolean() settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") settings.set_boolean("contact-favorites-first", self._favorites_first) self.update_contacts(self._window.contacts) def _on_discover_contacts(self, *args): discovered = [ c for c in self._window.discovered_contacts if not self._window.is_contact_known(c.public_key_hex) ] if not discovered: dialog = Adw.AlertDialog( heading=_("Discover Contacts"), body=_( "No new contacts have been heard yet. " "Leave the app running and contacts will appear " "as their adverts are received." ), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return dev = self._window.device_info has_self_loc = ( dev and dev.latitude is not None and dev.longitude is not None and (abs(dev.latitude) > 1e-6 or abs(dev.longitude) > 1e-6) ) self_lat = dev.latitude if dev else None self_lon = dev.longitude if dev else None def _subtitle(contact): subtitle = contact.type_label if has_self_loc and contact.has_location: from meshy.utils import haversine_distance dist = haversine_distance(self_lat, self_lon, contact.latitude, contact.longitude) if dist < 1.0: subtitle += f" · {format_number(dist * 1000, 0)} m" else: subtitle += f" · {format_number(dist)} km" return subtitle ns = show_contact_picker( self._window, title=_("Discover Contacts"), contacts=discovered, on_add=self._window.add_discovered_contact, subtitle_func=_subtitle, status_format_all=_("{total} discovered"), status_format_filtered=_("{visible}/{total} discovered"), ) # Filter menu ns.filter_btn.set_visible(True) filter_menu = Gio.Menu() for fid, label in ( ("all", _("All")), ("users", _("Users")), ("repeaters", _("Repeaters")), ("rooms", _("Room Servers")), ("sensors", _("Sensors")), ): filter_menu.append_item(Gio.MenuItem.new(label, f"discover.filter::{fid}")) ns.filter_btn.set_menu_model(filter_menu) # Sort menu ns.sort_btn.set_visible(True) sort_menu = Gio.Menu() for sid, label in ( ("name", _("Name")), ("last-seen", _("Last Seen")), ("arrival", _("Arrival Time")), ): sort_menu.append_item(Gio.MenuItem.new(label, f"discover.sort::{sid}")) if has_self_loc: sort_menu.append_item(Gio.MenuItem.new(_("Distance"), "discover.sort::distance")) ns.sort_btn.set_menu_model(sort_menu) # Actions action_group = Gio.SimpleActionGroup() filter_action = Gio.SimpleAction.new_stateful( "filter", GLib.VariantType.new("s"), GLib.Variant.new_string("all") ) sort_action = Gio.SimpleAction.new_stateful( "sort", GLib.VariantType.new("s"), GLib.Variant.new_string("name") ) action_group.add_action(filter_action) action_group.add_action(sort_action) ns.dialog.insert_action_group("discover", action_group) # Replace search-only filter with search + type filter ns.custom_filter.set_filter_func( self._discover_filter_func, (ns.search_entry, filter_action) ) def _on_filter_activated(_action, param): filter_action.set_state(param) ns.custom_filter.changed(Gtk.FilterChange.DIFFERENT) ns.update_status() filter_action.connect("activate", _on_filter_activated) # Sorter custom_sorter = Gtk.CustomSorter.new( self._discover_sort_func, (sort_action, self_lat, self_lon) ) def _on_sort_activated(_action, param): sort_action.set_state(param) custom_sorter.changed(Gtk.SorterChange.DIFFERENT) sort_action.connect("activate", _on_sort_activated) sort_model = Gtk.SortListModel(model=ns.filter_model, sorter=custom_sorter) ns.list_view.set_model(Gtk.NoSelection(model=sort_model)) ns.dialog._refs.extend([custom_sorter, sort_model, action_group]) ns.update_status() ns.present() def _discover_filter_func(self, item, user_data): search_entry, filter_action = user_data contact = item.contact search = search_entry.get_text().lower() if search and search not in contact.name.lower(): return False f = filter_action.get_state().get_string() return not ( f == "users" and contact.type != ContactType.CHAT or f == "repeaters" and contact.type != ContactType.REPEATER or f == "rooms" and contact.type != ContactType.ROOM or f == "sensors" and contact.type != ContactType.SENSOR ) @staticmethod def _discover_sort_func(a, b, user_data=None): sort_action, self_lat, self_lon = user_data mode = sort_action.get_state().get_string() if mode == "last-seen": ta = a.contact.last_seen tb = b.contact.last_seen if ta is not None and tb is not None: return (tb > ta) - (tb < ta) if ta is not None: return -1 if tb is not None: return 1 na = a.contact.name.lower() nb = b.contact.name.lower() return (na > nb) - (na < nb) if mode == "arrival": ta = a.contact.received_at tb = b.contact.received_at if ta is not None and tb is not None: return (tb > ta) - (tb < ta) if ta is not None: return -1 if tb is not None: return 1 na = a.contact.name.lower() nb = b.contact.name.lower() return (na > nb) - (na < nb) if mode == "distance" and self_lat is not None and self_lon is not None: from meshy.utils import haversine_distance da = ( haversine_distance(self_lat, self_lon, a.contact.latitude, a.contact.longitude) if a.contact.has_location else float("inf") ) db = ( haversine_distance(self_lat, self_lon, b.contact.latitude, b.contact.longitude) if b.contact.has_location else float("inf") ) if da != db: return (da > db) - (da < db) na = a.contact.name.lower() nb = b.contact.name.lower() return (na > nb) - (na < nb) def _on_add_manual(self, *args): dialog = Adw.AlertDialog( heading=_("Add Contact"), body=_("Enter the contact details."), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/add-contact-content.ui" ) dialog.set_extra_child(builder.get_object("content")) type_row = builder.get_object("type_row") type_list = Gtk.StringList() for label in [_("Chat"), _("Repeater"), _("Room Server"), _("Sensor")]: type_list.append(label) type_row.set_model(type_list) type_row.set_selected(0) name_row = builder.get_object("name_row") key_row = builder.get_object("key_row") dialog.add_response("cancel", _("Cancel")) dialog.add_response("add", _("Add")) dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "add": try: name = name_row.get_text().strip() key_text = key_row.get_text().strip() pub_key = bytes.fromhex(key_text) if len(pub_key) != 32 or not name: return if self._window.is_contact_known(key_text): self._show_import_error(_("This contact is already in your list.")) return contact_type = ( type_row.get_selected() + 1 ) # CHAT=1, REPEATER=2, ROOM=3, SENSOR=4 self._window.add_contact(pub_key, contact_type, name) except ValueError: pass dialog.connect("response", _on_response) dialog.present(self._window) def _on_import_clipboard(self, *args): clipboard = self._window.get_clipboard() clipboard.read_text_async(None, self._on_clipboard_read) def _on_clipboard_read(self, clipboard, result): try: text = clipboard.read_text_finish(result) except Exception: text = None if not text or not text.strip().startswith("meshcore://"): dialog = Adw.AlertDialog( heading=_("Import Failed"), body=_("No meshcore:// link found in clipboard."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return app = self._window.get_application() app._handle_meshcore_uri(text.strip()) def _on_scan_qr(self, *args): from meshy.camera import create_qr_scanner self._qr_scanner = create_qr_scanner(self._window, self._on_qr_result) self._qr_scanner.start() def _on_qr_result(self, data): """Handle a scanned QR code - meshcore:// URL or raw public key hex.""" from urllib.parse import parse_qs, urlparse # meshcore://contact/add?name=...&public_key=... if data.lower().startswith("meshcore://"): parsed = urlparse(data) params = parse_qs(parsed.query) full_path = f"{parsed.netloc}{parsed.path}".rstrip("/") if full_path == "channel/add" and "secret" in params: # meshcore://channel/add?name=...&secret=... ch_name = params.get("name", [""])[0] secret_hex = params["secret"][0].strip() try: secret = bytes.fromhex(secret_hex) except ValueError: self._show_import_error(_("Invalid channel secret in QR code.")) return if len(secret) != 16: self._show_import_error( _("Channel secret must be 16 bytes, got {}.").format(len(secret)) ) return self._window.add_channel_from_qr(ch_name, secret) return if "public_key" in params: pub_key_hex = params["public_key"][0].strip() name = params.get("name", [""])[0] # Extract contact type: 1=Companion, 2=Repeater, 3=RoomServer, 4=Sensor try: contact_type = int(params.get("type", ["1"])[0]) except ValueError: contact_type = 1 try: pub_key = bytes.fromhex(pub_key_hex) except ValueError: self._show_import_error(_("Invalid public key in QR code.")) return if len(pub_key) != 32: self._show_import_error( _("Public key must be 32 bytes, got {}.").format(len(pub_key)) ) return if self._window.is_contact_known(pub_key_hex): self._show_import_error(_("This contact is already in your list.")) return if name: self._window.add_contact(pub_key, contact_type, name) else: self._show_qr_contact_dialog(pub_key, pub_key_hex) return # Try as raw hex contact frame (old format) hex_data = data[len("meshcore://") :].strip() if "/" not in hex_data and "?" not in hex_data: try: contact_frame = bytes.fromhex(hex_data) self._window.import_contact(contact_frame) return except ValueError: pass # Try as raw hex public key (64 hex chars = 32 bytes) cleaned = data.strip().replace(" ", "").replace(":", "") try: pub_key = bytes.fromhex(cleaned) if len(pub_key) == 32: if self._window.is_contact_known(cleaned): self._show_import_error(_("This contact is already in your list.")) return self._show_qr_contact_dialog(pub_key, cleaned) return except ValueError: pass self._show_import_error(_("Could not parse QR code data:\n{}").format(data[:120])) def _show_import_error(self, message): dialog = Adw.AlertDialog(heading=_("Import Failed"), body=message) dialog.add_response("ok", _("OK")) dialog.present(self._window) def _show_qr_contact_dialog(self, pub_key, hex_str): """Show dialog to complete contact details from a scanned public key.""" dialog = Adw.AlertDialog( heading=_("QR Code Scanned"), body=_("Public key: {}...{}").format(hex_str[:16], hex_str[-16:]), ) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/qr-contact-content.ui" ) dialog.set_extra_child(builder.get_object("content")) type_row = builder.get_object("type_row") type_list = Gtk.StringList() for label in [_("Chat"), _("Repeater"), _("Room Server"), _("Sensor")]: type_list.append(label) type_row.set_model(type_list) type_row.set_selected(0) name_row = builder.get_object("name_row") dialog.add_response("cancel", _("Cancel")) dialog.add_response("add", _("Add Contact")) dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) def _on_response(d, response): if response == "add": name = name_row.get_text().strip() if not name: return contact_type = type_row.get_selected() + 1 self._window.add_contact(pub_key, contact_type, name) dialog.connect("response", _on_response) dialog.present(self._window) def _on_row_selected(self, listbox, row): import logging log = logging.getLogger(__name__) if self._login_dialog_open: log.debug("Login dialog already open, ignoring row selection") return if row and hasattr(row, "_contact"): contact = row._contact log.info( f"Contact selected: {contact.name} (type={contact.type}, logged_in={self._window.is_room_logged_in(contact.public_key_hex)})" ) if contact.type == ContactType.REPEATER: if self._window.is_room_logged_in(contact.public_key_hex): self._window.show_repeater( contact, is_admin=self._window.is_room_admin(contact.public_key_hex) ) else: self._show_room_login(contact, repeater=True) elif contact.type == ContactType.ROOM and not self._window.is_room_logged_in( contact.public_key_hex ): log.info("Room not logged in, showing login dialog") self._show_room_login(contact) elif contact.type == ContactType.SENSOR: self._window.show_sensor(contact) else: self._window.show_chat(contact) def _show_room_login(self, contact: Contact, repeater: bool = False): """Show a login dialog for a room/repeater contact.""" import logging log = logging.getLogger(__name__) log.info( f"Showing login dialog for {contact.name} (type={contact.type}, pub_key={contact.public_key_hex[:16]}...)" ) self._login_dialog_open = True builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/room-login-dialog.ui" ) dialog = builder.get_object("dialog") dialog.set_title(_("Login to {}").format(contact.name)) password_entry = builder.get_object("password_entry") save_switch = builder.get_object("save_switch") spinner = builder.get_object("spinner") status_label = builder.get_object("status_label") login_btn = builder.get_object("login_btn") # Pre-fill saved password if self._window.storage: saved = self._window.storage.get_room_password(contact.public_key_hex) if saved: password_entry.set_text(saved) save_switch.set_active(True) timeout_id = [None] def on_login_result(success, login_timestamp=None, is_admin=True, acl_perms=3): spinner.set_spinning(False) if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None if success: status_label.set_label(_("Login successful")) # Save or remove password if self._window.storage: if save_switch.get_active(): self._window.storage.save_room_password( contact.public_key_hex, password_entry.get_text() ) else: self._window.storage.remove_room_password(contact.public_key_hex) self._window.mark_room_logged_in(contact.public_key_hex, acl_perms) # Immediately sync to fetch pending room messages self._window.send_frame(build_sync_next_message()) dialog.close() if repeater: self._window.show_repeater(contact, login_timestamp, is_admin) else: self._window.show_chat(contact) else: status_label.set_label(_("Login failed — wrong password")) status_label.remove_css_class("dim-label") status_label.add_css_class("error") login_btn.set_sensitive(True) def on_timeout(): spinner.set_spinning(False) status_label.set_label(_("Login timed out")) self._window.stop_login() login_btn.set_sensitive(True) timeout_id[0] = None return False def on_retry(): status_label.set_label(_("Retrying via flood...")) def on_login_clicked(*args): log.info(f"Login button clicked for {contact.name}") password = password_entry.get_text() log.info(f"Password length: {len(password)}") spinner.set_spinning(True) status_label.set_label(_("Logging in...")) status_label.remove_css_class("error") status_label.add_css_class("dim-label") login_btn.set_sensitive(False) timeout_id[0] = GLib.timeout_add(15000, on_timeout) log.info(f"Calling send_login with pub_key={contact.public_key.hex()[:16]}...") self._window.send_login(contact.public_key, password, on_login_result, on_retry) login_btn.connect("clicked", on_login_clicked) password_entry.connect("apply", on_login_clicked) def on_dialog_closed(d): self._login_dialog_open = False self._window.stop_login() if timeout_id[0]: GLib.source_remove(timeout_id[0]) dialog.connect("closed", on_dialog_closed) dialog.present(self._window) def _sort_key(self, c: Contact): """Compute sort key for a contact based on current sort setting.""" return contact_sort_key(c, self._current_sort, self._favorites_first) def update_contacts(self, contacts: list[Contact]): sorted_contacts = sorted(contacts, key=self._sort_key) new_keys = [c.public_key_hex for c in sorted_contacts] old_keys = list(self._contact_rows.keys()) # Fast path: same contacts in same order — update in-place if new_keys == old_keys: for contact in sorted_contacts: key = contact.public_key_hex row = self._contact_rows.get(key) if row: row.set_title(GLib.markup_escape_text(contact.name)) row.set_subtitle(contact.path_label) row._contact = contact self._total_contacts = len(contacts) self._update_status_count() return # Full rebuild: contact set or order changed self._full_rebuild(sorted_contacts) def _full_rebuild(self, sorted_contacts: list[Contact]): """Remove all rows and recreate from scratch.""" selected_row = self._list_box.get_selected_row() selected_key = ( selected_row._contact.public_key_hex if selected_row and hasattr(selected_row, "_contact") else None ) clear_children(self._list_box) self._contact_rows.clear() new_selected = None for contact in sorted_contacts: row = self._create_contact_row(contact) self._list_box.append(row) self._contact_rows[contact.public_key_hex] = row if contact.public_key_hex == selected_key: new_selected = row if new_selected: self._list_box.select_row(new_selected) self._total_contacts = len(sorted_contacts) self._update_status_count() def _create_contact_row(self, contact: Contact) -> Gtk.ListBoxRow: row = Adw.ActionRow( title=GLib.markup_escape_text(contact.name), subtitle=contact.path_label, ) row._contact = contact # Type icon with favorite star icon_name = { ContactType.CHAT: "avatar-default-symbolic", ContactType.REPEATER: "repeater-symbolic", ContactType.ROOM: "user-home-symbolic", ContactType.SENSOR: "weather-clear-symbolic", }.get(contact.type, "avatar-default-symbolic") prefix_box = Gtk.Box(spacing=2) star = Gtk.Image.new_from_icon_name("starred-symbolic") star.set_pixel_size(10) if not contact.is_favorite: star.set_opacity(0) prefix_box.append(star) prefix_box.append(Gtk.Image.new_from_icon_name(icon_name)) row.add_prefix(prefix_box) # Unread badge — visible by default when count > 0, hidden on hover count = self._unread_counts.get(contact.public_key_hex, 0) unread_badge = Gtk.Label(label=str(count) if count else "") unread_badge.add_css_class("unread-badge") unread_badge.set_valign(Gtk.Align.CENTER) unread_badge.set_visible(count > 0) row.add_suffix(unread_badge) self._unread_dots[contact.public_key_hex] = unread_badge # "More" button + hover behavior + long press info_btn = Gtk.Button(icon_name="view-more-symbolic", valign=Gtk.Align.CENTER) info_btn.add_css_class("flat") info_btn.set_visible(False) on_detail = lambda *_, r=row: self._show_contact_detail(r._contact) info_btn.connect("clicked", on_detail) row.add_suffix(info_btn) _add_hover_swap( row, info_btn, unread_badge, lambda: self._unread_counts.get(contact.public_key_hex, 0), on_detail, ) row.set_activatable(True) return row def mark_unread(self, public_key_hex: str, unread: bool): """Show or hide the unread badge for a contact.""" if unread: self._unread_keys.add(public_key_hex) self._unread_counts[public_key_hex] = self._unread_counts.get(public_key_hex, 0) + 1 else: self._unread_keys.discard(public_key_hex) self._unread_counts[public_key_hex] = 0 if public_key_hex in self._unread_dots: badge = self._unread_dots[public_key_hex] count = self._unread_counts.get(public_key_hex, 0) badge.set_label(str(count) if count else "") badge.set_visible(count > 0) self._window.update_nav_unread_contacts() def _get_visible_rows(self): """Return visible rows in display order.""" rows = [] row = self._list_box.get_first_child() while row: if row.get_visible() and hasattr(row, "_contact"): rows.append(row) row = row.get_next_sibling() return rows def navigate(self, direction: int, unread_only: bool = False): """Move selection up (-1) or down (+1). If unread_only, skip to next unread.""" rows = self._get_visible_rows() if not rows: return if unread_only: rows = [r for r in rows if r._contact.public_key_hex in self._unread_keys] if not rows: return selected = self._list_box.get_selected_row() if selected and selected in rows: idx = rows.index(selected) idx = (idx + direction) % len(rows) else: idx = 0 if direction > 0 else len(rows) - 1 self._list_box.select_row(rows[idx]) rows[idx].grab_focus() def _on_path_discovery(self, contact: Contact, btn: Gtk.Button): """Send a path discovery request for a contact.""" btn.set_sensitive(False) self._discover_path_label.set_label(_("Searching...")) timeout_id = [None] def _on_response(data): if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None self._window.stop_path_discovery() btn.set_sensitive(True) # Parse the PATH_DISCOVERY_RESPONSE (0x8D) # Format: [code][reserved][prefix×6][out_path_len][out_path...][in_path_len][in_path...] try: i = 2 # skip code + reserved i += 6 # skip prefix opl_byte = data[i] i += 1 opl_hlen = ((opl_byte & 0xC0) >> 6) + 1 opl = opl_byte & 0x3F out_hops = [] for _j in range(opl): out_hops.append(data[i : i + opl_hlen].hex().upper()) i += opl_hlen ipl_byte = data[i] i += 1 ipl_hlen = ((ipl_byte & 0xC0) >> 6) + 1 ipl = ipl_byte & 0x3F in_hops = [] for _j in range(ipl): in_hops.append(data[i : i + ipl_hlen].hex().upper()) i += ipl_hlen if opl == 0 and ipl == 0: self._discover_path_label.set_label(_("Direct")) else: parts = [] if out_hops: parts.append(",".join(out_hops)) self._discover_path_label.set_label( " → ".join(parts) if parts else ngettext("%d hop", "%d hops", opl) % opl ) # Apply discovered outbound path to the contact if out_hops: path_bytes = b"".join(bytes.fromhex(h) for h in out_hops) hash_mode = opl_hlen - 1 path_len_byte = (hash_mode << 6) | (opl & 0x3F) self._window.set_contact_path(contact, path_len_byte, path_bytes) self._window.show_toast(f"Path set: {','.join(out_hops)}") except (IndexError, ValueError) as e: import logging logging.getLogger(__name__).warning(f"Path discovery parse error: {e}") self._discover_path_label.set_label(_("Path found")) self._window.refresh_contacts() def _on_timeout(): self._window.stop_path_discovery() btn.set_sensitive(True) self._discover_path_label.set_label(_("No response")) timeout_id[0] = None return False timeout_id[0] = GLib.timeout_add(30000, _on_timeout) self._window.send_path_discovery(contact.public_key, _on_response) def _on_get_advert_path(self, contact: Contact): """Get and display the inbound advertisement path for a contact.""" self._advert_path_label.set_label(_("Loading...")) # Set a timeout in case the response never comes (error consumed elsewhere) timeout_id = [None] def _on_response(result): if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None if not result: self._advert_path_label.set_label(_("No path cached")) return hops = result.get("hops", []) path_len = result.get("path_length", -1) if path_len < 0 or not hops: self._advert_path_label.set_label(_("Flood")) elif path_len == 0: self._advert_path_label.set_label(_("Direct")) else: self._advert_path_label.set_label(" → ".join(hops)) def _on_timeout(): self._window.set_advert_path_callback(None) self._advert_path_label.set_label(_("Not supported")) timeout_id[0] = None return False timeout_id[0] = GLib.timeout_add(3000, _on_timeout) self._window.get_advert_path(contact.public_key, _on_response) def _ping_contact(self, contact: Contact, btn: Gtk.Button): """Ping a repeater/room via trace path to get RTT and SNR.""" # Trace protocol only supports 1B and 2B hashes (power-of-2 encoding) if self._window.device_info.path_hash_mode > 1: self._window.show_toast(_("Ping is not supported with 3-byte path hashes")) return import time btn.set_sensitive(False) start = time.monotonic() timeout_id = [None] hash_width = self._window.device_info.path_hash_mode + 1 hop_hash = contact.public_key[:hash_width] def on_trace(result): if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None rtt = (time.monotonic() - start) * 1000 snr_values = result.get("snr_values", []) parts = [format_trip_time(int(rtt))] if len(snr_values) >= 1: parts.append(f"SNR out: {format_number(snr_values[0], sign=True)} dB") if len(snr_values) >= 2: parts.append(f"SNR back: {format_number(snr_values[-1], sign=True)} dB") msg = _("Ping {}: {}").format(contact.name, ", ".join(parts)) self._window.show_toast(msg, timeout=5) btn.set_sensitive(True) def on_device_timeout(timeout_ms): if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = GLib.timeout_add(timeout_ms, on_timeout) def on_timeout(): self._window.stop_trace_path() self._window.show_toast(_("Ping {}: timed out").format(contact.name), timeout=5) btn.set_sensitive(True) timeout_id[0] = None return False timeout_id[0] = GLib.timeout_add(15000, on_timeout) trace_flags = self._window.device_info.path_hash_mode self._window.send_trace_path(hop_hash, on_trace, on_device_timeout, flags=trace_flags) def _request_telemetry(self, contact: Contact, row: Adw.ActionRow): """Request and display telemetry from a contact.""" from meshy.protocol import parse_cayenne_lpp row.set_subtitle(_("Requesting...")) timeout_id = [None] def on_timeout(): row.set_subtitle(_("No response (timed out)")) timeout_id[0] = None return False def on_response(lpp_data): if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None if not lpp_data: row.set_subtitle(_("No telemetry data")) return entries = parse_cayenne_lpp(lpp_data) if not entries: row.set_subtitle(_("No telemetry data")) return self._show_telemetry_dialog(contact, entries) row.set_subtitle(_("Telemetry received")) timeout_id[0] = GLib.timeout_add(15000, on_timeout) self._window.get_telemetry(contact.public_key, on_response, contact_type=contact.type) @staticmethod def _battery_percent(volts: float) -> int: from meshy.models import battery_percent_from_mv return battery_percent_from_mv(int(volts * 1000)) def _show_telemetry_dialog(self, contact: Contact, entries: list[dict]): """Show a dialog with parsed telemetry data and optional map.""" from meshy.protocol import LPP_GPS, LPP_VOLTAGE # Check for GPS data gps_entry = next((e for e in entries if e["type"] == LPP_GPS), None) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/telemetry-dialog.ui" ) dialog = builder.get_object("dialog") content = builder.get_object("content") dialog.set_title(_("Telemetry — {}").format(contact.name)) dialog.set_content_height(550 if gps_entry else 400) channels = {} for entry in entries: channels.setdefault(entry["channel"], []).append(entry) for ch in sorted(channels): group = Adw.PreferencesGroup(title=_("Channel {}").format(ch)) for entry in channels[ch]: val = entry["value"] if isinstance(val, dict): alt_str = ( f", alt {format_number(val['alt'], 0)} m" if abs(val["alt"]) > 0.01 else "" ) subtitle = f"{val['lat']:.4f}, {val['lon']:.4f}{alt_str}" elif entry["type"] == LPP_VOLTAGE and ch == 1: pct = self._battery_percent(val) subtitle = f"{pct}% ({format_number(val, 2)} V)" elif isinstance(val, float): subtitle = f"{format_number(val, 2)} {entry['unit']}" else: subtitle = f"{val} {entry['unit']}" title = _("Battery") if entry["type"] == LPP_VOLTAGE and ch == 1 else entry["name"] row = Adw.ActionRow(title=title, subtitle=subtitle) group.add(row) content.append(group) # Show map if GPS data is present if gps_entry: try: from meshy.views import create_shumate_map, deregister_map_widget lat = gps_entry["value"]["lat"] lon = gps_entry["value"]["lon"] from meshy.views.map_view import _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, MapView map_widget, marker_layer, viewport = create_shumate_map( zoom_level=14, lat=lat, lon=lon ) map_widget.set_vexpand(False) map_widget.set_size_request(-1, 250) r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") marker = MapView.create_marker(r, g, b, icon_name, contact.name, lat, lon) marker_layer.add_marker(marker) # Show our companion's position too lats, lons = [lat], [lon] si = self._window.self_info self_lat = si.get("adv_lat", 0.0) self_lon = si.get("adv_lon", 0.0) if abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6: self_name = si.get("name", "") or _("My Device") self_marker = MapView.create_marker( *_SELF_COLOR, "avatar-default-symbolic", self_name, self_lat, self_lon ) marker_layer.add_marker(self_marker) lats.append(self_lat) lons.append(self_lon) # Fit viewport to show all markers from meshy.views.map_view import MapView MapView.fit_viewport(viewport, lats, lons) map_frame = Gtk.Frame() map_frame.set_child(map_widget) content.append(map_frame) except Exception as e: import logging log = logging.getLogger(__name__) log.error(f"Failed to create map widget for telemetry display: {e}", exc_info=True) # Show coordinates as text instead lat = gps_entry["value"]["lat"] lon = gps_entry["value"]["lon"] coords_label = Gtk.Label() # Avoid emoji on macOS due to CoreText rendering crashes (Bus error 10) from meshy.platforms import is_macos if is_macos(): coords_label.set_markup( f"GPS Coordinates:\nLat: {lat:.6f}, Lon: {lon:.6f}" ) else: coords_label.set_markup(f"GPS Coordinates:\n📍 {lat:.6f}, {lon:.6f}") coords_label.set_margin_top(12) coords_label.set_margin_bottom(12) content.append(coords_label) if gps_entry: dialog.connect("closed", lambda _d: deregister_map_widget(map_widget)) dialog.present(self._window) def _show_path_on_map(self, contact: Contact, hop_contacts: list, hop_prefixes: list = None): """Show the established path to a contact in a map dialog.""" from meshy.views.map_view import ( _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, _UNKNOWN_COLOR, MapView, companion_too_far, ) device = self._window.device_info self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) # Build full hop list including unknowns: (lat, lon, contact, prefix, snr) raw_hops = [] for i, hop_contact in enumerate(hop_contacts): prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None if hop_contact and hop_contact.has_location: raw_hops.append( (hop_contact.latitude, hop_contact.longitude, hop_contact, prefix, None) ) else: raw_hops.append((None, None, hop_contact, prefix, None)) if contact.has_location: raw_hops.append((contact.latitude, contact.longitude, contact, None, None)) if has_self and not companion_too_far(self_lat, self_lon, raw_hops): raw_hops.insert(0, (self_lat, self_lon, None, None, None)) hops = MapView.interpolate_unknown_hops(raw_hops) if not any(h[0] is not None for h in hops): return dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( self._window, title=_("Path to {}").format(contact.name) ) map_widget.get_map().remove_layer(marker_layer) path_layer = MapView.create_dashed_path_layer(viewport) map_widget.get_map().add_layer(path_layer) map_widget.get_map().add_layer(marker_layer) lats, lons = [], [] hop_num = 0 for lat, lon, hop_contact, prefix, _snr in hops: if lat is None: continue node = Shumate.Marker() node.set_location(lat, lon) path_layer.add_node(node) if hop_contact is None and prefix is None: # Self/device marker r, g, b = _SELF_COLOR icon_name = "network-wireless-symbolic" name = device.name or _("My Device") badge = "" elif hop_contact is None or not hop_contact.has_location: # Unknown/interpolated hop r, g, b = _UNKNOWN_COLOR icon_name = "repeater-symbolic" name = prefix or _("Unknown") hop_num += 1 badge = str(hop_num) else: r, g, b = _MARKER_COLORS.get(hop_contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(hop_contact.type, "avatar-default-symbolic") name = hop_contact.name hop_num += 1 badge = str(hop_num) widget = MapView._build_marker_widget(r, g, b, icon_name, "", badge_text=badge) widget.set_tooltip_text(name) if hop_contact is not None: click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, c=hop_contact: self._show_contact_detail(c) ) widget.add_controller(click) elif prefix is not None: click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, w=widget, nm=name, la=lat, lo=lon: ( MapView.show_marker_popover( w, nm, la, lo, contact_type=_("Unknown repeater"), interpolated=True ) ), ) widget.add_controller(click) hop_marker = Shumate.Marker() hop_marker.set_child(widget) hop_marker.set_location(lat, lon) marker_layer.add_marker(hop_marker) lats.append(lat) lons.append(lon) MapView.fit_viewport(viewport, lats, lons) dialog.present(self._window) def _show_on_map(self, contact: Contact): """Show a contact's location in a map dialog.""" from meshy.views.map_view import _MARKER_COLORS, _MARKER_ICONS, MapView dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( self._window, title=contact.name ) r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") marker = MapView.create_marker( r, g, b, icon_name, contact.name, contact.latitude, contact.longitude ) marker_layer.add_marker(marker) viewport.set_zoom_level(14) viewport.set_location(contact.latitude, contact.longitude) dialog.present(self._window) def _show_contact_detail(self, contact: Contact): """Show contact detail dialog.""" from meshy.views.contact_detail_dialog import ContactDetailDialog dialog = ContactDetailDialog(self, contact) dialog.present(self._window) meshy/src/views/device_view.py000066400000000000000000001533051523412555500170100ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Device information view.""" import json import logging import threading import urllib.request import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Shumate", "1.0") import contextlib from gi.repository import Adw, Gdk, GLib, Gtk, Pango, Shumate from meshy.models import DeviceInfo, snr_to_icon from meshy.utils import clear_children, format_number, format_trip_time log = logging.getLogger(__name__) _RELEASES_URL = "https://flasher.meshcore.io/releases" _FLASHER_URL = "https://flasher.meshcore.io" @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/device-view.ui") class DeviceView(Gtk.Box): __gtype_name__ = "MeshyDeviceView" _scroll = Gtk.Template.Child("scroll") _status_group = Gtk.Template.Child("status_group") _status_row = Gtk.Template.Child("status_row") _status_icon = Gtk.Template.Child("status_icon") _info_group = Gtk.Template.Child("info_group") _name_row = Gtk.Template.Child("name_row") _board_row = Gtk.Template.Child("board_row") _fw_row = Gtk.Template.Child("fw_row") _fw_update_btn = Gtk.Template.Child("fw_update_btn") _key_row = Gtk.Template.Child("key_row") _copy_key_btn = Gtk.Template.Child("copy_key_btn") _telemetry_row = Gtk.Template.Child("telemetry_row") _telemetry_btn = Gtk.Template.Child("telemetry_btn") _location_row = Gtk.Template.Child("location_row") _location_map_btn = Gtk.Template.Child("location_map_btn") _batt_group = Gtk.Template.Child("batt_group") _batt_row = Gtk.Template.Child("batt_row") _batt_bar = Gtk.Template.Child("batt_bar") _storage_row = Gtk.Template.Child("storage_row") _storage_bar = Gtk.Template.Child("storage_bar") _radio_group = Gtk.Template.Child("radio_group") _freq_row = Gtk.Template.Child("freq_row") _bw_row = Gtk.Template.Child("bw_row") _sf_row = Gtk.Template.Child("sf_row") _cr_row = Gtk.Template.Child("cr_row") _tx_row = Gtk.Template.Child("tx_row") _path_hash_row = Gtk.Template.Child("path_hash_row") _stats_group = Gtk.Template.Child("stats_group") _uptime_row = Gtk.Template.Child("uptime_row") _queue_row = Gtk.Template.Child("queue_row") _noise_row = Gtk.Template.Child("noise_row") _noise_chart_btn = Gtk.Template.Child("noise_chart_btn") _rssi_row = Gtk.Template.Child("rssi_row") _rssi_chart_btn = Gtk.Template.Child("rssi_chart_btn") _snr_row = Gtk.Template.Child("snr_row") _snr_chart_btn = Gtk.Template.Child("snr_chart_btn") _airtime_row = Gtk.Template.Child("airtime_row") _pkt_row = Gtk.Template.Child("pkt_row") _pkt_flood_row = Gtk.Template.Child("pkt_flood_row") _pkt_direct_row = Gtk.Template.Child("pkt_direct_row") _refresh_stats_btn = Gtk.Template.Child("refresh_stats_btn") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._location_lat = None self._location_lon = None self._location_pending_lat = None self._location_pending_lon = None self._gps_enabled = None # None = unknown, True/False from custom_vars self._section_widgets = [ ("Connection", self._status_group), ("Device Information", self._info_group), ("Battery & Storage", self._batt_group), ("Radio Configuration", self._radio_group), ("Statistics", self._stats_group), ] self._latest_fw_version = None self._repeat_badge = Gtk.Label(label=_("Repeat Mode")) self._repeat_badge.add_css_class("status-badge") self._repeat_badge.set_valign(Gtk.Align.CENTER) self._repeat_badge.set_visible(False) self._freq_row.add_suffix(self._repeat_badge) # Connect signals that reference self._window self._copy_key_btn.connect("clicked", self._on_copy_public_key) self._telemetry_btn.connect("clicked", self._on_request_telemetry) self._location_map_btn.connect("clicked", self._on_show_location_map) self._fw_update_btn.connect("clicked", self._on_fw_update_clicked) self._refresh_stats_btn.connect("clicked", lambda *_: self._window.refresh_stats()) for btn, key, title, unit in [ (self._noise_chart_btn, "noise_floor", _("Noise Floor"), "dBm"), (self._rssi_chart_btn, "last_rssi", _("Last RSSI"), "dBm"), (self._snr_chart_btn, "last_snr", _("Last SNR"), "dB"), ]: btn.set_visible(False) btn.connect("clicked", self._on_stats_chart, key, title, unit) def _build_actions_group(self) -> Adw.PreferencesGroup: """Build the Actions preference group from UI template.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/device-actions.ui" ) actions_group = builder.get_object("actions_group") builder.get_object("zero_hop_btn").connect( "clicked", lambda *_: self._window.send_advert(flood=False) ) builder.get_object("flood_btn").connect( "clicked", lambda *_: self._window.send_advert(flood=True) ) builder.get_object("clipboard_btn").connect("clicked", self._on_advert_to_clipboard) builder.get_object("qr_btn").connect("clicked", self._on_show_qr_code) builder.get_object("trace_btn").connect("clicked", self._on_trace_path) builder.get_object("discover_btn").connect("clicked", self._on_discover_nearby) builder.get_object("rx_log_btn").connect("clicked", self._on_rx_log) builder.get_object("reboot_btn").connect("clicked", self._on_reboot) return actions_group def build_sidebar_actions(self) -> Gtk.Widget: """Build a fresh Actions group for placement in the sidebar.""" return self._build_actions_group() @property def sections(self) -> list[str]: return [name for name, _w in self._section_widgets] def scroll_to_section(self, section_name: str): for name, widget in self._section_widgets: if name == section_name: widget.grab_focus() # Scroll the widget into view GLib.idle_add(self._do_scroll_to, widget) break def _do_scroll_to(self, widget): # Get widget position relative to the scrolled window success, x, y = widget.translate_coordinates(self._scroll.get_child(), 0, 0) if success: adj = self._scroll.get_vadjustment() adj.set_value(y) return False def _on_copy_public_key(self, *args): key = self._key_row.get_title() if key and key != "--": clipboard = self._window.get_clipboard() clipboard.set(key) self._window.show_toast(_("Public key copied")) def _on_advert_to_clipboard(self, *args): """Export self contact and copy to clipboard.""" self._window.export_self_to_clipboard() def _on_show_qr_code(self, *args): """Show a QR code dialog with structured meshcore:// URL.""" info = self._window.device_info if not info.public_key: self._window.show_toast(_("Device public key not available")) return self._show_qr_dialog(None) def _show_qr_dialog(self, _unused=None): """Display a QR code dialog for sharing this device as a contact.""" import io try: import segno except ImportError: self._window.show_toast(_("QR code library not available")) return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/qr-code-dialog.ui" ) dialog = builder.get_object("dialog") name_label = builder.get_object("name_label") qr_picture = builder.get_object("qr_picture") # Device name info = self._window.device_info name = info.name or "My Device" name_label.set_label(name) # Build structured URL (accepted by official client) from urllib.parse import quote structured_uri = ( f"meshcore://contact/add?name={quote(name)}" f"&public_key={info.public_key}&type=1" ) qr = segno.make(structured_uri) png_buffer = io.BytesIO() qr.save(png_buffer, kind="png", scale=8, border=2) texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(png_buffer.getvalue())) qr_picture.set_paintable(texture) dialog.present(self._window) def _on_factory_reset(self, *args): dialog = Adw.AlertDialog( heading=_("Factory Reset?"), body=_( "This will permanently erase ALL data on the companion " "device, including contacts, messages, channels, settings, " "and the device identity (keys). " "This action cannot be undone." ), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("reset", _("Factory Reset")) dialog.set_response_appearance("reset", Adw.ResponseAppearance.DESTRUCTIVE) def _on_response(d, response): if response == "reset": self._window.factory_reset_device() dialog.connect("response", _on_response) dialog.present(self._window) def _on_reboot(self, *args): dialog = Adw.AlertDialog( heading=_("Reboot Device?"), body=_("The device will disconnect and restart."), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("reboot", _("Reboot")) dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE) def _on_response(d, response): if response == "reboot": self._window.reboot_device() dialog.connect("response", _on_response) dialog.present(self._window) def update_device_info(self, info: DeviceInfo): self._name_row.set_title(info.name or "--") self._board_row.set_title(info.model or "--") self._fw_row.set_title( f"{info.ver} ({info.fw_build})" if info.ver and info.fw_build else info.ver or "--" ) if info.ver: self._check_firmware_update(info.ver) if info.public_key: self._key_row.set_title(info.public_key) # Battery if info.battery_mv > 0: pct = info.battery_percent self._batt_row.set_title(f"{pct}% ({info.battery_mv} mV)") self._batt_bar.set_value(pct) # Storage if info.storage_total_kb > 0: spct = info.storage_percent self._storage_row.set_title( f"{spct}% ({info.storage_used_kb} / {info.storage_total_kb} KB)" ) self._storage_bar.set_value(spct) # Radio - radio_freq is in MHz, radio_bw is in kHz self._repeat_badge.set_visible(info.repeat) if info.radio_freq: self._freq_row.set_title(f"{format_number(info.radio_freq, 3)} MHz") if info.radio_bw: self._bw_row.set_title(f"{format_number(info.radio_bw)} kHz") if info.radio_sf: self._sf_row.set_title(f"SF{info.radio_sf}") if info.radio_cr: self._cr_row.set_title(f"4/{info.radio_cr}") if info.tx_power: self._tx_row.set_title(f"{info.tx_power} dBm") hash_labels = { 0: _("1-byte (max 64 hops)"), 1: _("2-byte (max 32 hops)"), 2: _("3-byte (max 21 hops)"), } self._path_hash_row.set_title(hash_labels.get(info.path_hash_mode, "--")) def update_location(self, lat: float, lon: float): """Update the location row from the device's advertised position.""" if self._gps_enabled is None: # Custom vars not yet received — store for later self._location_pending_lat = lat self._location_pending_lon = lon return self._apply_location(lat, lon) def update_gps_enabled(self, enabled: bool): """Update GPS enabled state from custom_vars.""" self._gps_enabled = enabled # Apply pending location now that we know GPS state if self._location_pending_lat is not None: self._apply_location(self._location_pending_lat, self._location_pending_lon) self._location_pending_lat = None self._location_pending_lon = None else: self._update_location_title() def _apply_location(self, lat: float, lon: float): has_pos = abs(lat) > 1e-6 or abs(lon) > 1e-6 if has_pos: self._location_lat = lat self._location_lon = lon self._location_row.set_title(self._format_location(lat, lon)) else: self._location_lat = None self._location_lon = None self._location_row.set_title(_("Not set")) self._location_map_btn.set_visible(has_pos) self._update_location_title() def _update_location_title(self): has_pos = self._location_lat is not None if self._gps_enabled is None: # No GPS hardware on companion self._location_row.set_subtitle( _("Location (manually set)") if has_pos else _("Location") ) elif self._gps_enabled: self._location_row.set_subtitle( _("Location (GPS)") if has_pos else _("Location (GPS on, no fix)") ) else: self._location_row.set_subtitle( _("Location (manually set)") if has_pos else _("Location (GPS off)") ) @staticmethod def _format_location(lat: float, lon: float) -> str: lat_dir = "N" if lat >= 0 else "S" lon_dir = "E" if lon >= 0 else "W" return f"{abs(lat):.6f}° {lat_dir}, {abs(lon):.6f}° {lon_dir}" def _on_request_telemetry(self, btn): """Request telemetry from the connected companion device.""" from meshy.protocol import ( LPP_ALTITUDE, LPP_GPS, LPP_TEMPERATURE, LPP_VOLTAGE, parse_cayenne_lpp, ) pub_key_hex = self._window.device_info.public_key if not pub_key_hex: self._telemetry_row.set_title(_("Not connected")) return btn.set_sensitive(False) self._telemetry_row.set_title(_("Requesting…")) timeout_id = [None] def on_timeout(): btn.set_sensitive(True) self._telemetry_row.set_title(_("No response (timed out)")) timeout_id[0] = None return False def on_response(lpp_data): btn.set_sensitive(True) if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None if not lpp_data: self._telemetry_row.set_title(_("No telemetry data")) return entries = parse_cayenne_lpp(lpp_data) if not entries: self._telemetry_row.set_title(_("No telemetry data")) return # Build summary for the subtitle parts = [] for e in entries: if e["type"] == LPP_TEMPERATURE: parts.append(f"{format_number(e['value'])} °C") elif e["type"] == LPP_ALTITUDE: parts.append(f"{e['value']} m alt") elif e["type"] == LPP_VOLTAGE: parts.append(f"{format_number(e['value'], 2)} V") elif e["type"] == LPP_GPS: val = e["value"] parts.append(f"{format_number(val['alt'], 0)} m alt") self._telemetry_row.set_title(", ".join(parts) if parts else _("Telemetry received")) timeout_id[0] = GLib.timeout_add(15000, on_timeout) self._window.get_self_telemetry(on_response) def _on_show_location_map(self, btn): if self._location_lat is None or self._location_lon is None: return lat, lon = self._location_lat, self._location_lon name = self._window.device_info.name or "My Device" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/location-dialog.ui" ) dialog = builder.get_object("dialog") coords_label = builder.get_object("coords_label") map_frame = builder.get_object("map_frame") coords_label.set_label(self._format_location(lat, lon)) # Map from meshy.views import create_shumate_map, deregister_map_widget from meshy.views.map_view import _SELF_COLOR, MapView map_widget, marker_layer, viewport = create_shumate_map(zoom_level=14, lat=lat, lon=lon) map_widget.set_size_request(-1, 350) marker = MapView.create_marker(*_SELF_COLOR, "avatar-default-symbolic", name, lat, lon) marker_layer.add_marker(marker) map_frame.set_child(map_widget) dialog.connect("closed", lambda _d: deregister_map_widget(map_widget)) dialog.present(self._window) def update_stats(self, stats: dict): """Update statistics display from CMD_GET_STATS responses.""" from meshy.protocol import STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO core = stats.get(STATS_TYPE_CORE, {}) if core: secs = core.get("uptime_secs", 0) days, rem = divmod(secs, 86400) hours, rem = divmod(rem, 3600) mins, _secs = divmod(rem, 60) parts = [] if days: parts.append(_("{days}d").format(days=days)) if hours: parts.append(_("{hours}h").format(hours=hours)) parts.append(_("{mins}m").format(mins=mins)) self._uptime_row.set_title(" ".join(parts)) self._queue_row.set_title(_("{} messages").format(core.get("queue_len", 0))) radio = stats.get(STATS_TYPE_RADIO, {}) if radio: self._noise_row.set_title(f'{radio.get("noise_floor", 0)} dBm') self._rssi_row.set_title(f'{radio.get("last_rssi", 0)} dBm') self._snr_row.set_title(f'{format_number(radio.get("last_snr", 0))} dB') tx_s = radio.get("tx_air_secs", 0) rx_s = radio.get("rx_air_secs", 0) self._airtime_row.set_title(f"TX: {tx_s}s / RX: {rx_s}s") self._noise_chart_btn.set_visible(True) self._rssi_chart_btn.set_visible(True) self._snr_chart_btn.set_visible(True) pkts = stats.get(STATS_TYPE_PACKETS, {}) if pkts: recv = pkts.get("recv", 0) sent = pkts.get("sent", 0) errs = pkts.get("recv_errors") sub = f"RX: {recv} / TX: {sent}" if errs is not None: sub += " / " + _("{count} errors").format(count=errs) self._pkt_row.set_title(sub) self._pkt_flood_row.set_title( f'TX: {pkts.get("flood_tx", 0)} / RX: {pkts.get("flood_rx", 0)}' ) self._pkt_direct_row.set_title( f'TX: {pkts.get("direct_tx", 0)} / RX: {pkts.get("direct_rx", 0)}' ) def update_connection_status(self, connected: bool, transport: str = ""): """Update the connection status icon and text.""" if connected: transport_labels = {"ble": "Bluetooth", "usb": "USB", "tcp": "TCP"} label = transport_labels.get(transport, "") if label: self._status_row.set_title("{} \u00b7 {}".format(label, _("Connected"))) else: self._status_row.set_title(_("Connected")) self._status_icon.set_from_icon_name("charge-symbolic") else: self._status_row.set_title(_("Disconnected")) self._status_icon.set_from_icon_name("network-offline-symbolic") def _on_trace_path(self, *args): """Open the Trace Path dialog.""" from meshy.models import ContactType # Trace protocol only supports 1B and 2B hashes (power-of-2 encoding) if self._window.device_info.path_hash_mode > 1: self._window.show_toast(_("Trace Path is not supported with 3-byte path hashes")) return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/trace-path-dialog.ui" ) dialog = builder.get_object("trace_path_dialog") path_group = builder.get_object("path_group") path_entry = builder.get_object("path_entry") map_row = builder.get_object("map_row") run_btn = builder.get_object("run_btn") status_box = builder.get_object("status_box") spinner = builder.get_object("spinner") status_label = builder.get_object("status_label") results_group = builder.get_object("results_group") results_frame = builder.get_object("results_frame") summary_label = builder.get_object("summary_label") status_map_btn = builder.get_object("status_map_btn") results_box = builder.get_object("results_box") # Path hash size: mode 0→1 byte, mode 1→2 bytes, mode 2→3 bytes hash_width = self._window.device_info.path_hash_mode + 1 examples = {1: _("e.g. aa,bb,cc"), 2: _("e.g. aabb,ccdd"), 3: _("e.g. aabbcc,ddeeff")} path_group.set_description(examples.get(hash_width, examples[1])) # Own node hash for bookending the trace self_pub_key = ( bytes.fromhex(self._window.device_info.public_key) if self._window.device_info.public_key else b"" ) self_hash = self_pub_key[:hash_width] if len(self_pub_key) >= hash_width else b"" self_name = self._window.device_info.name or "This device" all_contacts = {c.public_key_hex: c for c in self._window.contacts} for c in self._window.discovered_contacts: all_contacts.setdefault(c.public_key_hex, c) all_contacts_list = list(all_contacts.values()) repeaters = [ c for c in all_contacts_list if c.type in (ContactType.REPEATER, ContactType.ROOM) ] has_located = any(c.has_location for c in repeaters) # Contact suggestion popover suggestion_popover = Gtk.Popover() suggestion_popover.set_parent(path_entry) suggestion_popover.set_autohide(False) suggestion_popover.set_has_arrow(False) pop_scroll = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, max_content_height=200, propagate_natural_height=True, width_request=380, ) contact_list = Gtk.ListBox() contact_list.set_selection_mode(Gtk.SelectionMode.NONE) contact_rows = [] for contact in repeaters: hop_hex = contact.public_key[:hash_width].hex() row = Adw.ActionRow( title=GLib.markup_escape_text(contact.name), subtitle=hop_hex, activatable=True, ) row.add_prefix(Gtk.Image(icon_name="avatar-default-symbolic")) row._hop_hex = hop_hex row._search_text = contact.name.lower() contact_list.append(row) contact_rows.append(row) pop_scroll.set_child(contact_list) suggestion_popover.set_child(pop_scroll) suppress_popup = [False] def on_contact_activated(lb, row): suppress_popup[0] = True text = path_entry.get_text().strip() if not text or text.endswith(","): path_entry.set_text(f"{text}{row._hop_hex},") else: prefix = text.rsplit(",", 1)[0] if "," in text else "" path_entry.set_text(f"{prefix},{row._hop_hex}," if prefix else f"{row._hop_hex},") path_entry.set_position(-1) suggestion_popover.popdown() path_entry.grab_focus() suppress_popup[0] = False contact_list.connect("row-activated", on_contact_activated) def on_path_changed(*args): if suppress_popup[0]: return text = path_entry.get_text() last_seg = text.rsplit(",", 1)[-1].strip().lower() if text else "" if not last_seg or not repeaters: suggestion_popover.popdown() return any_visible = False for row in contact_rows: visible = last_seg in row._search_text or last_seg in row._hop_hex row.set_visible(visible) if visible: any_visible = True if any_visible: suggestion_popover.popup() else: suggestion_popover.popdown() path_entry.connect("changed", on_path_changed) map_row.set_sensitive(has_located) if not has_located: map_row.set_tooltip_text(_("No repeaters with known location")) def on_pick_map(*args): from meshy.views.map_view import open_trace_picker_map def _on_map_done(hex_string): current = path_entry.get_text().strip() if current: path_entry.set_text(f"{current},{hex_string}") else: path_entry.set_text(hex_string) open_trace_picker_map(self._window, all_contacts_list, hash_width, _on_map_done) map_row.connect("activated", on_pick_map) timeout_id = [None] trace_start = [0.0] def clear_results(): clear_children(results_box) def add_node_row(label): """Add a node row to results.""" row = Gtk.Label(label=label, xalign=0) row.set_margin_start(16) row.set_margin_top(6) row.set_margin_bottom(2) results_box.append(row) def add_snr_row(snr): """Add an SNR indicator row between nodes.""" snr_text = f"\u2502 {format_number(snr, sign=True)} dB" label = Gtk.Label(label=snr_text, xalign=0) label.set_margin_start(20) label.set_margin_top(2) label.set_margin_bottom(2) label.add_css_class("caption") if snr >= 0: label.add_css_class("success") elif snr >= -5: label.add_css_class("dim-label") else: label.add_css_class("error") results_box.append(label) def show_results(result): """Display trace results in the results box.""" spinner.set_spinning(False) spinner.set_visible(False) if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None hops = result["path_hops"] # list of bytes objects (per-hop hashes) snr_values = result["snr_values"] clear_results() import time duration = time.monotonic() - trace_start[0] summary_label.set_label( _("Trace complete: {} hops, {}").format( len(hops), format_trip_time(int(duration * 1000)) ) ) status_box.set_visible(False) results_frame.set_visible(True) results_group.set_title(_("Result")) hop_hexes = [h.hex() for h in hops] hop_contacts = self._window.resolve_hop_prefixes(hop_hexes) # Start with companion node add_node_row(f"\u25cf {self_name} ({self_hash.hex()})") for i, hop_bytes in enumerate(hops): # SNR between previous node and this one if i < len(snr_values): add_snr_row(snr_values[i]) hop_hex = hop_bytes.hex() contact = hop_contacts[i] if contact: add_node_row(f"\u25cf {contact.name} ({hop_hex})") else: add_node_row(f"\u25cb {hop_hex}") # End with companion node (return path) if len(snr_values) > len(hops): add_snr_row(snr_values[len(hops)]) add_node_row(f"\u25cf {self_name} ({self_hash.hex()})") # Show on Map button in status bar if any hop has a known location has_any_location = any(c and c.has_location for c in hop_contacts) if has_any_location: status_map_btn.set_visible(True) # Reconnect handler for new results with contextlib.suppress(AttributeError, TypeError): status_map_btn.disconnect_by_func(status_map_btn._handler) def _on_map_clicked( *_, hc=hop_contacts, snr=snr_values, hw=hash_width, hp=hop_hexes ): self._show_trace_on_map(hc, snr, hw, hp) status_map_btn._handler = _on_map_clicked status_map_btn.connect("clicked", _on_map_clicked) else: status_map_btn.set_visible(False) run_btn.set_sensitive(True) def on_timeout(): spinner.set_spinning(False) spinner.set_visible(False) status_label.set_label(_("Trace timed out")) self._window.stop_trace_path() run_btn.set_sensitive(True) timeout_id[0] = None return False def on_run(*args): text = path_entry.get_text().strip() if not text: return parts = [p.strip() for p in text.split(",") if p.strip()] try: path_bytes = b"" for p in parts: path_bytes += bytes.fromhex(p) except ValueError: status_label.set_label(_("Invalid hex in path")) return trace_bytes = path_bytes clear_results() results_frame.set_visible(False) results_group.set_title("") status_box.set_visible(True) spinner.set_visible(True) spinner.set_spinning(True) status_label.set_label(_("Tracing...")) status_map_btn.set_visible(False) run_btn.set_sensitive(False) import time trace_start[0] = time.monotonic() def on_device_timeout(timeout_ms): """Device told us how long to wait.""" if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = GLib.timeout_add(timeout_ms, on_timeout) # Start with a fallback timeout; device may override with its own timeout_id[0] = GLib.timeout_add(30000, on_timeout) # Flag encodes hash size: mode 0→flag 0 (1B), mode 1→flag 1 (2B) trace_flags = self._window.device_info.path_hash_mode self._window.send_trace_path( trace_bytes, show_results, on_device_timeout, flags=trace_flags ) run_btn.connect("clicked", on_run) def on_dialog_closed(d): self._window.stop_trace_path() if timeout_id[0]: GLib.source_remove(timeout_id[0]) suggestion_popover.unparent() dialog.connect("closed", on_dialog_closed) dialog.present(self._window) def _show_trace_on_map(self, hop_contacts, snr_values, hash_width, hop_prefixes=None): """Show trace path results in a map dialog.""" from meshy.views.map_view import ( _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, _UNKNOWN_COLOR, MapView, companion_too_far, ) device = self._window.device_info self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) # Build full hop list: (lat, lon, contact, prefix, snr) raw_hops = [] for i, contact in enumerate(hop_contacts): snr = snr_values[i] if i < len(snr_values) else None prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None if contact and contact.has_location: raw_hops.append((contact.latitude, contact.longitude, contact, prefix, snr)) else: raw_hops.append((None, None, contact, prefix, snr)) if has_self and not companion_too_far(self_lat, self_lon, raw_hops): raw_hops.insert(0, (self_lat, self_lon, None, None, None)) hops = MapView.interpolate_unknown_hops(raw_hops) if not any(h[0] is not None for h in hops): return dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( self._window, title=_("Trace Path") ) map_widget.get_map().remove_layer(marker_layer) path_layer = MapView.create_dashed_path_layer(viewport) map_widget.get_map().add_layer(path_layer) map_widget.get_map().add_layer(marker_layer) lats, lons = [], [] hop_num = 0 for lat, lon, contact, prefix, snr in hops: if lat is None: continue node = Shumate.Marker() node.set_location(lat, lon) path_layer.add_node(node) if contact is None and prefix is None: r, g, b = _SELF_COLOR icon_name = "network-wireless-symbolic" name = device.name or _("My Device") type_label = None badge = "" elif contact is None or not contact.has_location: r, g, b = _UNKNOWN_COLOR icon_name = "repeater-symbolic" name = prefix or _("Unknown") type_label = _("Unknown repeater") hop_num += 1 badge = str(hop_num) else: r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") name = contact.name type_label = contact.type_label hop_num += 1 badge = str(hop_num) is_interpolated = (contact is None or not contact.has_location) and prefix is not None detail_cb = (lambda c=contact: self._window.show_contact_detail(c)) if contact else None widget = MapView._build_marker_widget(r, g, b, icon_name, "", badge_text=badge) widget.set_tooltip_text(name) click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, w=widget, nm=name, la=lat, lo=lon, s=snr, t=type_label, ip=is_interpolated, od=detail_cb: MapView.show_marker_popover( w, nm, la, lo, s, t, interpolated=ip, on_detail=od ), ) widget.add_controller(click) hop_marker = Shumate.Marker() hop_marker.set_child(widget) hop_marker.set_location(lat, lon) marker_layer.add_marker(hop_marker) lats.append(lat) lons.append(lon) # SNR labels at midpoints for i in range(1, len(hops)): if hops[i][0] is None or hops[i - 1][0] is None: continue snr = hops[i][4] if snr is None: continue mid_lat = (hops[i - 1][0] + hops[i][0]) / 2 mid_lon = (hops[i - 1][1] + hops[i][1]) / 2 snr_widget = MapView.build_snr_widget(snr) snr_marker = Shumate.Marker() snr_marker.set_child(snr_widget) snr_marker.set_location(mid_lat, mid_lon) marker_layer.add_marker(snr_marker) MapView.fit_viewport(viewport, lats, lons) dialog.present(self._window) def _on_discover_nearby(self, *args): """Send a NODE_DISCOVER_REQ and show responding nodes live.""" from meshy.models import ContactType builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/discover-nearby-dialog.ui" ) dialog = builder.get_object("dialog") spinner = builder.get_object("spinner") status_label = builder.get_object("status_label") list_box = builder.get_object("list_box") placeholder = builder.get_object("placeholder") status_label.set_label(_("Scanning... {}s remaining").format(30)) list_box.set_placeholder(placeholder) seen = set() seconds_left = [30] timer_id = [None] type_icons = { ContactType.CHAT: "avatar-default-symbolic", ContactType.REPEATER: "repeater-symbolic", ContactType.ROOM: "user-home-symbolic", ContactType.SENSOR: "weather-clear-symbolic", } type_labels = { ContactType.CHAT: _("Chat"), ContactType.REPEATER: _("Repeater"), ContactType.ROOM: _("Room Server"), ContactType.SENSOR: _("Sensor"), } # Track rows by key prefix so adverts can update discover rows rows_by_prefix = {} # key_prefix → row # Companion location for distance calculation dev = self._window.device_info self_lat = dev.latitude if dev else None self_lon = dev.longitude if dev else None has_self_loc = ( self_lat is not None and self_lon is not None and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) ) log.debug("Discover: companion loc=%s/%s has_self_loc=%s", self_lat, self_lon, has_self_loc) def _distance_str(contact): if not has_self_loc or not contact or not contact.has_location: return "" from meshy.utils import haversine_distance dist = haversine_distance(self_lat, self_lon, contact.latitude, contact.longitude) if dist < 1.0: return f"{format_number(dist * 1000, 0)} m" return f"{format_number(dist)} km" def _add_node_button(row): """Create an Add to Contacts button for unknown nodes.""" btn = Gtk.Button(icon_name="list-add-symbolic", valign=Gtk.Align.CENTER) btn.set_tooltip_text(_("Add to Contacts")) def on_add(_btn): info = row._node_info self._window.add_contact(info["pub_key"], info["type"], info["name"]) _btn.set_sensitive(False) _btn.set_icon_name("object-select-symbolic") self._window.show_toast(_("Added {}").format(info["name"])) btn.connect("clicked", on_add) row.add_suffix(btn) def on_discover_response(result): """Handle a NODE_DISCOVER_RESP.""" pub_key_hex = result.get("pub_key_hex", "") if not pub_key_hex: return if pub_key_hex in seen: return seen.add(pub_key_hex) snr = result.get("snr", 0) node_type = result.get("node_type", 1) snr_str = f"SNR: {format_number(snr)} dB" contact = self._window.find_contact_by_key_prefix(pub_key_hex) is_known = contact is not None and self._window.is_contact_known(contact.public_key_hex) if contact: name = contact.name label = f"{contact.type_label} \u00b7 {snr_str}" icon = type_icons.get(contact.type, "avatar-default-symbolic") log.debug( "Discover resp: %s found has_loc=%s", pub_key_hex[:16], contact.has_location ) else: name = f"0x{pub_key_hex[:16].upper()}" label = "{} \u00b7 {}".format(type_labels.get(node_type, _("Node")), snr_str) icon = type_icons.get(node_type, "repeater-symbolic") log.debug("Discover resp: %s not found", pub_key_hex[:16]) dist = _distance_str(contact) if dist: label += f" \u00b7 {dist}" row = Adw.ActionRow(title=name, subtitle=label) row.add_prefix(Gtk.Image.new_from_icon_name(icon)) is_discovered = contact is not None and not is_known if is_discovered: row._node_info = {"pub_key": contact.public_key, "type": contact.type, "name": name} _add_node_button(row) rows_by_prefix[pub_key_hex] = row list_box.append(row) def on_advert(contact): """Also listen for adverts triggered by the discover.""" # Check if we already have a row for this node from discover response existing_row = None for prefix, row in rows_by_prefix.items(): if contact.public_key_hex.startswith(prefix) or prefix.startswith( contact.public_key_hex[:16] ): existing_row = row break if existing_row: # Update existing row with advert data (name, distance) adv_name = contact.name or existing_row.get_title() existing_row.set_title(adv_name) dist = _distance_str(contact) if dist: cur_sub = existing_row.get_subtitle() or "" if "km" not in cur_sub and " m" not in cur_sub: existing_row.set_subtitle(f"{cur_sub} · {dist}") is_known = self._window.is_contact_known(contact.public_key_hex) if hasattr(existing_row, "_node_info"): existing_row._node_info["name"] = adv_name existing_row._node_info["pub_key"] = contact.public_key existing_row._node_info["type"] = contact.type elif not is_known: existing_row._node_info = { "pub_key": contact.public_key, "type": contact.type, "name": adv_name, } _add_node_button(existing_row) seen.add(contact.public_key_hex) return if contact.public_key_hex in seen: return seen.add(contact.public_key_hex) icon = type_icons.get(contact.type, "avatar-default-symbolic") is_known = self._window.is_contact_known(contact.public_key_hex) log.debug( "Discover advert: %s has_loc=%s", contact.public_key_hex[:16], contact.has_location ) subtitle = contact.type_label dist = _distance_str(contact) if dist: subtitle += f" · {dist}" row = Adw.ActionRow( title=contact.name or _("Unknown"), subtitle=subtitle, ) row.add_prefix(Gtk.Image.new_from_icon_name(icon)) if not is_known: row._node_info = { "pub_key": contact.public_key, "type": contact.type, "name": contact.name or _("Unknown"), } _add_node_button(row) rows_by_prefix[contact.public_key_hex] = row list_box.append(row) def on_tick(): seconds_left[0] -= 1 if seconds_left[0] <= 0: stop_scanning() return False status_label.set_label(_("Scanning... {}s remaining").format(seconds_left[0])) return True def stop_scanning(): self._window.stop_discover() self._window.set_advert_listener(None) spinner.set_spinning(False) count = len(seen) status_label.set_label( _("Done. {} nodes found.").format(count) if count != 1 else _("Done. {} node found.").format(count) ) if timer_id[0]: GLib.source_remove(timer_id[0]) timer_id[0] = None def on_dialog_closed(d): stop_scanning() dialog.connect("closed", on_dialog_closed) # Start scanning - send both discover req and zero-hop advert self._window.set_advert_listener(on_advert) self._window.send_discover_req(on_discover_response) # Also send a zero-hop advert - this is the proven method # that triggers nearby nodes to respond with their adverts GLib.timeout_add(200, lambda: self._window.send_advert(flood=False, silent=True) or False) timer_id[0] = GLib.timeout_add_seconds(1, on_tick) dialog.present(self._window) def _on_stats_chart(self, _btn, stats_key, title, unit): """Open a live chart dialog for a radio statistic (noise floor, RSSI, SNR).""" from meshy.protocol import STATS_TYPE_RADIO, build_get_stats MAX_READINGS = 60 readings = [] timer_id = [None] is_float = stats_key == "last_snr" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/stats-chart-dialog.ui" ) dialog = builder.get_object("stats_chart_dialog") dialog.set_title(title) value_label = builder.get_object("value_label") drawing_area = builder.get_object("drawing_area") caption = builder.get_object("caption_label") caption.set_label(_("{unit} over last 2 minutes").format(unit=unit)) def _draw(_area, cr, width, height): if len(readings) < 2: return y_min = min(readings) - 5 y_max = max(readings) + 5 if y_max - y_min < 10: mid = (y_max + y_min) / 2 y_min = mid - 5 y_max = mid + 5 y_range = y_max - y_min pad_left = 48 pad_right = 8 pad_top = 8 pad_bottom = 20 gw = width - pad_left - pad_right gh = height - pad_top - pad_bottom cr.set_line_width(0.5) n_grid = 4 for i in range(n_grid + 1): y = pad_top + gh * i / n_grid db_val = y_max - (y_max - y_min) * i / n_grid cr.set_source_rgba(0.5, 0.5, 0.5, 0.4) cr.set_dash([4, 4], 0) cr.move_to(pad_left, y) cr.line_to(width - pad_right, y) cr.stroke() cr.set_dash([], 0) cr.set_source_rgba(0.5, 0.5, 0.5, 0.8) cr.set_font_size(10) cr.move_to(4, y + 4) cr.show_text(format_number(db_val, 0)) n_points = len(readings) for i in range(0, n_points, max(1, n_points // 4)): x = pad_left + gw * i / (n_points - 1) secs = (n_points - 1 - i) * 2 cr.set_source_rgba(0.5, 0.5, 0.5, 0.8) cr.set_font_size(10) cr.move_to(x - 8, height - 2) cr.show_text(f"-{secs}s") points = [] for i, val in enumerate(readings): x = pad_left + gw * i / (n_points - 1) y = pad_top + gh * (1.0 - (val - y_min) / y_range) points.append((x, y)) cr.move_to(points[0][0], points[0][1]) for x, y in points[1:]: cr.line_to(x, y) cr.line_to(points[-1][0], pad_top + gh) cr.line_to(points[0][0], pad_top + gh) cr.close_path() color = drawing_area.get_style_context().lookup_color("accent_color") if color[0]: c = color[1] cr.set_source_rgba(c.red, c.green, c.blue, 0.3) else: cr.set_source_rgba(0.2, 0.5, 0.9, 0.3) cr.fill() cr.set_line_width(2) cr.move_to(points[0][0], points[0][1]) for x, y in points[1:]: cr.line_to(x, y) if color[0]: c = color[1] cr.set_source_rgba(c.red, c.green, c.blue, 1.0) else: cr.set_source_rgba(0.2, 0.5, 0.9, 1.0) cr.stroke() drawing_area.set_draw_func(_draw) def on_stats(radio_stats): val = radio_stats.get(stats_key, 0) readings.append(val) if len(readings) > MAX_READINGS: del readings[: len(readings) - MAX_READINGS] fmt = f"{format_number(val)} {unit}" if is_float else f"{val} {unit}" value_label.set_label(fmt) drawing_area.queue_draw() def poll(): if not self._window.is_connected: return False self._window.send_frame(build_get_stats(STATS_TYPE_RADIO)) return True self._window.set_radio_stats_callback(on_stats) poll() timer_id[0] = GLib.timeout_add(2000, poll) def on_closed(d): if timer_id[0]: GLib.source_remove(timer_id[0]) timer_id[0] = None self._window.set_radio_stats_callback(None) dialog.connect("closed", on_closed) dialog.present(self._window) def _on_rx_log(self, *args): """Open the Rx Log dialog showing received radio packets.""" builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/rx-log-dialog.ui") dialog = builder.get_object("rx_log_dialog") list_box = builder.get_object("rx_log_list") clear_btn = builder.get_object("clear_btn") placeholder = builder.get_object("rx_log_placeholder") list_box.set_placeholder(placeholder) sf = self._window.device_info.radio_sf or 12 def _make_row(entry): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box.set_margin_start(12) box.set_margin_end(12) box.set_margin_top(8) box.set_margin_bottom(8) header_box = Gtk.Box(spacing=8) title = Gtk.Label( label=f"{entry.route_type_name} {entry.payload_type_name}", xalign=0, hexpand=True ) title.add_css_class("heading") header_box.append(title) snr_icon = Gtk.Image.new_from_icon_name(snr_to_icon(entry.snr, sf)) header_box.append(snr_icon) snr_label = Gtk.Label(label=f"{format_number(entry.snr, 2)}dB", xalign=1) snr_label.add_css_class("dim-label") header_box.append(snr_label) box.append(header_box) def _detail(text, wrap=False): lbl = Gtk.Label(label=text, xalign=0) lbl.add_css_class("caption") lbl.add_css_class("dim-label") if wrap: lbl.set_wrap(True) lbl.set_wrap_mode(Pango.WrapMode.WORD_CHAR) box.append(lbl) _detail( f'{entry.timestamp.strftime("%H:%M:%S")} \u00b7 ' f'{_("Size")}: {entry.size} {_("bytes")}' ) if entry.pkt_hash: _detail(f"Hash: {entry.pkt_hash.upper()}") path_csv = ",".join(entry.path) if entry.path else "" _detail(f'{_("Path")}: {len(entry.path)} {_("hops")} ' f'[{path_csv}]', wrap=True) _detail(f'{_("Path Hashes")}: {entry.path_hash_size}-{_("byte per hop")}') if entry.transport_from and entry.transport_to: _detail(f"From: <{entry.transport_from}> " f"To: <{entry.transport_to}>") return box for entry in reversed(self._window.rx_log): list_box.append(_make_row(entry)) def on_new_entry(entry): list_box.prepend(_make_row(entry)) self._window.set_rx_log_callback(on_new_entry) def on_clear(*_): self._window.rx_log.clear() clear_children(list_box) clear_btn.connect("clicked", on_clear) def on_closed(d): self._window.set_rx_log_callback(None) dialog.connect("closed", on_closed) dialog.present(self._window) # ─── Firmware update check ────────────────────────────── @staticmethod def _parse_version(ver_str): ver = ver_str.strip().lstrip("v") ver = ver.split("-")[0] try: parts = [int(x) for x in ver.split(".")] while len(parts) < 3: parts.append(0) return tuple(parts) except (ValueError, AttributeError): return (0, 0, 0) def _check_firmware_update(self, current_ver): if self._latest_fw_version is not None: self._show_update_if_needed(current_ver, self._latest_fw_version) return def fetch(): try: req = urllib.request.Request(_RELEASES_URL, headers={"User-Agent": "Meshy"}) with urllib.request.urlopen(req, timeout=10) as resp: releases = json.loads(resp.read()) for entry in releases: if entry.get("type") == "companion": latest = entry["version"] GLib.idle_add(self._on_releases_fetched, current_ver, latest) return except Exception as e: log.debug("Firmware update check failed: %s", e) threading.Thread(target=fetch, daemon=True).start() def _on_releases_fetched(self, current_ver, latest_ver): self._latest_fw_version = latest_ver self._show_update_if_needed(current_ver, latest_ver) return False def _show_update_if_needed(self, current_ver, latest_ver): current = self._parse_version(current_ver) latest = self._parse_version(latest_ver) if latest > current: self._fw_update_btn.set_visible(True) self._fw_update_btn.set_tooltip_text( _("Update available: {version}").format(version=latest_ver) ) else: self._fw_update_btn.set_visible(False) def _on_fw_update_clicked(self, btn): launcher = Gtk.UriLauncher.new(_FLASHER_URL) launcher.launch(self._window, None, None) meshy/src/views/map_view.py000066400000000000000000001323671523412555500163330ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Map view - shows contacts with known locations on an OpenStreetMap.""" import math import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Shumate", "1.0") from gi.repository import Gdk, GLib, Gtk, Pango, Shumate from meshy.models import Contact, ContactType from meshy.utils import format_number # Marker colors by contact type (R, G, B) _MARKER_COLORS = { ContactType.CHAT: (0.26, 0.52, 0.96), # Blue ContactType.REPEATER: (0.30, 0.69, 0.31), # Green ContactType.ROOM: (0.61, 0.32, 0.88), # Purple ContactType.SENSOR: (1.0, 0.60, 0.0), # Orange } _MARKER_ICONS = { ContactType.CHAT: "avatar-default-symbolic", ContactType.REPEATER: "repeater-symbolic", ContactType.ROOM: "user-home-symbolic", ContactType.SENSOR: "weather-clear-symbolic", } # Self marker color (Red) - MeshCore companion GPS _SELF_COLOR = (0.90, 0.16, 0.22) # Unknown/interpolated hop color (grey) _UNKNOWN_COLOR = (0.55, 0.55, 0.55) # Cluster color (dark grey) _CLUSTER_COLOR = (0.35, 0.35, 0.40) # Route colors for multi-path map display (smart routing) _ROUTE_COLORS = [ (0.13, 0.59, 0.95), # Blue (0.96, 0.26, 0.21), # Red (0.30, 0.69, 0.31), # Green (0.98, 0.74, 0.02), # Amber (0.61, 0.15, 0.69), # Purple (1.00, 0.34, 0.13), # Deep Orange (0.00, 0.59, 0.53), # Teal (0.91, 0.12, 0.39), # Pink ] # Max distance (km) between companion and nearest hop before hiding companion _COMPANION_MAX_DISTANCE_KM = 30 # Minimum pixel distance between markers before clustering _CLUSTER_RADIUS_PX = 60 _MAX_LATITUDE = 85.0511287798 def companion_too_far(self_lat, self_lon, raw_hops): """Return True if the companion is > _COMPANION_MAX_DISTANCE_KM from every located hop.""" from meshy.utils import haversine_distance for lat, lon, _contact, _prefix, _snr in raw_hops: if lat is not None: if haversine_distance(self_lat, self_lon, lat, lon) <= _COMPANION_MAX_DISTANCE_KM: return False return True def _lat_lon_to_pixels(lat, lon, zoom): """Convert lat/lon to pixel coordinates at a given zoom level.""" lat = max(-_MAX_LATITUDE, min(_MAX_LATITUDE, lat)) n = 2.0**zoom x = (lon + 180.0) / 360.0 * n * 256 lat_rad = math.radians(lat) y = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n * 256 return x, y @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/map-view.ui") class MapView(Gtk.Box): """Full map widget showing contacts with known locations.""" __gtype_name__ = "MeshyMapView" _dot_user = Gtk.Template.Child("dot_user") _dot_repeater = Gtk.Template.Child("dot_repeater") _dot_room = Gtk.Template.Child("dot_room") _dot_sensor = Gtk.Template.Child("dot_sensor") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._located_contacts: list[Contact] = [] self._self_lat = None self._self_lon = None self._last_zoom = -1 self._zoom_handler_id = None self._recluster_timeout_id = None self._expanded_clusters: list[list[Contact]] = [] # clusters forced open self._all_contacts: list[Contact] = [] # all contacts with location (unfiltered) self._discovered_contacts: list[Contact] = [] self._show_discovered: bool = False self._trace_path_layer: Shumate.PathLayer | None = None self._trace_marker_layer: Shumate.MarkerLayer | None = None self._trace_anim_id: int | None = None self._visible_types: set[int] = { ContactType.CHAT, ContactType.REPEATER, ContactType.ROOM, ContactType.SENSOR, } self._setup_map() self._setup_legend_dots() def cleanup(self): """Break reference cycles so GC can collect this view and its map.""" self.clear_trace_path() if self._recluster_timeout_id: GLib.source_remove(self._recluster_timeout_id) self._recluster_timeout_id = None if self._zoom_handler_id is not None: self._map.get_viewport().disconnect(self._zoom_handler_id) self._zoom_handler_id = None self._marker_layer.remove_all() self._located_contacts.clear() self._all_contacts.clear() self._discovered_contacts.clear() self._expanded_clusters.clear() self._window = None def _setup_map(self): from meshy.views import create_shumate_map self._map, self._marker_layer, viewport = create_shumate_map(zoom_level=3) self._map.set_hexpand(True) # Listen for zoom changes to recluster self._zoom_handler_id = viewport.connect("notify::zoom-level", self._on_zoom_changed) self.prepend(self._map) def _setup_legend_dots(self): for dot, ct in [ (self._dot_user, ContactType.CHAT), (self._dot_repeater, ContactType.REPEATER), (self._dot_room, ContactType.ROOM), (self._dot_sensor, ContactType.SENSOR), ]: r, g, b = _MARKER_COLORS[ct] dot.set_draw_func( lambda area, cr, w, h, _r=r, _g=g, _b=b: ( cr.set_source_rgb(_r, _g, _b), cr.arc(w / 2, h / 2, min(w, h) / 2, 0, 6.2832), cr.fill(), ) ) def update_contacts(self, contacts: list[Contact]): """Refresh markers from the current contact list.""" self._all_contacts = [c for c in contacts if c.has_location] # Check self location device_info = self._window.device_info self._self_lat = getattr(device_info, "latitude", None) self._self_lon = getattr(device_info, "longitude", None) if self._self_lat and self._self_lon: if abs(self._self_lat) < 1e-6 and abs(self._self_lon) < 1e-6: self._self_lat = None self._self_lon = None self._apply_filters(fit_bounds=True) def update_discovered(self, contacts: list[Contact]): """Set the list of discovered contacts available for map display.""" for c in contacts: c.is_discovered = True self._discovered_contacts = [c for c in contacts if c.has_location] if self._show_discovered: self._apply_filters() def _apply_filters(self, fit_bounds=False): """Filter contacts by visible types and refresh the map.""" self._located_contacts = [c for c in self._all_contacts if c.type in self._visible_types] if self._show_discovered: contact_keys = {c.public_key_hex for c in self._located_contacts} self._located_contacts += [ c for c in self._discovered_contacts if c.type in self._visible_types and c.public_key_hex not in contact_keys ] self._expanded_clusters.clear() self.clear_trace_path() if fit_bounds and (self._located_contacts or (self._self_lat and self._self_lon)): self._fit_bounds() self._last_zoom = -1 # force recluster self._recluster() self._update_status_label() def _update_status_label(self): """Update the status label with contact counts.""" if hasattr(self, "_status_label"): visible = len(self._located_contacts) total = len(self._all_contacts) all_contacts = len(self._window.contacts) if self._window.contacts else 0 if self._show_discovered: disc_count = len( [c for c in self._discovered_contacts if c.type in self._visible_types] ) visible_contacts = visible - disc_count else: visible_contacts = visible if visible_contacts == total: text = _("{} of {} contacts on map").format(visible_contacts, all_contacts) else: text = _("{} of {} located on map").format(visible_contacts, total) if self._show_discovered: text += f"\n+ {disc_count} discovered" self._status_label.set_label(text) def build_sidebar_filters(self) -> Gtk.Widget: """Build the sidebar filter controls and contact count.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/map-sidebar-filters.ui" ) box = builder.get_object("sidebar_box") # Connect filter toggle signals for row_id, ct in [ ("users_row", ContactType.CHAT), ("repeaters_row", ContactType.REPEATER), ("rooms_row", ContactType.ROOM), ("sensors_row", ContactType.SENSOR), ]: row = builder.get_object(row_id) row.set_active(ct in self._visible_types) def _on_toggled(switch_row, pspec, contact_type=ct): if switch_row.get_active(): self._visible_types.add(contact_type) else: self._visible_types.discard(contact_type) self._apply_filters() row.connect("notify::active", _on_toggled) # Discovered nodes toggle disc_row = builder.get_object("disc_row") disc_row.set_active(self._show_discovered) def _on_disc_toggled(switch_row, pspec): self._show_discovered = switch_row.get_active() self._apply_filters() disc_row.connect("notify::active", _on_disc_toggled) self._status_label = builder.get_object("status_label") self._update_status_label() return box def _on_zoom_changed(self, viewport, pspec): """Debounce zoom changes to avoid reclustering on every frame.""" self._expanded_clusters.clear() if self._recluster_timeout_id: GLib.source_remove(self._recluster_timeout_id) self._recluster_timeout_id = GLib.timeout_add(150, self._recluster) def _recluster(self): """Recompute clusters at the current zoom level and rebuild markers.""" self._recluster_timeout_id = None viewport = self._map.get_viewport() zoom = viewport.get_zoom_level() _LABEL_ZOOM = 10 labels_changed = (self._last_zoom < _LABEL_ZOOM) != (zoom < _LABEL_ZOOM) if abs(zoom - self._last_zoom) < 0.3 and not self._expanded_clusters and not labels_changed: return False self._last_zoom = zoom self._marker_layer.remove_all() # Contacts that belong to expanded clusters get individual markers expanded_keys = set() for group in self._expanded_clusters: for c in group: expanded_keys.add(c.public_key_hex) # Cluster only the non-expanded contacts to_cluster = [c for c in self._located_contacts if c.public_key_hex not in expanded_keys] clusters = self._compute_clusters(to_cluster, zoom) for cluster in clusters: if len(cluster) == 1: marker = self._create_contact_marker(cluster[0]) else: marker = self._create_cluster_marker(cluster, self._zoom_or_expand_cluster) self._marker_layer.add_marker(marker) # Add expanded contacts as individual markers spread in a circle for group in self._expanded_clusters: avg_lat = sum(c.latitude for c in group) / len(group) sum(c.longitude for c in group) / len(group) for i, contact in enumerate(group): angle = 2 * math.pi * i / len(group) # Offset in degrees (~30m radius at equator, scales with zoom) offset = 0.0003 * (2 ** (15 - zoom)) offset = max(offset, 0.00005) # minimum offset dlat = offset * math.cos(angle) dlon = offset * math.sin(angle) / max(math.cos(math.radians(avg_lat)), 0.01) marker = self._create_contact_marker(contact) marker.set_location(contact.latitude + dlat, contact.longitude + dlon) self._marker_layer.add_marker(marker) # Add self marker (MeshCore device GPS) if self._self_lat and self._self_lon: self._marker_layer.add_marker(self._create_self_marker(self._self_lat, self._self_lon)) return False # don't repeat timeout @staticmethod def _compute_clusters(contacts: list[Contact], zoom: float) -> list[list[Contact]]: """Group contacts that are too close at the current zoom into clusters.""" if not contacts: return [] # Convert all contacts to pixel positions points = [] for c in contacts: px, py = _lat_lon_to_pixels(c.latitude, c.longitude, zoom) points.append((px, py, c)) clustered = [False] * len(points) clusters: list[list[Contact]] = [] radius_sq = _CLUSTER_RADIUS_PX**2 for i, (px, py, contact) in enumerate(points): if clustered[i]: continue group = [contact] clustered[i] = True # Find all unclustered points within radius for j in range(i + 1, len(points)): if clustered[j]: continue dx = points[j][0] - px dy = points[j][1] - py if dx * dx + dy * dy < radius_sq: group.append(points[j][2]) clustered[j] = True clusters.append(group) return clusters def _create_contact_marker(self, contact: Contact) -> Shumate.Marker: """Create a colored circle marker with icon for a contact.""" discovered = getattr(contact, "is_discovered", False) if discovered: r, g, b = _UNKNOWN_COLOR else: r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") zoom = self._map.get_viewport().get_zoom_level() widget = self._build_marker_widget( r, g, b, icon_name, contact.name, boxed_label=True, show_label=zoom >= 10 ) widget.set_tooltip_text( f"{contact.name}\n{contact.type_label}\n{contact.latitude:.5f}, {contact.longitude:.5f}" ) click = Gtk.GestureClick() if discovered: click.connect( "pressed", lambda g, n, x, y, w=widget, c=contact: MapView.show_marker_popover( w, c.name, c.latitude, c.longitude, contact_type=c.type_label ), ) else: click.connect("pressed", lambda g, n, x, y, c=contact: self._on_marker_clicked(c)) widget.add_controller(click) marker = Shumate.Marker() marker.set_child(widget) marker.set_location(contact.latitude, contact.longitude) return marker @staticmethod def _create_cluster_marker(contacts: list[Contact], on_cluster_click=None) -> Shumate.Marker: """Create a cluster marker showing the count of grouped contacts.""" avg_lat = sum(c.latitude for c in contacts) / len(contacts) avg_lon = sum(c.longitude for c in contacts) / len(contacts) count = len(contacts) size = min(48, 32 + count) r, g, b = _CLUSTER_COLOR box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0, halign=Gtk.Align.CENTER) circle = Gtk.DrawingArea() circle.set_content_width(size) circle.set_content_height(size) radius = size / 2 - 1 def draw_cluster(area, cr, w, h): cx, cy = w / 2, h / 2 cr.set_source_rgba(r, g, b, 0.3) cr.arc(cx, cy, radius, 0, 6.2832) cr.fill() cr.set_source_rgb(r, g, b) cr.arc(cx, cy, radius * 0.75, 0, 6.2832) cr.fill() cr.set_source_rgb(1, 1, 1) cr.set_line_width(2) cr.arc(cx, cy, radius * 0.75, 0, 6.2832) cr.stroke() circle.set_draw_func(draw_cluster) overlay = Gtk.Overlay() overlay.set_child(circle) count_label = Gtk.Label(label=str(count)) count_label.add_css_class("map-marker-icon") attrs = Pango.AttrList() attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD)) count_label.set_attributes(attrs) count_label.set_halign(Gtk.Align.CENTER) count_label.set_valign(Gtk.Align.CENTER) overlay.add_overlay(count_label) box.append(overlay) names = sorted(c.name for c in contacts) if len(names) > 8: tooltip = "\n".join(names[:8]) + "\n\u2026 " + _("and {} more").format(len(names) - 8) else: tooltip = "\n".join(names) box.set_tooltip_text(tooltip) if on_cluster_click: click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, cs=contacts, lat=avg_lat, lon=avg_lon: on_cluster_click( cs, lat, lon ), ) box.add_controller(click) marker = Shumate.Marker() marker.set_child(box) marker.set_location(avg_lat, avg_lon) return marker def _zoom_or_expand_cluster(self, contacts, lat, lon): """Zoom in to split a cluster, or expand it if zooming won't help.""" viewport = self._map.get_viewport() current_zoom = viewport.get_zoom_level() max_zoom = viewport.get_max_zoom_level() # Check if zooming in by 2 would split this cluster test_zoom = min(current_zoom + 2, max_zoom) test_clusters = self._compute_clusters(contacts, test_zoom) would_split = len(test_clusters) > 1 if would_split and current_zoom < max_zoom: # Zooming will help — zoom in viewport.set_zoom_level(test_zoom) viewport.set_location(lat, lon) else: # Zooming won't help — expand the cluster into individual markers self._expanded_clusters.append(contacts) self._last_zoom = -1 # force rebuild self._recluster() def _create_self_marker(self, lat: float, lon: float) -> Shumate.Marker: """Create the self/device location marker.""" r, g, b = _SELF_COLOR name = self._window.device_info.name or _("My Device") zoom = self._map.get_viewport().get_zoom_level() widget = self._build_marker_widget( r, g, b, "avatar-default-symbolic", name, boxed_label=True, show_label=zoom >= 10 ) widget.set_tooltip_text(f"{name}\n{lat:.5f}, {lon:.5f}") marker = Shumate.Marker() marker.set_child(widget) marker.set_location(lat, lon) return marker @staticmethod def _build_marker_widget( r, g, b, icon_name, label_text, badge_text="", boxed_label=False, show_label=True ): """Build a marker widget: colored circle with icon + name label below.""" box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1, halign=Gtk.Align.CENTER) circle = Gtk.DrawingArea() circle.set_content_width(32) circle.set_content_height(32) def draw_circle(area, cr, w, h): cr.set_source_rgb(r, g, b) cr.arc(w / 2, h / 2, 14, 0, 6.2832) cr.fill() cr.set_source_rgb(1, 1, 1) cr.set_line_width(2) cr.arc(w / 2, h / 2, 14, 0, 6.2832) cr.stroke() circle.set_draw_func(draw_circle) overlay = Gtk.Overlay() overlay.set_child(circle) icon = Gtk.Image.new_from_icon_name(icon_name) icon.set_pixel_size(16) icon.add_css_class("map-marker-icon") icon.set_halign(Gtk.Align.CENTER) icon.set_valign(Gtk.Align.CENTER) overlay.add_overlay(icon) if badge_text: badge = Gtk.Label(label=badge_text) badge.add_css_class("map-marker-badge") badge.set_halign(Gtk.Align.END) badge.set_valign(Gtk.Align.START) overlay.add_overlay(badge) box.append(overlay) if label_text and show_label: if boxed_label: name_widget = MapView.build_map_label_widget(label_text) name_widget.get_first_child().set_max_width_chars(12) name_widget.get_first_child().set_ellipsize(3) else: name_widget = Gtk.Label(label=label_text) name_widget.add_css_class("caption") name_widget.set_max_width_chars(12) name_widget.set_ellipsize(3) # PANGO_ELLIPSIZE_END box.append(name_widget) return box @staticmethod def create_marker( r, g, b, icon_name, label_text, lat, lon, boxed_label=True, show_label=True, badge_text="" ): """Create a positioned Shumate.Marker with standard styling.""" widget = MapView._build_marker_widget( r, g, b, icon_name, label_text, badge_text=badge_text, boxed_label=boxed_label, show_label=show_label, ) marker = Shumate.Marker() marker.set_child(widget) marker.set_location(lat, lon) return marker @staticmethod def create_dashed_path_layer(viewport, color_rgb=None, stroke_width=2): """Create a consistently styled dashed PathLayer for map overlays.""" layer = Shumate.PathLayer.new(viewport) layer.set_stroke_width(stroke_width) layer.set_dash([6, 4]) color = Gdk.RGBA() if color_rgb: r, g, b = color_rgb color.parse(f"rgba({int(r*255)}, {int(g*255)}, {int(b*255)}, 0.9)") else: color.parse("rgba(100, 100, 100, 0.9)") layer.set_stroke_color(color) return layer @staticmethod def build_map_label_widget(text): """Build a label with opaque background for map overlays.""" label = Gtk.Label(label=text) label.add_css_class("caption") label.set_margin_start(6) label.set_margin_end(6) label.set_margin_top(2) label.set_margin_bottom(2) box = Gtk.Box() box.append(label) box.add_css_class("map-overlay-box") return box @staticmethod def build_snr_widget(snr): """Build a styled SNR label with opaque background for map overlays.""" box = MapView.build_map_label_widget(f"{format_number(snr, sign=True)} dB") label = box.get_first_child() if snr >= 0: label.add_css_class("success") elif snr >= -5: label.add_css_class("dim-label") else: label.add_css_class("error") return box @staticmethod def show_marker_popover( widget, name, lat, lon, snr=None, contact_type=None, interpolated=False, on_detail=None ): """Show a detail popover anchored to a map marker widget.""" popover = Gtk.Popover() box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=4, margin_start=8, margin_end=8, margin_top=8, margin_bottom=8, ) if on_detail is not None: name_btn = Gtk.Button(label=name) name_btn.add_css_class("flat") name_btn.add_css_class("heading") name_btn.set_halign(Gtk.Align.START) name_btn.connect("clicked", lambda *_: (popover.popdown(), on_detail())) box.append(name_btn) else: name_label = Gtk.Label(label=name, xalign=0) name_label.add_css_class("heading") box.append(name_label) if contact_type is not None: type_label = Gtk.Label(label=contact_type, xalign=0) type_label.add_css_class("dim-label") box.append(type_label) if interpolated: note = Gtk.Label(label=_("Position is illustrative — real location unknown"), xalign=0) note.add_css_class("dim-label") box.append(note) else: coords = Gtk.Label(label=f"{lat:.5f}, {lon:.5f}", xalign=0) coords.add_css_class("dim-label") box.append(coords) if snr is not None: snr_text = Gtk.Label(label=_("SNR: {} dB").format(format_number(snr)), xalign=0) box.append(snr_text) popover.set_child(box) popover.set_parent(widget) popover.popup() def _on_marker_clicked(self, contact: Contact): """Navigate to the contact's detail view.""" self._window.show_contact_detail(contact) @staticmethod def interpolate_unknown_hops(hops): """Fill in coordinates for hops without a known location. Input/output: list of (lat_or_none, lon_or_none, contact_or_none, prefix_or_none, snr_or_none) Unknown hops between two located hops are linearly interpolated. Edge unknowns get a small offset from the nearest known hop. """ n = len(hops) if n == 0: return hops result = list(hops) # Find runs of unknown-location hops and interpolate i = 0 while i < n: if result[i][0] is not None: i += 1 continue # Found an unknown hop — find the run run_start = i while i < n and result[i][0] is None: i += 1 run_end = i # exclusive # Find the nearest known hops before and after prev_lat, prev_lon = None, None for j in range(run_start - 1, -1, -1): if result[j][0] is not None: prev_lat, prev_lon = result[j][0], result[j][1] break next_lat, next_lon = None, None for j in range(run_end, n): if result[j][0] is not None: next_lat, next_lon = result[j][0], result[j][1] break run_len = run_end - run_start if prev_lat is not None and next_lat is not None: # Interpolate between two known points for k, idx in enumerate(range(run_start, run_end)): frac = (k + 1) / (run_len + 1) lat = prev_lat + frac * (next_lat - prev_lat) lon = prev_lon + frac * (next_lon - prev_lon) result[idx] = (lat, lon) + result[idx][2:] elif prev_lat is not None: # Edge unknowns at the end — offset from previous for k, idx in enumerate(range(run_start, run_end)): lat = prev_lat + (k + 1) * 0.001 lon = prev_lon + (k + 1) * 0.001 result[idx] = (lat, lon) + result[idx][2:] elif next_lat is not None: # Edge unknowns at the start — offset from next for k, idx in enumerate(range(run_start, run_end)): lat = next_lat - (run_len - k) * 0.001 lon = next_lon - (run_len - k) * 0.001 result[idx] = (lat, lon) + result[idx][2:] return result def show_trace_path(self, hop_contacts, snr_values, hop_prefixes=None): """Draw a trace path on the map from trace results (with SNR).""" device = self._window.device_info self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) hops = [] for i, contact in enumerate(hop_contacts): snr = snr_values[i] if i < len(snr_values) else None prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None if contact and contact.has_location: hops.append((contact.latitude, contact.longitude, contact, prefix, snr)) else: hops.append((None, None, contact, prefix, snr)) if has_self and not companion_too_far(self_lat, self_lon, hops): hops.insert(0, (self_lat, self_lon, None, None, None)) hops = self.interpolate_unknown_hops(hops) self._draw_path_on_map(hops) def _draw_path_on_map(self, hops): """Draw a path overlay: polyline + circle markers + SNR at midpoints. hops: list of (lat, lon, contact_or_none, prefix_or_none, snr_or_none) contact=None + prefix=None means self/device node. contact=None + prefix!=None means unknown/interpolated hop. """ self.clear_trace_path() if not hops: return viewport = self._map.get_viewport() # Create path layer for the line self._trace_path_layer = MapView.create_dashed_path_layer(viewport) self._map.get_map().add_layer(self._trace_path_layer) # Create marker layer self._trace_marker_layer = Shumate.MarkerLayer.new(viewport) self._map.get_map().add_layer(self._trace_marker_layer) # Add path nodes and markers lats, lons = [], [] hop_num = 0 for lat, lon, contact, prefix, snr in hops: node = Shumate.Marker() node.set_location(lat, lon) self._trace_path_layer.add_node(node) if contact is None and prefix is None: # Self/device marker r, g, b = _SELF_COLOR icon_name = "network-wireless-symbolic" name = self._window.device_info.name or _("My Device") badge = "" elif contact is None: # Unknown/interpolated hop r, g, b = _UNKNOWN_COLOR icon_name = "repeater-symbolic" name = prefix hop_num += 1 badge = str(hop_num) else: r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") name = contact.name hop_num += 1 badge = str(hop_num) widget = self._build_marker_widget(r, g, b, icon_name, "", badge_text=badge) widget.set_tooltip_text(name) if contact is not None: click = Gtk.GestureClick() click.connect("pressed", lambda g, n, x, y, c=contact: self._on_marker_clicked(c)) widget.add_controller(click) elif prefix is not None: click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, w=widget, nm=name, la=lat, lo=lon: MapView.show_marker_popover( w, nm, la, lo, contact_type=_("Unknown repeater"), interpolated=True ), ) widget.add_controller(click) hop_marker = Shumate.Marker() hop_marker.set_child(widget) hop_marker.set_location(lat, lon) self._trace_marker_layer.add_marker(hop_marker) lats.append(lat) lons.append(lon) # Add SNR labels at midpoints between consecutive hops for i in range(1, len(hops)): snr = hops[i][4] if snr is None: continue mid_lat = (hops[i - 1][0] + hops[i][0]) / 2 mid_lon = (hops[i - 1][1] + hops[i][1]) / 2 snr_label = Gtk.Label(label=f"{format_number(snr, sign=True)} dB") snr_label.add_css_class("caption") if snr >= 0: snr_label.add_css_class("success") elif snr >= -5: snr_label.add_css_class("dim-label") else: snr_label.add_css_class("error") snr_marker = Shumate.Marker() snr_marker.set_child(snr_label) snr_marker.set_location(mid_lat, mid_lon) self._trace_marker_layer.add_marker(snr_marker) # Fit map to path bounds MapView.fit_viewport(viewport, lats, lons) def clear_trace_path(self): """Remove any trace path overlay from the map.""" if self._trace_anim_id: GLib.source_remove(self._trace_anim_id) self._trace_anim_id = None if self._trace_path_layer: self._map.get_map().remove_layer(self._trace_path_layer) self._trace_path_layer = None if self._trace_marker_layer: self._map.get_map().remove_layer(self._trace_marker_layer) self._trace_marker_layer = None @staticmethod def fit_viewport(viewport, lats, lons): """Zoom and center a viewport to fit the given coordinates.""" if not lats: return if len(lats) == 1: viewport.set_zoom_level(14) viewport.set_location(lats[0], lons[0]) return min_lat, max_lat = min(lats), max(lats) min_lon, max_lon = min(lons), max(lons) span = max(max_lat - min_lat, max_lon - min_lon) if span < 0.005: zoom = 14 elif span < 0.05: zoom = 13 elif span < 0.1: zoom = 12 elif span < 0.5: zoom = 10 elif span < 1.0: zoom = 9 else: zoom = 7 viewport.set_zoom_level(zoom) viewport.set_location((min_lat + max_lat) / 2, (min_lon + max_lon) / 2) @staticmethod def open_map_dialog(window, title=None): """Create and return (dialog, map_widget, marker_layer, viewport). The caller can add markers, path layers, etc. and then call dialog.present(window) to show it. """ builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/map-dialog.ui") dialog = builder.get_object("dialog") dialog.set_title(title or _("Map")) # Size to nearly fill the parent window win_width = window.get_width() or 800 win_height = window.get_height() or 600 dialog.set_content_width(max(500, win_width - 60)) dialog.set_content_height(max(400, win_height - 60)) toolbar_view = builder.get_object("toolbar_view") from meshy.views import create_shumate_map, deregister_map_widget map_widget, marker_layer, viewport = create_shumate_map() map_widget.set_hexpand(True) toolbar_view.set_content(map_widget) dialog.connect("closed", lambda _d: deregister_map_widget(map_widget)) return dialog, map_widget, marker_layer, viewport @staticmethod def _filter_outliers(lats, lons): """Remove geographic outliers using median + MAD (robust to skew).""" if len(lats) < 3: return lats, lons from math import sqrt from statistics import median med_lat, med_lon = median(lats), median(lons) dists = [ sqrt((la - med_lat) ** 2 + (lo - med_lon) ** 2) for la, lo in zip(lats, lons, strict=False) ] med_dist = median(dists) or 0.01 filtered = [ (la, lo) for la, lo, d in zip(lats, lons, dists, strict=False) if d <= 3 * med_dist ] if not filtered: return lats, lons return [la for la, _ in filtered], [lo for _, lo in filtered] def _fit_bounds(self): """Zoom and center the map to show all located contacts.""" lats = [c.latitude for c in self._located_contacts] lons = [c.longitude for c in self._located_contacts] if self._self_lat and self._self_lon: lats.append(self._self_lat) lons.append(self._self_lon) if not lats: return viewport = self._map.get_viewport() if len(lats) == 1: viewport.set_zoom_level(14) viewport.set_location(lats[0], lons[0]) return lats, lons = self._filter_outliers(lats, lons) min_lat, max_lat = min(lats), max(lats) min_lon, max_lon = min(lons), max(lons) center_lat = (min_lat + max_lat) / 2 center_lon = (min_lon + max_lon) / 2 lat_span = max_lat - min_lat lon_span = max_lon - min_lon span = max(lat_span, lon_span) if span < 0.01: zoom = 14 elif span < 0.05: zoom = 13 elif span < 0.1: zoom = 12 elif span < 0.5: zoom = 10 elif span < 1.0: zoom = 9 elif span < 5.0: zoom = 7 elif span < 20.0: zoom = 5 else: zoom = 3 viewport.set_zoom_level(zoom) viewport.set_location(center_lat, center_lon) def open_trace_picker_map(window, contacts, hash_width, on_done): """Open a map dialog for selecting trace route waypoints by clicking. on_done(hex_string) is called with comma-separated hop hashes when the user confirms the selection. """ located = [ c for c in contacts if c.type in (ContactType.REPEATER, ContactType.ROOM) and c.has_location ] if not located: return selected = [] # list of Contact objects (ordered, may repeat) dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( window, title=_("Pick Trace Route") ) toolbar_view = dialog.get_child() path_layer = MapView.create_dashed_path_layer(viewport) map_widget.get_map().add_layer(path_layer) badge_layer = Shumate.MarkerLayer.new(viewport) map_widget.get_map().add_layer(badge_layer) # Track badge labels per contact for multi-selection display badge_markers = {} # pub_key_hex -> (Shumate.Marker, Gtk.Label) def _update_path_and_badges(): # Update polyline (companion → waypoints → companion) path_layer.remove_all() if selected and self_node: path_layer.add_node(self_node) for c in selected: pt = Shumate.Coordinate() pt.set_location(c.latitude, c.longitude) path_layer.add_node(pt) if selected and self_node: end = Shumate.Coordinate() end.set_location(self_lat, self_lon) path_layer.add_node(end) # Rebuild badge markers for m, _lbl in badge_markers.values(): badge_layer.remove_marker(m) badge_markers.clear() # Collect indices per contact indices = {} for i, c in enumerate(selected): indices.setdefault(c.public_key_hex, []).append(i + 1) for key_hex, nums in indices.items(): contact = next(c for c in selected if c.public_key_hex == key_hex) text = ",".join(str(n) for n in nums) badge_widget = MapView.build_map_label_widget(text) marker = Shumate.Marker() marker.set_child(badge_widget) marker.set_location(contact.latitude, contact.longitude) badge_layer.add_marker(marker) badge_markers[key_hex] = (marker, badge_widget) # Update route label if selected: names = [c.name for c in selected] route_label.set_label(" → ".join(names)) route_label.set_visible(True) done_btn.set_sensitive(True) else: route_label.set_label("") route_label.set_visible(False) done_btn.set_sensitive(False) def _on_contact_clicked(contact): selected.append(contact) _update_path_and_badges() # Companion (self) marker device = window._device_info if hasattr(window, "_device_info") else None self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6) self_node = None if has_self: self_node = Shumate.Coordinate() self_node.set_location(self_lat, self_lon) # Clustering state expanded_clusters = [] last_zoom = [-1] recluster_timeout = [None] def _create_picker_contact_marker(contact, zoom): r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic") widget = MapView._build_marker_widget( r, g, b, icon_name, contact.name, boxed_label=True, show_label=zoom >= 10 ) widget.set_tooltip_text(contact.name) widget.set_cursor(Gdk.Cursor.new_from_name("pointer")) click = Gtk.GestureClick() click.connect("pressed", lambda g, n, x, y, c=contact: _on_contact_clicked(c)) widget.add_controller(click) marker = Shumate.Marker() marker.set_child(widget) marker.set_location(contact.latitude, contact.longitude) return marker def _zoom_or_expand(contacts, lat, lon): current_zoom = viewport.get_zoom_level() test_zoom = min(current_zoom + 2, 20) test_clusters = MapView._compute_clusters(contacts, test_zoom) if len(test_clusters) > 1 and current_zoom < 18: viewport.set_zoom_level(test_zoom) viewport.set_location(lat, lon) else: expanded_clusters.append(contacts) last_zoom[0] = -1 _rebuild_markers() def _rebuild_markers(): zoom = viewport.get_zoom_level() _LABEL_ZOOM = 10 labels_changed = (last_zoom[0] < _LABEL_ZOOM) != (zoom < _LABEL_ZOOM) if abs(zoom - last_zoom[0]) < 0.3 and not expanded_clusters and not labels_changed: return False last_zoom[0] = zoom marker_layer.remove_all() expanded_keys = set() for group in expanded_clusters: for c in group: expanded_keys.add(c.public_key_hex) to_cluster = [c for c in located if c.public_key_hex not in expanded_keys] clusters = MapView._compute_clusters(to_cluster, zoom) for cluster in clusters: if len(cluster) == 1: marker = _create_picker_contact_marker(cluster[0], zoom) else: marker = MapView._create_cluster_marker(cluster, _zoom_or_expand) marker_layer.add_marker(marker) for group in expanded_clusters: avg_lat = sum(c.latitude for c in group) / len(group) sum(c.longitude for c in group) / len(group) for i, contact in enumerate(group): angle = 2 * math.pi * i / len(group) offset = 0.0003 * (2 ** (15 - zoom)) offset = max(offset, 0.00005) dlat = offset * math.cos(angle) dlon = offset * math.sin(angle) / max(math.cos(math.radians(avg_lat)), 0.01) marker = _create_picker_contact_marker(contact, zoom) marker.set_location(contact.latitude + dlat, contact.longitude + dlon) marker_layer.add_marker(marker) if has_self: self_name = getattr(device, "name", "") or _("My Device") r, g, b = _SELF_COLOR widget = MapView._build_marker_widget( r, g, b, "avatar-default-symbolic", self_name, boxed_label=True, show_label=zoom >= 10, ) widget.set_tooltip_text(self_name) sm = Shumate.Marker() sm.set_child(widget) sm.set_location(self_lat, self_lon) marker_layer.add_marker(sm) return False def _on_zoom_changed(vp, pspec): expanded_clusters.clear() if recluster_timeout[0]: GLib.source_remove(recluster_timeout[0]) recluster_timeout[0] = GLib.timeout_add(150, _rebuild_markers) viewport.connect("notify::zoom-level", _on_zoom_changed) _rebuild_markers() # Bottom bar with route info and controls builder_bar = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/trace-picker-bottom-bar.ui" ) bottom_bar = builder_bar.get_object("bottom_bar") route_label = builder_bar.get_object("route_label") undo_btn = builder_bar.get_object("undo_btn") clear_btn = builder_bar.get_object("clear_btn") done_btn = builder_bar.get_object("done_btn") def _on_undo(*_): if selected: selected.pop() _update_path_and_badges() undo_btn.connect("clicked", _on_undo) def _on_clear(*_): selected.clear() _update_path_and_badges() clear_btn.connect("clicked", _on_clear) def _on_done(*_): hex_parts = [c.public_key[:hash_width].hex() for c in selected] on_done(",".join(hex_parts)) dialog.close() done_btn.connect("clicked", _on_done) toolbar_view.add_bottom_bar(bottom_bar) # Main content: map + instruction hint — unparent map from toolbar_view first toolbar_view.set_content(None) content_overlay = Gtk.Overlay() content_overlay.set_child(map_widget) hint_label = Gtk.Label(label=_("Tap repeaters to build the trace route")) hint_label.add_css_class("caption") hint_label.set_halign(Gtk.Align.CENTER) hint_label.set_valign(Gtk.Align.START) hint_label.set_margin_top(8) hint_label.add_css_class("map-overlay-label") content_overlay.add_overlay(hint_label) toolbar_view.set_content(content_overlay) # Fit map to show all repeaters + companion lats = [c.latitude for c in located] lons = [c.longitude for c in located] if has_self: lats.append(self_lat) lons.append(self_lon) MapView.fit_viewport(viewport, lats, lons) dialog.present(window) meshy/src/views/repeater_view.py000066400000000000000000001625531523412555500173650ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Repeater management view — shown in the content panel for repeater contacts.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") import contextlib import copy from gi.repository import Adw, Gio, GLib, Gtk from meshy.models import Contact, ContactType from meshy.protocol import ( build_send_cli_command, ) from meshy.repeater_helpers import ( ACL_ROLES, ROLE_PERMS, parse_acl_response, parse_region_text, ) from meshy.utils import ( TimeoutManager, clear_children, format_duration, format_number, format_time_ago, ) def _battery_pct(mv): if mv >= 4100: return 100 if mv <= 3300: return 0 return int((mv - 3300) / 8) @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/repeater-view.ui") class RepeaterView(Gtk.Box): __gtype_name__ = "MeshyRepeaterView" REQUEST_TIMEOUT_MS = 15000 FETCH_CMD_TIMEOUT_MS = 8000 # Main _stack = Gtk.Template.Child("stack") # Status page _st_clock = Gtk.Template.Child("st_clock") _clock_drift_label = Gtk.Template.Child("clock_drift_label") _clock_sync_btn = Gtk.Template.Child("clock_sync_btn") _clock_reset_btn = Gtk.Template.Child("clock_reset_btn") _st_battery = Gtk.Template.Child("st_battery") _st_uptime = Gtk.Template.Child("st_uptime") _st_queue = Gtk.Template.Child("st_queue") _st_errors = Gtk.Template.Child("st_errors") _st_rssi = Gtk.Template.Child("st_rssi") _st_snr = Gtk.Template.Child("st_snr") _st_noise = Gtk.Template.Child("st_noise") _st_tx_air = Gtk.Template.Child("st_tx_air") _st_rx_air = Gtk.Template.Child("st_rx_air") _st_channel_util = Gtk.Template.Child("st_channel_util") _st_duty_cycle = Gtk.Template.Child("st_duty_cycle") _st_sent = Gtk.Template.Child("st_sent") _st_recv = Gtk.Template.Child("st_recv") _st_recv_errors = Gtk.Template.Child("st_recv_errors") _st_flood = Gtk.Template.Child("st_flood") _st_direct = Gtk.Template.Child("st_direct") _st_dups = Gtk.Template.Child("st_dups") _status_refresh_btn = Gtk.Template.Child("status_refresh_btn") # CLI page _cli_quick_box = Gtk.Template.Child("cli_quick_box") _cli_view = Gtk.Template.Child("cli_view") _cli_entry = Gtk.Template.Child("cli_entry") _cli_send_btn = Gtk.Template.Child("cli_send_btn") # Neighbors page _nb_spinner = Gtk.Template.Child("nb_spinner") _nb_status = Gtk.Template.Child("nb_status") _nb_list = Gtk.Template.Child("nb_list") _nb_refresh_btn = Gtk.Template.Child("nb_refresh_btn") _nb_load_more = Gtk.Template.Child("nb_load_more") _nb_map_btn = Gtk.Template.Child("nb_map_btn") # Access page _acl_spinner = Gtk.Template.Child("acl_spinner") _acl_status = Gtk.Template.Child("acl_status") _acl_list = Gtk.Template.Child("acl_list") _acl_refresh_btn = Gtk.Template.Child("acl_refresh_btn") _acl_add_btn = Gtk.Template.Child("acl_add_btn") # Regions page _reg_loading_box = Gtk.Template.Child("reg_loading_box") _reg_spinner = Gtk.Template.Child("reg_spinner") _reg_status = Gtk.Template.Child("reg_status") _reg_retry_btn = Gtk.Template.Child("reg_retry_btn") _reg_content_box = Gtk.Template.Child("reg_content_box") _reg_default_entry = Gtk.Template.Child("reg_default_entry") _reg_list = Gtk.Template.Child("reg_list") _reg_add_btn = Gtk.Template.Child("reg_add_btn") _reg_apply_btn = Gtk.Template.Child("reg_apply_btn") # Settings page – fetch spinners _basic_fetch_spinner = Gtk.Template.Child("basic_fetch_spinner") _loc_fetch_spinner = Gtk.Template.Child("loc_fetch_spinner") _radio_fetch_spinner = Gtk.Template.Child("radio_fetch_spinner") _advert_fetch_spinner = Gtk.Template.Child("advert_fetch_spinner") # Settings page _set_name = Gtk.Template.Child("set_name") _set_repeat = Gtk.Template.Child("set_repeat") _set_allow_read_only = Gtk.Template.Child("set_allow_read_only") _set_admin_pwd = Gtk.Template.Child("set_admin_pwd") _admin_pwd_save_btn = Gtk.Template.Child("admin_pwd_save_btn") _set_guest_pwd = Gtk.Template.Child("set_guest_pwd") _guest_pwd_save_btn = Gtk.Template.Child("guest_pwd_save_btn") _set_lat = Gtk.Template.Child("set_lat") _set_lon = Gtk.Template.Child("set_lon") _set_freq = Gtk.Template.Child("set_freq") _set_bw = Gtk.Template.Child("set_bw") _set_sf = Gtk.Template.Child("set_sf") _set_cr = Gtk.Template.Child("set_cr") _set_tx = Gtk.Template.Child("set_tx") _set_advert_int = Gtk.Template.Child("set_advert_int") _set_flood_int = Gtk.Template.Child("set_flood_int") _reboot_btn = Gtk.Template.Child("reboot_btn") _basic_fetch_btn = Gtk.Template.Child("basic_fetch_btn") _basic_save_btn = Gtk.Template.Child("basic_save_btn") _loc_fetch_btn = Gtk.Template.Child("loc_fetch_btn") _loc_save_btn = Gtk.Template.Child("loc_save_btn") _radio_fetch_btn = Gtk.Template.Child("radio_fetch_btn") _radio_save_btn = Gtk.Template.Child("radio_save_btn") _advert_fetch_btn = Gtk.Template.Child("advert_fetch_btn") _advert_save_btn = Gtk.Template.Child("advert_save_btn") # Telemetry _tele_btn = Gtk.Template.Child("tele_btn") # Bottom bar _logout_btn = Gtk.Template.Child("logout_btn") @property def _node_label(self): if self._contact and self._contact.type == ContactType.ROOM: return _("room") return _("repeater") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._contact = None self._timeouts = TimeoutManager() self._nb_offset = 0 self._nb_total = 0 self._nb_neighbors = [] # list of (contact_or_None, snr) self._acl_entries = [] # list of {'key': hex, 'perm': int} self._region_entries = [] # list of {'name', 'indent', 'home', 'flood'} self._original_region_entries = [] self._original_default_scope = "" self._pending_region_cmds = [] self._reg_cmd_ids = set() self._regions_pending = False self._reg_prefixes = [] self._reg_popover_entry = None self._current_page = "placeholder" self._leaving_regions = False self._pending_cmd_ids = [] self._pending_prefixes = [] self._fetch_btn = None self._fetch_spinner = None # CLI buffer reference self._cli_buf = self._cli_view.get_buffer() # Populate CLI quick command buttons for label, cmd in [ ("get name", "get name"), ("get radio", "get radio"), ("get tx", "get tx"), ("ver", "ver"), ("neighbors", "neighbors"), ("advert", "advert"), ("clock", "clock"), ]: btn = Gtk.Button(label=label) btn.add_css_class("flat") btn.connect("clicked", lambda b, c=cmd: self._send_cli(c)) self._cli_quick_box.append(btn) # Signal connections self._status_refresh_btn.connect("clicked", lambda *_: self._request_status()) self._tele_btn.connect("clicked", lambda *_: self._request_telemetry()) self._clock_sync_btn.connect("clicked", self._on_sync_time_clicked) self._clock_reset_btn.connect("clicked", self._on_clock_reset_clicked) self._cli_entry.connect( "activate", lambda *_: self._send_cli(self._cli_entry.get_text().strip()) ) self._cli_send_btn.connect( "clicked", lambda *_: self._send_cli(self._cli_entry.get_text().strip()) ) self._nb_refresh_btn.connect("clicked", lambda *_: self._request_neighbors(reset=True)) self._nb_load_more.connect("clicked", lambda *_: self._request_neighbors(reset=False)) self._nb_map_btn.connect("clicked", lambda *_: self._show_neighbors_map()) self._acl_refresh_btn.connect("clicked", lambda *_: self._request_acl()) self._acl_add_btn.connect("clicked", lambda *_: self._show_acl_add_dialog()) self._reg_retry_btn.connect("clicked", lambda *_: self._request_regions()) self._reg_add_btn.connect("clicked", lambda *_: self._show_region_add_dialog()) self._reg_apply_btn.connect("clicked", lambda *_: self._on_reg_apply()) self._reg_default_entry.connect("notify::text", lambda *_: self._on_reg_default_changed()) self._stack.connect("notify::visible-child-name", self._on_stack_page_changed) reg_action_group = Gio.SimpleActionGroup() for name, cb in [ ("toggle_flood", lambda *_: self._on_reg_toggle_flood()), ("set_home", lambda *_: self._on_reg_set_home()), ("remove", lambda *_: self._on_reg_remove()), ]: action = Gio.SimpleAction.new(name, None) action.connect("activate", cb) reg_action_group.add_action(action) self._reg_list.insert_action_group("reg", reg_action_group) self._basic_fetch_btn.connect( "clicked", lambda *_: self._fetch_section( self._basic_fetch_cmds(), self._parse_basic, self._basic_fetch_btn, self._basic_fetch_spinner, ), ) self._basic_save_btn.connect("clicked", lambda *_: self._save_basic()) self._admin_pwd_save_btn.connect("clicked", lambda *_: self._save_admin_password()) self._guest_pwd_save_btn.connect("clicked", lambda *_: self._save_guest_password()) self._loc_fetch_btn.connect( "clicked", lambda *_: self._fetch_section( ["get lat", "get lon"], self._parse_location, self._loc_fetch_btn, self._loc_fetch_spinner, ), ) self._loc_save_btn.connect("clicked", lambda *_: self._save_location()) self._radio_fetch_btn.connect( "clicked", lambda *_: self._fetch_section( ["get radio", "get tx"], self._parse_radio, self._radio_fetch_btn, self._radio_fetch_spinner, ), ) self._radio_save_btn.connect("clicked", lambda *_: self._save_radio()) self._advert_fetch_btn.connect( "clicked", lambda *_: self._fetch_section( ["get advert.interval", "get flood.advert.interval"], self._parse_advert, self._advert_fetch_btn, self._advert_fetch_spinner, ), ) self._advert_save_btn.connect("clicked", lambda *_: self._save_advert()) self._reboot_btn.connect("activated", self._on_reboot) self._logout_btn.connect("clicked", self._on_logout) def _switch_tab(self, name): """Programmatically switch to a tab.""" self._current_page = name self._stack.set_visible_child_name(name) def _start_timeout(self, on_timeout): """Start a request timeout. Cancels any previous one.""" self._timeouts.start("request", self.REQUEST_TIMEOUT_MS, on_timeout) def _cancel_timeout(self): self._timeouts.cancel("request") # ─── Status ──────────────────────────────────────────────── def _on_sync_time_clicked(self, btn): """Send the current system time to the repeater via CLI command.""" if not self._contact: return import time from datetime import datetime ts = int(time.time()) self._window.send_cli_command(self._contact, f"time {ts}") self._st_clock.set_subtitle(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) self._clock_drift_label.set_visible(False) self._clock_sync_btn.set_visible(False) self._clock_reset_btn.set_visible(False) self._window.show_toast(_("Time sync sent to {}").format(self._node_label)) def _on_clock_reset_clicked(self, btn): """Reset the repeater's clock and reboot it (for when clock is ahead).""" if not self._contact: return dialog = Adw.AlertDialog( heading=_("Reset Clock & Reboot?"), body=_( "The clock is ahead of the current time.\n" "Firmware does not allow setting time backwards.\n\n" "This will reset the clock and reboot the device." ), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("reset", _("Reset & Reboot")) dialog.set_response_appearance("reset", Adw.ResponseAppearance.DESTRUCTIVE) def _on_response(d, response): if response == "reset": self._window.send_cli_command(self._contact, "clkreboot") self._clock_drift_label.set_visible(False) self._clock_sync_btn.set_visible(False) self._clock_reset_btn.set_visible(False) self._st_clock.set_subtitle(_("Rebooting...")) self._window.show_toast( _("Clock reset & reboot sent to {}").format(self._node_label) ) dialog.connect("response", _on_response) dialog.present(self._window) def _request_status(self): if not self._contact: return self._start_timeout(self._on_status_timeout) self._window.send_status_req(self._contact.public_key, self._on_status_response) def _on_status_timeout(self): self._st_battery.set_subtitle(_("Request timed out")) self._window.set_status_callback(None) self._start_pending_regions() def _on_status_response(self, status): self._cancel_timeout() mv = status["battery_mv"] pct = _battery_pct(mv) self._st_battery.set_subtitle(f"{pct}% ({mv} mV)") self._st_uptime.set_subtitle(format_duration(status["uptime_secs"])) self._st_queue.set_subtitle(str(status["queue_len"])) self._st_errors.set_subtitle(str(status["error_events"])) self._st_rssi.set_subtitle(f'{status["last_rssi"]} dB') self._st_snr.set_subtitle(f'{format_number(status["last_snr"])} dB') self._st_noise.set_subtitle(f'{status["noise_floor"]} dB') self._st_tx_air.set_subtitle(format_duration(status["tx_air_secs"])) self._st_rx_air.set_subtitle(format_duration(status["rx_air_secs"])) uptime = status["uptime_secs"] if uptime > 0: util = (status["tx_air_secs"] + status["rx_air_secs"]) / uptime * 100 self._st_channel_util.set_subtitle(f"{format_number(util)}%") duty = status["tx_air_secs"] / uptime * 100 self._st_duty_cycle.set_subtitle(f"{format_number(duty)}%") self._st_sent.set_subtitle(str(status["packets_sent"])) self._st_recv.set_subtitle(str(status["packets_recv"])) recv_errors = status.get("recv_errors") if recv_errors is not None: recv_errors_percentage = recv_errors / (status["packets_recv"] + recv_errors) * 100 self._st_recv_errors.set_visible(True) self._st_recv_errors.set_subtitle( f"{recv_errors} / {format_number(recv_errors_percentage, 2)}%" ) self._st_flood.set_subtitle(f'TX: {status["flood_tx"]} / RX: {status["flood_rx"]}') self._st_direct.set_subtitle(f'TX: {status["direct_tx"]} / RX: {status["direct_rx"]}') self._st_dups.set_subtitle(f'Flood: {status["dup_flood"]} / Direct: {status["dup_direct"]}') self._start_pending_regions() def _start_pending_regions(self): if self._regions_pending: self._regions_pending = False GLib.timeout_add(500, lambda: (self._request_regions(), False)[1]) # ─── Telemetry ───────────────────────────────────────────── def _request_telemetry(self): if not self._contact: return self._tele_btn.set_sensitive(False) self._tele_btn.set_label(_("Requesting…")) timeout_id = [None] def _reset_btn(): self._tele_btn.set_sensitive(True) self._tele_btn.set_label(_("Request Telemetry")) def on_timeout(): _reset_btn() timeout_id[0] = None return False def on_response(lpp_data): if timeout_id[0]: GLib.source_remove(timeout_id[0]) timeout_id[0] = None _reset_btn() if not lpp_data: return from meshy.protocol import parse_cayenne_lpp entries = parse_cayenne_lpp(lpp_data) if entries: self._show_telemetry_dialog(entries) timeout_id[0] = GLib.timeout_add(self.REQUEST_TIMEOUT_MS, on_timeout) self._window.get_telemetry( self._contact.public_key, on_response, contact_type=self._contact.type ) def _show_telemetry_dialog(self, entries: list[dict]): from meshy.models import battery_percent_from_mv from meshy.protocol import LPP_GPS, LPP_VOLTAGE gps_entry = next((e for e in entries if e["type"] == LPP_GPS), None) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/repeater-telemetry-dialog.ui" ) dialog = builder.get_object("dialog") content = builder.get_object("content") dialog.set_title(_("Telemetry — {}").format(self._contact.name if self._contact else "")) dialog.set_content_height(550 if gps_entry else 400) channels = {} for entry in entries: channels.setdefault(entry["channel"], []).append(entry) for ch in sorted(channels): group = Adw.PreferencesGroup(title=_("Channel {}").format(ch)) for entry in channels[ch]: val = entry["value"] if isinstance(val, dict): alt_str = ( f', alt {format_number(val["alt"], 0)} m' if abs(val["alt"]) > 0.01 else "" ) subtitle = f'{val["lat"]:.4f}, {val["lon"]:.4f}{alt_str}' elif entry["type"] == LPP_VOLTAGE and ch == 1: pct = battery_percent_from_mv(int(val * 1000)) subtitle = f"{pct}% ({format_number(val, 2)} V)" elif isinstance(val, float): subtitle = f'{format_number(val, 2)} {entry["unit"]}' else: subtitle = f'{val} {entry["unit"]}' title = _("Battery") if entry["type"] == LPP_VOLTAGE and ch == 1 else entry["name"] row = Adw.ActionRow(title=title, subtitle=subtitle) group.add(row) content.append(group) if gps_entry: from meshy.views import create_shumate_map, deregister_map_widget lat = gps_entry["value"]["lat"] lon = gps_entry["value"]["lon"] map_widget, marker_layer, viewport = create_shumate_map(zoom_level=14, lat=lat, lon=lon) if map_widget: map_widget.set_vexpand(False) map_widget.set_size_request(-1, 200) content.append(map_widget) dialog.connect("closed", lambda _d: deregister_map_widget(map_widget)) dialog.present(self._window) # ─── CLI ─────────────────────────────────────────────────── def _send_cli(self, cmd): if not cmd or not self._contact: return self._cli_entry.set_text("") self._cli_append(f"> {cmd}") self._window.send_frame(build_send_cli_command(self._contact.public_key, cmd)) _CLI_MAX_LINES = 5000 def _cli_append(self, text): end = self._cli_buf.get_end_iter() self._cli_buf.insert(end, text + "\n") line_count = self._cli_buf.get_line_count() if line_count > self._CLI_MAX_LINES: start = self._cli_buf.get_start_iter() trim_end = self._cli_buf.get_iter_at_line(line_count - self._CLI_MAX_LINES) self._cli_buf.delete(start, trim_end) GLib.idle_add( lambda: self._cli_view.get_parent() .get_vadjustment() .set_value(self._cli_view.get_parent().get_vadjustment().get_upper()) or False ) def on_cli_response(self, contact, message): """Called by window when a CLI response arrives. Returns True if consumed.""" if ( self._contact and message.is_cli and contact.public_key_hex == self._contact.public_key_hex ): self._cli_append(message.text) return True return False # ─── Neighbors ───────────────────────────────────────────── def _request_neighbors(self, reset=True): if not self._contact: return if reset: self._nb_offset = 0 self._nb_neighbors = [] self._nb_map_btn.set_visible(False) clear_children(self._nb_list) self._nb_spinner.set_spinning(True) self._nb_status.set_label(_("Requesting...")) self._nb_load_more.set_visible(False) self._start_timeout(self._on_neighbors_timeout) self._window.get_neighbors( self._contact.public_key, self._on_neighbors_response, offset=self._nb_offset ) def _on_neighbors_timeout(self): self._nb_spinner.set_spinning(False) self._nb_status.set_label(_("Request timed out")) self._nb_load_more.set_visible(self._nb_offset < self._nb_total) self._window.set_neighbors_callback(None) def _on_neighbors_response(self, result): self._cancel_timeout() self._nb_spinner.set_spinning(False) self._nb_total = result["total"] neighbors = result["neighbors"] self._nb_offset += len(neighbors) for nb in neighbors: prefix = nb.get("pub_key_hex", "") last_heard = nb.get("last_heard", 0) snr = nb.get("snr", 0) contact = self._window.find_contact_by_key_prefix(prefix) self._nb_neighbors.append((contact, snr)) title = contact.name if contact else prefix subtitle = f"{format_time_ago(last_heard)} \u00b7 SNR: {format_number(snr)} dB" row = Adw.ActionRow(title=title, subtitle=subtitle) row.add_prefix(Gtk.Image.new_from_icon_name("repeater-symbolic")) self._nb_list.append(row) self._nb_status.set_label(_("{} of {} neighbors").format(self._nb_offset, self._nb_total)) self._nb_load_more.set_visible(self._nb_offset < self._nb_total) center = self._contact has_center = center and center.has_location has_located_nb = any(c and c.has_location for c, _ in self._nb_neighbors) self._nb_map_btn.set_visible(has_center and has_located_nb) def _show_neighbors_map(self): from gi.repository import Shumate from meshy.views.map_view import MapView center = self._contact if not center or not center.has_location: return dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog( self._window, title=_("Neighbor Map") ) # Remove marker_layer so we can re-add it on top of path layers map_widget.get_map().remove_layer(marker_layer) lats = [center.latitude] lons = [center.longitude] neighbors_located = [] # First pass: add path layers (lines) so they render below markers for contact, snr in self._nb_neighbors: if not contact or not contact.has_location: continue neighbors_located.append((contact, snr)) path_layer = MapView.create_dashed_path_layer(viewport) map_widget.get_map().add_layer(path_layer) center_node = Shumate.Marker() center_node.set_location(center.latitude, center.longitude) path_layer.add_node(center_node) nb_node = Shumate.Marker() nb_node.set_location(contact.latitude, contact.longitude) path_layer.add_node(nb_node) lats.append(contact.latitude) lons.append(contact.longitude) # Re-add marker layer on top of all path layers map_widget.get_map().add_layer(marker_layer) # Center repeater marker (red) center_widget = MapView._build_marker_widget(0.90, 0.16, 0.22, "repeater-symbolic", "") center_widget.set_tooltip_text(center.name) center_marker = Shumate.Marker() center_marker.set_child(center_widget) center_marker.set_location(center.latitude, center.longitude) marker_layer.add_marker(center_marker) click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y: MapView.show_marker_popover( center_widget, center.name, center.latitude, center.longitude ), ) center_widget.add_controller(click) # Neighbor markers (green) and SNR labels for contact, snr in neighbors_located: nb_widget = MapView._build_marker_widget(0.30, 0.69, 0.31, "repeater-symbolic", "") nb_widget.set_tooltip_text(contact.name) nb_click = Gtk.GestureClick() nb_click.connect( "pressed", lambda g, n, x, y, w=nb_widget, c=contact, s=snr: MapView.show_marker_popover( w, c.name, c.latitude, c.longitude, s ), ) nb_widget.add_controller(nb_click) nb_marker = Shumate.Marker() nb_marker.set_child(nb_widget) nb_marker.set_location(contact.latitude, contact.longitude) marker_layer.add_marker(nb_marker) mid_lat = (center.latitude + contact.latitude) / 2 mid_lon = (center.longitude + contact.longitude) / 2 snr_widget = MapView.build_snr_widget(snr) snr_marker = Shumate.Marker() snr_marker.set_child(snr_widget) snr_marker.set_location(mid_lat, mid_lon) marker_layer.add_marker(snr_marker) MapView.fit_viewport(viewport, lats, lons) dialog.present(self._window) # ─── Access Control (ACL) ───────────────────────────────── def _request_acl(self): if not self._contact: return # Clear list clear_children(self._acl_list) self._acl_spinner.set_spinning(True) self._acl_status.set_label(_("Requesting...")) self._start_timeout(self._on_acl_timeout) self._window.get_acl(self._contact.public_key, self._on_acl_response) def _on_acl_timeout(self): self._acl_spinner.set_spinning(False) self._acl_status.set_label(_("Request timed out")) self._window.set_acl_callback(None) def _on_acl_response(self, data): self._cancel_timeout() self._acl_spinner.set_spinning(False) self._acl_entries = parse_acl_response(data) for entry in self._acl_entries: prefix = entry["key"] perm = entry["perm"] # Try to match to a known contact or own companion self_key = self._window.self_info.get("public_key", "") if self_key.startswith(prefix): name = self._window.self_info.get("name", "") or _("Me") else: contact = self._window.find_contact_by_key_prefix(prefix) name = contact.name if contact else None role = _(ACL_ROLES.get(perm & 3, "Unknown")) title = name or prefix subtitle = f"{role} \u00b7 {prefix}" row = Adw.ActionRow(title=title, subtitle=subtitle) row.add_prefix(Gtk.Image.new_from_icon_name("avatar-default-symbolic")) # Remove button remove_btn = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER) remove_btn.add_css_class("destructive-action") remove_btn.connect("clicked", lambda b, k=prefix: self._on_acl_remove(k)) row.add_suffix(remove_btn) self._acl_list.append(row) count = len(self._acl_entries) self._acl_status.set_label( _("{} entry").format(count) if count == 1 else _("{} entries").format(count) ) def _on_acl_remove(self, key_hex): """Remove a node from ACL via setperm 0.""" if not self._contact: return # Find contact name for the confirmation message name = key_hex self_key = self._window.self_info.get("public_key", "") if self_key.startswith(key_hex): name = self._window.self_info.get("name", "") or _("Me") else: for c in self._window.contacts: if c.public_key_hex.startswith(key_hex): name = c.name break dialog = Adw.AlertDialog( heading=_("Remove from ACL?"), body=_("Remove {} from the access control list?").format(name), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("remove", _("Remove")) dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) def _on_response(d, response): if response == "remove": self._send_cmds([f"setperm {key_hex} 0"]) # Refresh after a short delay to let the command take effect GLib.timeout_add(1000, lambda: (self._request_acl(), False)[1]) dialog.connect("response", _on_response) dialog.present(self._window) def _show_acl_add_dialog(self): """Show discover-style dialog to add a contact to the ACL.""" if not self._contact: return from meshy.views import show_contact_picker role_row = Adw.ComboRow(title=_("Role")) role_model = Gtk.StringList.new([_("Admin"), _("Read-Write"), _("Read Only")]) role_row.set_model(role_model) role_row.set_selected(0) role_group = Adw.PreferencesGroup() role_group.set_margin_start(12) role_group.set_margin_end(12) role_group.set_margin_top(8) role_group.set_margin_bottom(4) role_group.add(role_row) acl_keys = {e["key"] for e in self._acl_entries} eligible = [ c for c in self._window.contacts if c.public_key_hex != self._contact.public_key_hex and not any(c.public_key_hex.startswith(k) for k in acl_keys) ] def _on_add(contact): perm = ROLE_PERMS[role_row.get_selected()] self._send_cmds([f"setperm {contact.public_key_hex} {perm}"]) GLib.timeout_add(1000, lambda: (self._request_acl(), False)[1]) ns = show_contact_picker( self._window, title=_("Add to Access Control"), contacts=eligible, on_add=_on_add, extra_top_widget=role_group, ) ns.dialog._refs.append(role_model) ns.present() # ─── Regions ─────────────────────────────────────────────── def _cancel_reg_timeout(self): self._timeouts.cancel("reg") def _request_regions(self): if not self._contact: return # Cancel previous region fetch self._cancel_reg_timeout() for src_id in list(self._reg_cmd_ids): GLib.source_remove(src_id) self._window.cancel_cli_prefixes(self._reg_prefixes) self._reg_cmd_ids = set() self._reg_prefixes = [] # Clear region list clear_children(self._reg_list) # Show loading, hide content self._reg_loading_box.set_visible(True) self._reg_content_box.set_visible(False) self._reg_spinner.set_spinning(True) self._reg_status.set_label(_("Loading regions...")) self._reg_retry_btn.set_visible(False) commands = ["region", "region default"] responses = [None, None] remaining = [2] def make_cb(idx): def on_response(text): responses[idx] = text.strip() remaining[0] -= 1 if remaining[0] <= 0: self._cancel_reg_timeout() self._on_regions_loaded(responses) return on_response def on_timeout(): self._window.cancel_cli_prefixes(self._reg_prefixes) self._reg_prefixes = [] received = [r for r in responses if r is not None] if received: self._on_regions_loaded(responses) else: self._on_regions_timeout() self._timeouts.start("reg", self.REQUEST_TIMEOUT_MS, on_timeout) for i, cmd in enumerate(commands): holder = [0] def send(c=cmd, idx=i, h=holder): self._reg_cmd_ids.discard(h[0]) if not self._contact: return False prefix = f"{self._window.cli_prefix_counter:02X}|" self._reg_prefixes.append(prefix) self._window.send_cli_with_prefix(self._contact.public_key, c, make_cb(idx)) return False holder[0] = GLib.timeout_add(i * 1500, send) self._reg_cmd_ids.add(holder[0]) def _on_regions_timeout(self): self._reg_spinner.set_spinning(False) self._reg_status.set_label(_("Request timed out")) self._reg_retry_btn.set_visible(True) def _on_regions_loaded(self, responses): region_text = responses[0] if len(responses) > 0 and responses[0] else "" default_text = responses[1] if len(responses) > 1 and responses[1] else "" if not region_text: self._on_regions_timeout() return self._region_entries, default_name = parse_region_text(region_text, default_text) self._reg_default_entry.set_text(default_name) self._original_default_scope = default_name self._original_region_entries = copy.deepcopy(self._region_entries) self._pending_region_cmds = [] self._reg_apply_btn.set_sensitive(False) self._rebuild_region_rows() self._reg_loading_box.set_visible(False) self._reg_content_box.set_visible(True) def _rebuild_region_rows(self): clear_children(self._reg_list) for entry in self._region_entries: is_wildcard = entry["name"] == "*" title = _("Global (wildcard)") if is_wildcard else entry["name"] parts = [] if entry["home"]: parts.append(_("Home")) parts.append(_("Flood allowed") if entry["flood"] else _("Flood denied")) subtitle = " · ".join(parts) row = Adw.ActionRow( title=GLib.markup_escape_text(title), subtitle=subtitle, ) prefix_box = Gtk.Box(spacing=0) visual_indent = max(0, entry["indent"] - 1) if visual_indent > 0: spacer = Gtk.Box() spacer.set_size_request(visual_indent * 16, -1) prefix_box.append(spacer) flood_icon = "check-plain-symbolic" if entry["flood"] else "circle-crossed-symbolic" prefix_box.append(Gtk.Image.new_from_icon_name(flood_icon)) row.add_prefix(prefix_box) menu_btn = Gtk.Button( icon_name="view-more-symbolic", valign=Gtk.Align.CENTER, tooltip_text=_("Actions"), ) menu_btn.add_css_class("flat") menu_btn.connect("clicked", lambda b, e=entry: self._show_reg_popover(b, e)) row.add_suffix(menu_btn) self._reg_list.append(row) def _show_reg_popover(self, button, entry): self._reg_popover_entry = entry is_wildcard = entry["name"] == "*" menu = Gio.Menu() flood_label = _("Deny Flood") if entry["flood"] else _("Allow Flood") menu.append(flood_label, "reg.toggle_flood") if not is_wildcard: if not entry["home"]: menu.append(_("Set as Home"), "reg.set_home") menu.append(_("Remove"), "reg.remove") popover = Gtk.PopoverMenu(menu_model=menu) popover.set_has_arrow(False) popover.set_parent(button) popover.connect("closed", lambda p: GLib.idle_add(p.unparent)) popover.popup() def _mark_regions_dirty(self): self._reg_apply_btn.set_sensitive(True) def _check_regions_dirty(self): if self._pending_region_cmds: return True return self._reg_default_entry.get_text().strip() != self._original_default_scope def _on_reg_default_changed(self): if self._check_regions_dirty(): self._mark_regions_dirty() else: self._reg_apply_btn.set_sensitive(False) def _on_reg_toggle_flood(self): entry = self._reg_popover_entry if not entry: return entry["flood"] = not entry["flood"] cmd = "region allowf" if entry["flood"] else "region denyf" self._pending_region_cmds.append(f'{cmd} {entry["name"]}') self._rebuild_region_rows() self._mark_regions_dirty() def _on_reg_set_home(self): entry = self._reg_popover_entry if not entry: return for e in self._region_entries: e["home"] = False entry["home"] = True self._pending_region_cmds.append(f'region home {entry["name"]}') self._rebuild_region_rows() self._mark_regions_dirty() def _on_reg_remove(self): entry = self._reg_popover_entry if not entry: return idx = self._region_entries.index(entry) if ( idx + 1 < len(self._region_entries) and self._region_entries[idx + 1]["indent"] > entry["indent"] ): self._window.show_toast(_("Remove child regions first")) return self._region_entries.remove(entry) self._pending_region_cmds.append(f'region remove {entry["name"]}') self._rebuild_region_rows() self._mark_regions_dirty() def _on_reg_apply(self): cmds = list(self._pending_region_cmds) current_default = self._reg_default_entry.get_text().strip() if current_default != self._original_default_scope: cmd = ( f"region default {current_default}" if current_default else "region default " ) cmds.append(cmd) if not cmds: return cmds.append("region save") self._send_cmds(cmds) self._pending_region_cmds = [] self._reg_apply_btn.set_sensitive(False) GLib.timeout_add(1500, lambda: (self._request_regions(), False)[1]) def _on_stack_page_changed(self, stack, pspec): new_page = stack.get_visible_child_name() if self._leaving_regions: self._leaving_regions = False self._current_page = new_page return prev_page = self._current_page if prev_page != "regions" or not self._check_regions_dirty(): self._current_page = new_page return self._leaving_regions = True stack.set_visible_child_name("regions") target_page = new_page dialog = Adw.AlertDialog( heading=_("Unsaved Changes"), body=_("You have unsaved region changes."), ) dialog.add_response("discard", _("Discard")) dialog.add_response("apply", _("Apply")) dialog.set_response_appearance("discard", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED) dialog.set_close_response("close") def _on_response(d, response): if response == "apply": self._on_reg_apply() self._leaving_regions = True stack.set_visible_child_name(target_page) elif response == "discard": self._pending_region_cmds = [] self._reg_apply_btn.set_sensitive(False) self._request_regions() self._leaving_regions = True stack.set_visible_child_name(target_page) else: self._leaving_regions = False dialog.connect("response", _on_response) dialog.present(self._window) def _show_region_add_dialog(self): if not self._contact: return dialog = Adw.AlertDialog( heading=_("Add Region"), body=_("Enter a name and optionally choose a parent region."), ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("add", _("Add")) dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/region-add-content.ui" ) dialog.set_extra_child(builder.get_object("content")) name_entry = builder.get_object("name_entry") parent_names = ( [_("None")] + ["*"] + [e["name"] for e in self._region_entries if e["name"] != "*"] ) parent_row = builder.get_object("parent_row") parent_model = Gtk.StringList.new(parent_names) parent_row.set_model(parent_model) parent_row.set_selected(0) def _on_response(d, response): if response != "add": return name = name_entry.get_text().strip() if not name: return selected = parent_row.get_selected() if selected == 0: parent_entry = None else: parent_name = parent_names[selected] parent_entry = next( (e for e in self._region_entries if e["name"] == parent_name), None ) child_indent = (parent_entry["indent"] + 1) if parent_entry else 1 insert_idx = len(self._region_entries) if parent_entry: pi = self._region_entries.index(parent_entry) insert_idx = pi + 1 while ( insert_idx < len(self._region_entries) and self._region_entries[insert_idx]["indent"] > parent_entry["indent"] ): insert_idx += 1 self._region_entries.insert( insert_idx, { "name": name, "indent": child_indent, "home": False, "flood": True, }, ) if parent_entry: self._pending_region_cmds.append(f"region put {name} {parent_name}") else: self._pending_region_cmds.append(f"region put {name}") self._rebuild_region_rows() self._mark_regions_dirty() dialog.connect("response", _on_response) dialog.present(self._window) # ─── Settings ────────────────────────────────────────────── def _cancel_fetch(self): """Cancel any in-progress settings fetch.""" self._timeouts.cancel("fetch") for src_id in self._pending_cmd_ids: GLib.source_remove(src_id) self._window.cancel_cli_prefixes(self._pending_prefixes) self._pending_cmd_ids = [] self._pending_prefixes = [] if self._fetch_spinner: self._fetch_spinner.set_spinning(False) self._fetch_spinner.set_visible(False) if self._fetch_btn: self._fetch_btn.set_sensitive(True) self._fetch_btn = None self._fetch_spinner = None def _fetch_section(self, commands, parser, fetch_btn, spinner): """Fetch a section's settings via sequential CLI commands with retry.""" if not self._contact: return self._cancel_fetch() self._fetch_btn = fetch_btn self._fetch_spinner = spinner fetch_btn.set_sensitive(False) spinner.set_visible(True) spinner.set_spinning(True) responses = [None] * len(commands) self._fetch_next_cmd(commands, responses, 0, parser, False) def _fetch_next_cmd(self, commands, responses, idx, parser, retried): """Send command at idx; on response advance, on timeout retry once.""" if not self._contact: self._cancel_fetch() return if idx >= len(commands): self._cancel_fetch() parser(responses) return def on_response(text): self._timeouts.cancel("fetch") responses[idx] = text.strip() self._pending_prefixes = [] self._fetch_next_cmd(commands, responses, idx + 1, parser, False) def on_timeout(): self._window.cancel_cli_prefixes(self._pending_prefixes) self._pending_prefixes = [] if not retried: self._fetch_next_cmd(commands, responses, idx, parser, True) else: self._fetch_next_cmd(commands, responses, idx + 1, parser, False) self._timeouts.start("fetch", self.FETCH_CMD_TIMEOUT_MS, on_timeout) prefix = f"{self._window.cli_prefix_counter:02X}|" self._pending_prefixes = [prefix] self._window.send_cli_with_prefix(self._contact.public_key, commands[idx], on_response) def _basic_fetch_cmds(self): cmds = ["get name", "get repeat"] if self._contact and self._contact.type == ContactType.ROOM: cmds.append("get allow.read.only") return cmds def _parse_basic(self, responses): for i, text in enumerate(responses): if text is None: continue t = text.strip().lstrip("> ").strip() if i == 0: if t and "," not in t and not any(c in t for c in "{}[]"): self._set_name.set_text(t) elif i == 1: self._set_repeat.set_active(t.lower() in ("on", "1", "true")) elif i == 2: self._set_allow_read_only.set_active(t.lower() in ("on", "1", "true")) def _parse_location(self, responses): for i, text in enumerate(responses): if text is None: continue t = text.strip().lstrip("> ").strip() try: val = float(t) if i == 0: self._set_lat.set_text(str(val)) else: self._set_lon.set_text(str(val)) except ValueError: pass def _parse_radio(self, responses): for text in responses: if text is None: continue t = text.strip().lstrip("> ").strip() if "," in t: parts = t.split(",") try: self._set_freq.set_text(parts[0].strip()) self._set_bw.set_text(parts[1].strip()) self._set_sf.set_value(int(float(parts[2].strip()))) if len(parts) > 3: self._set_cr.set_value(int(float(parts[3].strip()))) except (ValueError, IndexError): pass else: with contextlib.suppress(ValueError): self._set_tx.set_value(int(float(t))) def _parse_advert(self, responses): for i, text in enumerate(responses): if text is None: continue t = text.strip().lstrip("> ").strip() try: val = int(float(t)) if i == 0: self._set_advert_int.set_value(val) else: self._set_flood_int.set_value(val) except ValueError: pass def _save_admin_password(self): if not self._contact: return pwd = self._set_admin_pwd.get_text() if not pwd: return self._send_cmds([f"password {pwd}"]) self._set_admin_pwd.set_text("") self._window.show_toast(_("Admin password changed — re-login required")) def _save_guest_password(self): if not self._contact: return pwd = self._set_guest_pwd.get_text() self._send_cmds([f"set guest.password {pwd}"]) def _save_basic(self): if not self._contact: return cmds = [] name = self._set_name.get_text().strip() if name: cmds.append(f"set name {name}") cmds.append(f'set repeat {"on" if self._set_repeat.get_active() else "off"}') if self._contact.type == ContactType.ROOM: val = "on" if self._set_allow_read_only.get_active() else "off" cmds.append(f"set allow.read.only {val}") self._send_cmds(cmds) def _save_location(self): if not self._contact: return cmds = [] with contextlib.suppress(ValueError): cmds.append(f"set lat {float(self._set_lat.get_text())}") with contextlib.suppress(ValueError): cmds.append(f"set lon {float(self._set_lon.get_text())}") self._send_cmds(cmds) def _save_radio(self): if not self._contact: return cmds = [] freq = self._set_freq.get_text().strip() bw = self._set_bw.get_text().strip() if freq and bw: sf = int(self._set_sf.get_value()) cr = int(self._set_cr.get_value()) cmds.append(f"set radio {freq},{bw},{sf},{cr}") tx = int(self._set_tx.get_value()) if tx > 0: cmds.append(f"set tx {tx}") self._send_cmds(cmds) def _save_advert(self): if not self._contact: return cmds = [ f"set advert.interval {int(self._set_advert_int.get_value())}", f"set flood.advert.interval {int(self._set_flood_int.get_value())}", ] self._send_cmds(cmds) def _send_cmds(self, cmds): if not cmds: return for i, cmd in enumerate(cmds): GLib.timeout_add( i * 300, lambda c=cmd: ( self._window.send_frame(build_send_cli_command(self._contact.public_key, c)) or False ), ) name = self._contact.name if self._contact else self._node_label self._window.show_toast(_("Settings sent to {}").format(name)) def _on_reboot(self, *args): confirm = Adw.AlertDialog( heading=_("Reboot {}?").format( self._contact.name if self._contact else self._node_label ), body=_("Reboot {}? It will be offline briefly.").format( self._contact.name if self._contact else self._node_label ), ) confirm.add_response("cancel", _("Cancel")) confirm.add_response("reboot", _("Reboot")) confirm.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE) confirm.connect( "response", lambda d, r: (self._send_cli("reboot")) if r == "reboot" else None ) confirm.present(self._window) # ─── Public API ──────────────────────────────────────────── def _reset_ui(self): """Reset all UI widgets to default/empty state.""" self._cancel_fetch() # Status rows for w in ( self._st_battery, self._st_uptime, self._st_queue, self._st_errors, self._st_rssi, self._st_snr, self._st_noise, self._st_tx_air, self._st_rx_air, self._st_sent, self._st_recv, self._st_flood, self._st_direct, self._st_dups, ): w.set_subtitle("--") self._st_recv_errors.set_visible(False) # Neighbors clear_children(self._nb_list) self._nb_offset = 0 self._nb_total = 0 self._nb_neighbors = [] self._nb_spinner.set_spinning(False) self._nb_status.set_label("") self._nb_load_more.set_visible(False) self._nb_map_btn.set_visible(False) # ACL clear_children(self._acl_list) self._acl_entries = [] self._acl_spinner.set_spinning(False) self._acl_status.set_label("") # Regions clear_children(self._reg_list) self._region_entries = [] self._original_region_entries = [] self._original_default_scope = "" self._reg_default_entry.set_text("") self._reg_loading_box.set_visible(True) self._reg_content_box.set_visible(False) self._reg_spinner.set_spinning(False) self._reg_status.set_label("") self._reg_retry_btn.set_visible(False) # Settings self._set_name.set_text("") self._set_repeat.set_active(False) self._set_allow_read_only.set_active(False) self._set_admin_pwd.set_text("") self._set_guest_pwd.set_text("") self._set_lat.set_text("") self._set_lon.set_text("") self._set_freq.set_text("") self._set_bw.set_text("") self._set_sf.set_value(5) self._set_cr.set_value(5) self._set_tx.set_value(1) self._set_advert_int.set_value(0) self._set_flood_int.set_value(0) # CLI self._cli_buf.set_text("") def set_repeater(self, contact: Contact, login_timestamp=None, is_admin=True): """Set the active repeater and switch to status view.""" # Clean up previous repeater state self._cancel_timeout() self._cancel_reg_timeout() self._pending_region_cmds = [] self._leaving_regions = False self._pending_cmd_ids = [] self._pending_prefixes = [] self._window.set_cli_response_callback(None) self._window.set_status_callback(None) self._window.set_neighbors_callback(None) self._window.set_acl_callback(None) self._reset_ui() self._contact = contact self._is_admin = is_admin is_room = contact.type == ContactType.ROOM # Show/hide admin-only tabs for name in ("cli", "access", "settings", "regions"): child = self._stack.get_child_by_name(name) if child: self._stack.get_page(child).set_visible(is_admin) # Neighbors not supported by room server firmware nb_child = self._stack.get_child_by_name("neighbors") if nb_child: self._stack.get_page(nb_child).set_visible(not is_room) # Guest access toggle only for room servers self._set_allow_read_only.set_visible(is_room) # Show/hide clock sync controls (admin only) self._st_clock.set_visible(is_admin) # Display the repeater's clock time from the login response if is_admin and login_timestamp: import time from datetime import datetime dt = datetime.fromtimestamp(login_timestamp) self._st_clock.set_subtitle(dt.strftime("%Y-%m-%d %H:%M:%S")) now = int(time.time()) drift = now - login_timestamp # positive = behind, negative = ahead if abs(drift) > 300: # more than 5 minutes off self._clock_drift_label.set_label(_("Time is off")) self._clock_drift_label.set_visible(True) if drift > 0: # Repeater clock is behind — can set time forward self._clock_sync_btn.set_visible(True) self._clock_reset_btn.set_visible(False) else: # Repeater clock is ahead — firmware rejects setting time back self._clock_sync_btn.set_visible(False) self._clock_reset_btn.set_visible(True) else: self._clock_drift_label.set_visible(False) self._clock_sync_btn.set_visible(False) self._clock_reset_btn.set_visible(False) else: self._st_clock.set_subtitle("--") self._clock_drift_label.set_visible(False) self._clock_sync_btn.set_visible(False) self._clock_reset_btn.set_visible(False) self._switch_tab("status") # Clear CLI output self._cli_buf.set_text("") # Register CLI callback for admin if is_admin: self._window.set_cli_response_callback(self.on_cli_response) self._regions_pending = True # Request status (regions will load after status completes) self._request_status() def _on_logout(self, *args): if self._contact: self._window.logout_repeater(self._contact.public_key_hex) def clear(self): self._cancel_timeout() self._cancel_reg_timeout() for src_id in list(self._reg_cmd_ids): GLib.source_remove(src_id) self._reg_cmd_ids = set() self._cancel_fetch() self._pending_region_cmds = [] self._reg_apply_btn.set_sensitive(False) self._contact = None self._current_page = "placeholder" self._stack.set_visible_child_name("placeholder") self._window.set_cli_response_callback(None) self._window.set_status_callback(None) self._window.set_neighbors_callback(None) self._window.set_acl_callback(None) meshy/src/views/sensor_view.py000066400000000000000000000246711523412555500170650ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Sensor telemetry view for sensor-type contacts.""" import logging import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, GLib, Gtk from meshy.models import Contact, ContactType from meshy.utils import format_number log = logging.getLogger(__name__) _REQUEST_TIMEOUT_MS = 15000 _AUTO_REFRESH_MS = 30000 @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/sensor-view.ui") class SensorView(Gtk.Box): __gtype_name__ = "MeshySensorView" _placeholder = Gtk.Template.Child("placeholder") _scroll = Gtk.Template.Child("scroll") _content_box = Gtk.Template.Child("content_box") _status_group = Gtk.Template.Child("status_group") _status_row = Gtk.Template.Child("status_row") _map_frame = Gtk.Template.Child("map_frame") _action_bar = Gtk.Template.Child("action_bar") _spinner = Gtk.Template.Child("spinner") _refresh_btn = Gtk.Template.Child("refresh_btn") _login_btn = Gtk.Template.Child("login_btn") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._contact: Contact | None = None self._timeout_id: int | None = None self._auto_refresh_id: int | None = None self._map_widget = None self._dynamic_groups: list[Gtk.Widget] = [] self._refresh_btn.connect("clicked", lambda *_: self._request_telemetry()) self._login_btn.connect("clicked", self._on_login_clicked) def set_sensor(self, contact: Contact): self.clear() self._contact = contact self._placeholder.set_visible(False) self._scroll.set_visible(True) self._action_bar.set_visible(True) self._status_row.set_subtitle(_("Requesting...")) self._status_group.set_visible(True) self._login_btn.set_visible(True) self._request_telemetry() self._auto_refresh_id = GLib.timeout_add(_AUTO_REFRESH_MS, self._on_auto_refresh) def clear(self): self._cancel_timeout() self._cancel_auto_refresh() self._cleanup_map() self._remove_dynamic_groups() self._contact = None self._status_row.set_subtitle("--") self._status_group.set_visible(True) self._placeholder.set_visible(True) self._scroll.set_visible(False) self._action_bar.set_visible(False) self._spinner.set_visible(False) self._spinner.set_spinning(False) self._login_btn.set_visible(False) def _request_telemetry(self): if not self._contact: return self._cancel_timeout() self._spinner.set_visible(True) self._spinner.set_spinning(True) self._refresh_btn.set_sensitive(False) self._timeout_id = GLib.timeout_add(_REQUEST_TIMEOUT_MS, self._on_timeout) self._window.get_telemetry( self._contact.public_key, self._on_telemetry_response, contact_type=ContactType.SENSOR, ) def _on_telemetry_response(self, lpp_data): self._cancel_timeout() self._spinner.set_visible(False) self._spinner.set_spinning(False) self._refresh_btn.set_sensitive(True) if not lpp_data: self._status_row.set_subtitle(_("No telemetry data")) return from meshy.protocol import parse_cayenne_lpp entries = parse_cayenne_lpp(lpp_data) if not entries: self._status_row.set_subtitle(_("No telemetry data")) return self._display_telemetry(entries) def _display_telemetry(self, entries: list[dict]): from meshy.models import battery_percent_from_mv from meshy.protocol import LPP_GPS, LPP_VOLTAGE self._remove_dynamic_groups() self._cleanup_map() self._status_group.set_visible(False) channels: dict[int, list[dict]] = {} for entry in entries: channels.setdefault(entry["channel"], []).append(entry) gps_entry = None for ch in sorted(channels): group = Adw.PreferencesGroup(title=_("Channel {}").format(ch)) for entry in channels[ch]: val = entry["value"] if isinstance(val, dict): if entry["type"] == LPP_GPS: gps_entry = entry alt_str = ( f", alt {format_number(val['alt'], 0)} m" if abs(val.get("alt", 0)) > 0.01 else "" ) subtitle = f"{val['lat']:.4f}, {val['lon']:.4f}{alt_str}" else: subtitle = ", ".join(f"{k}: {v}" for k, v in val.items()) elif entry["type"] == LPP_VOLTAGE and ch == 1: pct = battery_percent_from_mv(int(val * 1000)) subtitle = f"{pct}% ({format_number(val, 2)} V)" elif isinstance(val, float): subtitle = f"{format_number(val, 2)} {entry['unit']}" else: subtitle = f"{val} {entry['unit']}" title = _("Battery") if entry["type"] == LPP_VOLTAGE and ch == 1 else entry["name"] row = Adw.ActionRow(title=title, subtitle=subtitle) group.add(row) insert_pos = len(self._dynamic_groups) + 1 self._content_box.insert_child_after( group, self._status_group if insert_pos == 1 else self._dynamic_groups[-1] ) self._dynamic_groups.append(group) if gps_entry: self._show_map(gps_entry) def _show_map(self, gps_entry: dict): try: from meshy.views import create_shumate_map from meshy.views.map_view import _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, MapView lat = gps_entry["value"]["lat"] lon = gps_entry["value"]["lon"] map_widget, marker_layer, viewport = create_shumate_map(zoom_level=14, lat=lat, lon=lon) map_widget.set_vexpand(False) map_widget.set_size_request(-1, 250) r, g, b = _MARKER_COLORS.get(self._contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(self._contact.type, "avatar-default-symbolic") marker = MapView.create_marker(r, g, b, icon_name, self._contact.name, lat, lon) marker_layer.add_marker(marker) lats, lons = [lat], [lon] si = self._window.self_info self_lat = si.get("adv_lat", 0.0) self_lon = si.get("adv_lon", 0.0) if abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6: self_name = si.get("name", "") or _("My Device") self_marker = MapView.create_marker( *_SELF_COLOR, "avatar-default-symbolic", self_name, self_lat, self_lon ) marker_layer.add_marker(self_marker) lats.append(self_lat) lons.append(self_lon) MapView.fit_viewport(viewport, lats, lons) self._map_frame.set_child(map_widget) self._map_frame.set_visible(True) self._map_widget = map_widget except Exception as e: log.error("Failed to create map for sensor telemetry: %s", e, exc_info=True) lat = gps_entry["value"]["lat"] lon = gps_entry["value"]["lon"] from meshy.platforms import is_macos label = Gtk.Label() if is_macos(): label.set_markup(f"GPS Coordinates:\nLat: {lat:.6f}, Lon: {lon:.6f}") else: label.set_markup(f"GPS Coordinates:\n📍 {lat:.6f}, {lon:.6f}") self._map_frame.set_child(label) self._map_frame.set_visible(True) def _on_login_clicked(self, btn): if not self._contact: return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/sensor-login-dialog.ui" ) dialog = builder.get_object("dialog") pwd_entry = builder.get_object("pwd_entry") login_btn = builder.get_object("login_btn") status_label = builder.get_object("status_label") dialog.set_title(_("Login to {}").format(self._contact.name)) contact = self._contact def _on_login(*_args): password = pwd_entry.get_text().strip() if not password: return login_btn.set_sensitive(False) status_label.set_label(_("Logging in...")) def _on_login_result(success, login_timestamp, is_admin, acl_perms=3): if success: dialog.close() self._window.mark_room_logged_in(contact.public_key_hex, acl_perms) self._window.show_toast(_("Logged in to {}").format(contact.name)) else: login_btn.set_sensitive(True) status_label.set_label(_("Login failed")) self._window.send_login(contact.public_key, password, _on_login_result) login_btn.connect("clicked", _on_login) pwd_entry.connect("apply", _on_login) dialog.present(self._window) def _on_timeout(self): self._timeout_id = None self._spinner.set_visible(False) self._spinner.set_spinning(False) self._refresh_btn.set_sensitive(True) if not self._dynamic_groups: self._status_row.set_subtitle(_("No response (timed out)")) return False def _on_auto_refresh(self): if self._contact: self._request_telemetry() return True return False def _cancel_timeout(self): if self._timeout_id is not None: GLib.source_remove(self._timeout_id) self._timeout_id = None def _cancel_auto_refresh(self): if self._auto_refresh_id is not None: GLib.source_remove(self._auto_refresh_id) self._auto_refresh_id = None def _cleanup_map(self): if self._map_widget: from meshy.views import deregister_map_widget deregister_map_widget(self._map_widget) self._map_widget = None self._map_frame.set_child(None) self._map_frame.set_visible(False) def _remove_dynamic_groups(self): for group in self._dynamic_groups: self._content_box.remove(group) self._dynamic_groups.clear() meshy/src/views/settings_view.py000066400000000000000000001745661523412555500174250ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Settings view for radio configuration and device settings.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gio, GLib, Gtk from meshy.models import Contact, DeviceInfo from meshy.utils import clear_children, format_number, parse_number # (name, freq_mhz, bw_hz, sf, cr, tx_dbm) RADIO_PRESETS = [ ("Australia (Mid)", 915.075, 125000, 9, 5, 20), ("Australia (Narrow)", 916.575, 62500, 7, 8, 20), ("Australia (Wide)", 915.8, 250000, 11, 5, 20), ("Australia SA, WA", 923.125, 62500, 8, 8, 20), ("Australia QLD", 923.125, 62500, 8, 5, 20), ("Brazil", 923.125, 62500, 8, 8, 20), ("Czech Republic", 869.432, 62500, 7, 5, 14), ("EU 433MHz", 433.650, 250000, 11, 5, 20), ("EU/UK (Deprecated)", 869.525, 250000, 11, 5, 14), ("EU/UK (Medium Range)", 869.525, 250000, 10, 5, 14), ("EU/UK (Narrow)", 869.618, 62500, 8, 5, 14), ("New Zealand", 917.375, 250000, 11, 5, 20), ("New Zealand (Narrow)", 917.375, 62500, 7, 5, 20), ("Netherlands", 869.618, 62500, 7, 5, 14), ("Portugal 433", 433.375, 62500, 9, 5, 20), ("Portugal 869", 869.618, 62500, 7, 5, 14), ("Switzerland", 869.618, 62500, 8, 5, 14), ("USA/Canada", 910.525, 62500, 7, 5, 20), ("Vietnam (Narrow)", 920.250, 62500, 8, 5, 20), ("Vietnam (Deprecated)", 920.250, 250000, 11, 5, 20), ] def _off_grid_freq_for(freq_mhz: float) -> float: """Pick the off-grid frequency matching the current band.""" if freq_mhz < 500: return 433.0 if freq_mhz < 900: return 869.495 return 918.0 def _repeat_freq_from_ranges(freq_mhz: float, ranges: list[tuple[int, int]]) -> float | None: """Pick a repeat frequency (MHz) from firmware-reported ranges for the current band.""" freq_khz = int(freq_mhz * 1000) best = None best_dist = float("inf") for lower, upper in ranges: mid = (lower + upper) // 2 dist = abs(mid - freq_khz) if dist < best_dist: best_dist = dist best = mid return best / 1000.0 if best is not None else None @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/settings-view.ui") class SettingsView(Gtk.Box): __gtype_name__ = "MeshySettingsView" _pref_page = Gtk.Template.Child("pref_page") _app_group = Gtk.Template.Child("app_group") _style_row = Gtk.Template.Child("style_row") _theme_row = Gtk.Template.Child("theme_row") _channel_notif_row = Gtk.Template.Child("channel_notif_row") _background_switch = Gtk.Template.Child("background_switch") _autostart_switch = Gtk.Template.Child("autostart_switch") _advert_group = Gtk.Template.Child("advert_group") _name_entry = Gtk.Template.Child("name_entry") _advert_loc_switch = Gtk.Template.Child("advert_loc_switch") _loc_group = Gtk.Template.Child("loc_group") _gps_switch = Gtk.Template.Child("gps_switch") _lat_entry = Gtk.Template.Child("lat_entry") _lon_entry = Gtk.Template.Child("lon_entry") _locate_btn = Gtk.Template.Child("locate_btn") _pick_map_btn = Gtk.Template.Child("pick_map_btn") _radio_group = Gtk.Template.Child("radio_group") _preset_row = Gtk.Template.Child("preset_row") _repeat_switch = Gtk.Template.Child("repeat_switch") _radio_advanced = Gtk.Template.Child("radio_advanced") _freq_entry = Gtk.Template.Child("freq_entry") _bw_entry = Gtk.Template.Child("bw_entry") _sf_spin = Gtk.Template.Child("sf_spin") _cr_spin = Gtk.Template.Child("cr_spin") _tx_spin = Gtk.Template.Child("tx_spin") _routing_help_button = Gtk.Template.Child("routing_help_button") _routing_mode_combo = Gtk.Template.Child("routing_mode_combo") _path_hash_combo = Gtk.Template.Child("path_hash_combo") _routing_group = Gtk.Template.Child("routing_group") _multi_ack_spin = Gtk.Template.Child("multi_ack_spin") _auto_add_group = Gtk.Template.Child("auto_add_group") _auto_add_switch = Gtk.Template.Child("auto_add_switch") _auto_add_expander = Gtk.Template.Child("auto_add_expander") _auto_chat_switch = Gtk.Template.Child("auto_chat_switch") _auto_repeater_switch = Gtk.Template.Child("auto_repeater_switch") _auto_room_switch = Gtk.Template.Child("auto_room_switch") _auto_sensor_switch = Gtk.Template.Child("auto_sensor_switch") _auto_overwrite_switch = Gtk.Template.Child("auto_overwrite_switch") _auto_hop_limit_entry = Gtk.Template.Child("auto_hop_limit_entry") _tele_group = Gtk.Template.Child("tele_group") _tele_base_combo = Gtk.Template.Child("tele_base_combo") _tele_loc_combo = Gtk.Template.Child("tele_loc_combo") _tele_env_combo = Gtk.Template.Child("tele_env_combo") _regions_group = Gtk.Template.Child("regions_group") _regions_wrapbox = Gtk.Template.Child("regions_wrapbox") _region_entry = Gtk.Template.Child("region_entry") _add_region_btn = Gtk.Template.Child("add_region_btn") _default_scope_combo = Gtk.Template.Child("default_scope_combo") _bt_group = Gtk.Template.Child("bt_group") _pin_entry = Gtk.Template.Child("pin_entry") def __init__(self, window, **kwargs): super().__init__(**kwargs) self._window = window self._apply_btn: Gtk.Button | None = None self._clean_snapshot: dict | None = None self._suppressing_changes = False self._applying = False # True while Apply commands are in flight self._applying_preset = False # True while preset is populating fields self._radio_dirty = False # True when user edited radio params since last snapshot self._pre_repeat_snapshot: dict | None = None # radio params before repeat on self._region_rows: dict[str, Gtk.Button] = {} self._section_widgets = [ (_("Application"), self._app_group), (_("Advert"), self._advert_group), (_("Routing and Messaging"), self._routing_group), (_("Location"), self._loc_group), (_("Radio"), self._radio_group), (_("Auto-Add Contacts"), self._auto_add_group), (_("Telemetry Privacy"), self._tele_group), (_("Regions"), self._regions_group), (_("Bluetooth"), self._bt_group), ] # Preset ComboRow model — built from RADIO_PRESETS data preset_model = Gtk.StringList() preset_model.append(_("Custom")) for name, *_rest in RADIO_PRESETS: preset_model.append(name) self._preset_row.set_model(preset_model) self._preset_row.connect("notify::selected", self._on_preset_selected) self._repeat_switch.connect("notify::active", self._on_repeat_toggled) # Telemetry combo models — shared label set tele_labels = Gtk.StringList.new([_("Deny All"), _("Allow per Contact"), _("Allow All")]) self._tele_base_combo.set_model(tele_labels) self._tele_loc_combo.set_model(tele_labels) self._tele_env_combo.set_model(tele_labels) # Default scope combo — starts with "None", regions added dynamically self._scope_model = Gtk.StringList() self._scope_model.append(_("None")) self._default_scope_combo.set_model(self._scope_model) self._default_scope_combo.connect("notify::selected", self._on_default_scope_changed) # Signal connections self._gps_switch.connect("notify::active", self._on_gps_toggled) self._locate_btn.connect("clicked", self._on_locate_clicked) self._pick_map_btn.connect("clicked", self._on_pick_map_clicked) self._region_entry.connect("activate", self._on_add_region) self._add_region_btn.connect("clicked", self._on_add_region) self._region_entry.connect("icon-press", self._on_region_icon_press) # Connect change signals for dirty tracking for entry in (self._name_entry, self._lat_entry, self._lon_entry): entry.connect("changed", lambda *_: self._check_dirty()) self._pin_entry.connect("changed", self._on_pin_changed) # Radio preset params — also reset preset to Custom when edited manually def _on_radio_param_changed(*_args): if not self._suppressing_changes and not self._applying_preset: self._radio_dirty = True if self._preset_row.get_selected() != 0: self._preset_row.set_selected(0) self._check_dirty() for entry in (self._freq_entry, self._bw_entry): entry.connect("changed", _on_radio_param_changed) for spin in (self._sf_spin, self._cr_spin): spin.connect("notify::value", _on_radio_param_changed) self._tx_spin.connect("notify::value", lambda *_: self._check_dirty()) self._multi_ack_spin.connect("notify::value", lambda *_: self._check_dirty()) self._auto_add_switch.connect("notify::active", self._on_auto_add_toggled) for switch in ( self._advert_loc_switch, self._repeat_switch, self._gps_switch, self._auto_add_switch, self._auto_chat_switch, self._auto_repeater_switch, self._auto_room_switch, self._auto_sensor_switch, self._auto_overwrite_switch, ): switch.connect("notify::active", lambda *_: self._check_dirty()) self._auto_hop_limit_entry.connect("changed", lambda *_: self._check_dirty()) self._preset_row.connect("notify::selected", lambda *_: self._check_dirty()) self._path_hash_combo.connect("notify::selected", lambda *_: self._check_dirty()) for combo in (self._tele_base_combo, self._tele_loc_combo, self._tele_env_combo): combo.connect("notify::selected", lambda *_: self._check_dirty()) self._init_style_settings() self._init_routing_settings() def _init_routing_settings(self): mode = self._app_settings.get_string("default-routing-mode") self._routing_mode_combo.set_selected(1 if mode == "smart" else 0) self._routing_mode_combo.connect("notify::selected", self._on_routing_mode_changed) self._routing_help_button.connect("clicked", self._on_routing_help_clicked) def _on_routing_help_clicked(self, _button): Gtk.show_uri(self._window, "https://meshy-app.org/docs/guide/routing/", 0) def _on_routing_mode_changed(self, combo, pspec): mode = "smart" if combo.get_selected() == 1 else "classic" self._app_settings.set_string("default-routing-mode", mode) Contact._global_smart_default = mode == "smart" self._window.update_contacts_from_storage() def _init_style_settings(self): from meshy.theme_manager import THEMES settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy") self._app_settings = settings mode = settings.get_string("color-scheme") scheme_options = ["system", "light", "dark", "custom"] idx = scheme_options.index(mode) if mode in scheme_options else 0 self._style_row.set_selected(idx) self._theme_row.set_visible(idx == 3) theme_id = settings.get_string("custom-theme") theme = THEMES.get(theme_id) if theme: self._theme_row.set_subtitle(theme["name"]) self._style_row.connect("notify::selected", self._on_style_changed) self._theme_row.connect("activated", self._on_theme_row_activated) notif_level = settings.get_int("default-channel-notification-level") self._channel_notif_row.set_selected(max(0, min(notif_level, 2))) self._channel_notif_row.connect("notify::selected", self._on_channel_notif_changed) self._background_switch.set_active(settings.get_boolean("run-in-background")) self._background_switch.connect("notify::active", self._on_background_changed) self._autostart_switch.set_active(settings.get_boolean("autostart")) self._autostart_switch.connect("notify::active", self._on_autostart_changed) def _on_background_changed(self, row, pspec): enabled = row.get_active() self._app_settings.set_boolean("run-in-background", enabled) app = self._window.get_application() if app: app.update_background_mode(enabled) def _on_autostart_changed(self, row, pspec): enabled = row.get_active() self._app_settings.set_boolean("autostart", enabled) app = self._window.get_application() if app: app.update_autostart(enabled) def _on_channel_notif_changed(self, row, pspec): self._app_settings.set_int("default-channel-notification-level", row.get_selected()) def _on_style_changed(self, row, pspec): from meshy.theme_manager import ThemeManager scheme_options = ["system", "light", "dark", "custom"] idx = row.get_selected() mode = scheme_options[idx] if idx < len(scheme_options) else "system" manager = ThemeManager.get_default() if mode == "custom": theme_id = self._app_settings.get_string("custom-theme") manager.set_custom_theme(theme_id) else: manager.set_color_scheme(mode) self._theme_row.set_visible(idx == 3) def _on_theme_row_activated(self, row): from meshy.theme_chooser_dialog import ThemeChooserDialog dialog = ThemeChooserDialog(self._app_settings, self._on_custom_theme_changed) dialog.present(self._window) def _on_custom_theme_changed(self): from meshy.theme_manager import THEMES theme_id = self._app_settings.get_string("custom-theme") theme = THEMES.get(theme_id) if theme: self._theme_row.set_subtitle(theme["name"]) def create_apply_button(self) -> Gtk.Button: """Create an Apply button to be placed in the headerbar.""" btn = Gtk.Button(label=_("Apply")) btn.add_css_class("suggested-action") btn.set_sensitive(False) btn.connect("clicked", self._on_apply_all) self._apply_btn = btn return btn @property def sections(self) -> list[str]: return [name for name, _w in self._section_widgets] def scroll_to_section(self, section_name: str): for name, widget in self._section_widgets: if name == section_name: GLib.idle_add(self._do_scroll_to, widget) break def _do_scroll_to(self, widget): scrolled = widget.get_ancestor(Gtk.ScrolledWindow) if scrolled: success, x, y = widget.translate_coordinates(scrolled.get_child(), 0, 0) if success: adj = scrolled.get_vadjustment() adj.set_value(y) return False def get_auto_add_config(self) -> dict: return { "auto_add_chat": self._auto_chat_switch.get_active(), "auto_add_repeater": self._auto_repeater_switch.get_active(), "auto_add_room_server": self._auto_room_switch.get_active(), "auto_add_sensor": self._auto_sensor_switch.get_active(), "overwrite_oldest": self._auto_overwrite_switch.get_active(), "max_hops": int(self._auto_hop_limit_entry.get_text() or "0"), } def restore_snapshot(self): """Revert all widgets to the last clean state (discard unapplied changes).""" snap = self._clean_snapshot if not snap: return self._suppressing_changes = True self._name_entry.set_text(snap.get("name", "")) self._advert_loc_switch.set_active(snap.get("advert_loc", False)) self._lat_entry.set_text(snap.get("lat", "")) self._lon_entry.set_text(snap.get("lon", "")) self._freq_entry.set_text(snap.get("freq", "")) self._bw_entry.set_text(snap.get("bw", "")) self._sf_spin.set_value(snap.get("sf", 7)) self._cr_spin.set_value(snap.get("cr", 5)) self._tx_spin.set_value(snap.get("tx", 20)) self._preset_row.set_selected(snap.get("preset", 0)) self._repeat_switch.set_active(snap.get("repeat", False)) self._path_hash_combo.set_selected(snap.get("path_hash", 0)) self._multi_ack_spin.set_value(snap.get("multi_ack", 1)) self._auto_add_switch.set_active(snap.get("auto_add", False)) self._auto_add_expander.set_sensitive(snap.get("auto_add", False)) self._auto_chat_switch.set_active(snap.get("auto_chat", False)) self._auto_repeater_switch.set_active(snap.get("auto_repeater", False)) self._auto_room_switch.set_active(snap.get("auto_room", False)) self._auto_sensor_switch.set_active(snap.get("auto_sensor", False)) self._auto_overwrite_switch.set_active(snap.get("auto_overwrite", False)) self._auto_hop_limit_entry.set_text(snap.get("auto_hop_limit", "")) self._tele_base_combo.set_selected(snap.get("tele_base", 0)) self._tele_loc_combo.set_selected(snap.get("tele_loc", 0)) self._tele_env_combo.set_selected(snap.get("tele_env", 0)) self._gps_switch.set_active(snap.get("gps", self._gps_switch.get_active())) self._pin_entry.set_text(snap.get("pin", "")) self._suppressing_changes = False self._radio_dirty = False if self._apply_btn: self._apply_btn.set_sensitive(False) def _take_snapshot(self): """Capture current widget values as the 'clean' state.""" self._clean_snapshot = { "name": self._name_entry.get_text(), "advert_loc": self._advert_loc_switch.get_active(), "lat": self._lat_entry.get_text(), "lon": self._lon_entry.get_text(), "freq": self._freq_entry.get_text(), "bw": self._bw_entry.get_text(), "sf": self._sf_spin.get_value(), "cr": self._cr_spin.get_value(), "tx": self._tx_spin.get_value(), "preset": self._preset_row.get_selected(), "repeat": self._repeat_switch.get_active(), "path_hash": self._path_hash_combo.get_selected(), "multi_ack": self._multi_ack_spin.get_value(), "auto_add": self._auto_add_switch.get_active(), "auto_chat": self._auto_chat_switch.get_active(), "auto_repeater": self._auto_repeater_switch.get_active(), "auto_room": self._auto_room_switch.get_active(), "auto_sensor": self._auto_sensor_switch.get_active(), "auto_overwrite": self._auto_overwrite_switch.get_active(), "auto_hop_limit": self._auto_hop_limit_entry.get_text(), "tele_base": self._tele_base_combo.get_selected(), "tele_loc": self._tele_loc_combo.get_selected(), "tele_env": self._tele_env_combo.get_selected(), "gps": self._gps_switch.get_active(), "pin": self._pin_entry.get_text(), } self._radio_dirty = False if self._apply_btn: self._apply_btn.set_sensitive(False) def _check_dirty(self): """Enable Apply button if any setting differs from the clean snapshot.""" if self._suppressing_changes or not self._clean_snapshot or not self._apply_btn: return snap = self._clean_snapshot dirty = ( self._name_entry.get_text() != snap["name"] or self._advert_loc_switch.get_active() != snap["advert_loc"] or self._lat_entry.get_text() != snap["lat"] or self._lon_entry.get_text() != snap["lon"] or self._freq_entry.get_text() != snap["freq"] or self._bw_entry.get_text() != snap["bw"] or self._sf_spin.get_value() != snap["sf"] or self._cr_spin.get_value() != snap["cr"] or self._tx_spin.get_value() != snap["tx"] or self._preset_row.get_selected() != snap["preset"] or self._repeat_switch.get_active() != snap.get("repeat", False) or self._path_hash_combo.get_selected() != snap.get("path_hash", 0) or self._multi_ack_spin.get_value() != snap.get("multi_ack", 1) or self._auto_add_switch.get_active() != snap.get("auto_add", False) or self._auto_chat_switch.get_active() != snap["auto_chat"] or self._auto_repeater_switch.get_active() != snap["auto_repeater"] or self._auto_room_switch.get_active() != snap["auto_room"] or self._auto_sensor_switch.get_active() != snap["auto_sensor"] or self._auto_overwrite_switch.get_active() != snap["auto_overwrite"] or self._auto_hop_limit_entry.get_text() != snap.get("auto_hop_limit", "") or self._tele_base_combo.get_selected() != snap.get("tele_base", 0) or self._tele_loc_combo.get_selected() != snap.get("tele_loc", 0) or self._tele_env_combo.get_selected() != snap.get("tele_env", 0) or self._gps_switch.get_active() != snap.get("gps", False) or self._pin_entry.get_text() != snap.get("pin", "") ) pin_ok = self._is_pin_valid(self._pin_entry.get_text().strip()) self._apply_btn.set_sensitive(dirty and pin_ok) if not dirty: self._radio_dirty = False def _detect_preset(self, info: DeviceInfo): """Set the preset dropdown to match current radio params, or Custom.""" if info.repeat or not info.radio_freq: self._preset_row.set_selected(0) return for i, (_, freq, bw, sf, cr, _tx) in enumerate(RADIO_PRESETS): if ( abs(info.radio_freq - freq) < 0.001 and abs(info.radio_bw - bw / 1000) < 0.01 and info.radio_sf == sf and info.radio_cr == cr ): self._preset_row.set_selected(i + 1) # +1 for "Custom" at index 0 return self._preset_row.set_selected(0) # Custom def _on_preset_selected(self, combo, *args): idx = combo.get_selected() if self._suppressing_changes: return self._radio_dirty = True if idx == 0: self._check_dirty() return self._applying_preset = True _, freq, bw, sf, cr, _tx = RADIO_PRESETS[idx - 1] self._freq_entry.set_text(format_number(freq, 3)) self._bw_entry.set_text(format_number(bw / 1000, 1)) # Hz to kHz for display self._sf_spin.set_value(sf) self._cr_spin.set_value(cr) self._applying_preset = False # TX power is intentionally not set — it's independent of the preset def _on_repeat_toggled(self, switch, *args): if self._suppressing_changes: return active = switch.get_active() if active: self._pre_repeat_snapshot = { "freq": self._freq_entry.get_text(), "preset": self._preset_row.get_selected(), } try: cur_freq = parse_number(self._freq_entry.get_text()) except ValueError: cur_freq = 918.0 fw_freq = _repeat_freq_from_ranges( cur_freq, self._window._device_info.allowed_repeat_freqs ) repeat_freq = fw_freq if fw_freq is not None else _off_grid_freq_for(cur_freq) self._suppressing_changes = True self._freq_entry.set_text(format_number(repeat_freq, 3)) self._preset_row.set_selected(0) self._suppressing_changes = False self._freq_entry.set_sensitive(False) self._preset_row.set_sensitive(False) else: snap = self._pre_repeat_snapshot if snap: self._suppressing_changes = True self._freq_entry.set_text(snap["freq"]) self._preset_row.set_selected(snap["preset"]) self._suppressing_changes = False self._pre_repeat_snapshot = None self._freq_entry.set_sensitive(True) self._preset_row.set_sensitive(True) self._check_dirty() def _on_auto_add_toggled(self, switch, *args): if self._suppressing_changes: return self._auto_add_expander.set_sensitive(switch.get_active()) def _on_apply_all(self, *args): """Apply all settings to the device, sending only changed values.""" snap = self._clean_snapshot or {} cmds = [] # list of callables to send in sequence # Advert name = self._name_entry.get_text().strip() if name and name != snap.get("name", ""): cmds.append(lambda: self._window.set_advert_name(name)) # Location — send latlon first, then loc policy via set_other_params lat_text = self._lat_entry.get_text().strip() lon_text = self._lon_entry.get_text().strip() try: lat = float(lat_text) if lat_text else 0.0 lon = float(lon_text) if lon_text else 0.0 if lat_text != snap.get("lat", "") or lon_text != snap.get("lon", ""): cmds.append(lambda _lat=lat, _lon=lon: self._window.set_advert_latlon(_lat, _lon)) except ValueError: pass advert_loc = self._advert_loc_switch.get_active() if advert_loc != snap.get("advert_loc", False): policy = 1 if advert_loc else 0 cmds.append(lambda: self._window.set_advert_loc_policy(policy)) # Companion GPS gps = self._gps_switch.get_active() if gps != snap.get("gps", False): value = "1" if gps else "0" cmds.append(lambda: self._window.set_custom_var(f"gps:{value}")) # Telemetry privacy tele_base = self._tele_base_combo.get_selected() tele_loc = self._tele_loc_combo.get_selected() tele_env = self._tele_env_combo.get_selected() if ( tele_base != snap.get("tele_base", 0) or tele_loc != snap.get("tele_loc", 0) or tele_env != snap.get("tele_env", 0) ): cmds.append(lambda: self._window.set_telemetry_mode(tele_base, tele_loc, tele_env)) # Radio try: freq_mhz = parse_number(self._freq_entry.get_text()) bw_khz = parse_number(self._bw_entry.get_text()) sf = int(self._sf_spin.get_value()) cr = int(self._cr_spin.get_value()) power = int(self._tx_spin.get_value()) repeat = self._repeat_switch.get_active() repeat_changed = repeat != snap.get("repeat", False) radio_changed = ( self._freq_entry.get_text() != snap.get("freq", "") or self._bw_entry.get_text() != snap.get("bw", "") or sf != snap.get("sf", 0) or cr != snap.get("cr", 0) ) if radio_changed or repeat_changed: rpt = repeat if repeat_changed else None dev_cr = cr - 4 if self._window._device_info.cr_old_encoding else cr cmds.append( lambda: self._window.set_radio_params( int(freq_mhz * 1000), int(bw_khz * 1000), sf, dev_cr, rpt ) ) if power != snap.get("tx", 0): cmds.append(lambda: self._window.set_tx_power(power)) except ValueError: pass # Path hash mode if ( self._path_hash_combo.get_sensitive() and self._path_hash_combo.get_selected() != snap.get("path_hash", 0) ): mode = self._path_hash_combo.get_selected() cmds.append(lambda: self._window.set_path_hash_mode(mode)) # Multi ACK (display 1/2 → firmware 0/1) multi_ack_val = int(self._multi_ack_spin.get_value()) if multi_ack_val != int(snap.get("multi_ack", 1)): fw_val = multi_ack_val - 1 cmds.append(lambda: self._window.set_multi_acks(fw_val)) # Auto-add auto_add = self._auto_add_switch.get_active() hop_text = self._auto_hop_limit_entry.get_text().strip() hop_limit = int(hop_text) if hop_text.isdigit() else 0 if ( auto_add != snap.get("auto_add", False) or self._auto_chat_switch.get_active() != snap.get("auto_chat", False) or self._auto_repeater_switch.get_active() != snap.get("auto_repeater", False) or self._auto_room_switch.get_active() != snap.get("auto_room", False) or self._auto_sensor_switch.get_active() != snap.get("auto_sensor", False) or self._auto_overwrite_switch.get_active() != snap.get("auto_overwrite", False) or self._auto_hop_limit_entry.get_text() != snap.get("auto_hop_limit", "") ): from meshy.protocol import build_set_auto_add_config if auto_add: frame = build_set_auto_add_config( chat=self._auto_chat_switch.get_active(), repeater=self._auto_repeater_switch.get_active(), room=self._auto_room_switch.get_active(), sensor=self._auto_sensor_switch.get_active(), overwrite_oldest=self._auto_overwrite_switch.get_active(), max_hops=hop_limit, ) else: frame = build_set_auto_add_config() cmds.append(lambda: self._window.send_frame(frame)) # BLE PIN pin_text = self._pin_entry.get_text().strip() if pin_text != snap.get("pin", "") and pin_text.isdigit(): pin_val = int(pin_text) cmds.append(lambda: self._window.set_device_pin(pin_val)) if not cmds: return self._applying = True self._take_snapshot() self._send_next_setting(cmds, 0) def _send_next_setting(self, cmds, index): if index >= len(cmds): self._applying = False self._sync_device_info_from_widgets() self._window.show_toast(_("Settings applied on device")) from meshy.protocol import build_app_start self._window.send_frame(build_app_start()) return self._window._frame_handler.expect_ok( on_ok=lambda: self._send_next_setting(cmds, index + 1), on_error=lambda err: self._on_setting_error(err), ) cmds[index]() def _sync_device_info_from_widgets(self): info = self._window._device_info try: info.radio_freq = parse_number(self._freq_entry.get_text()) info.radio_bw = parse_number(self._bw_entry.get_text()) info.radio_sf = int(self._sf_spin.get_value()) info.radio_cr = int(self._cr_spin.get_value()) info.tx_power = int(self._tx_spin.get_value()) except ValueError: pass self._window._device_view.update_device_info(info) def _on_setting_error(self, err): self._applying = False msg = err["message"] if err else _("Unknown error") self._window.show_toast(_("Settings error: {}").format(msg)) def update_device_info(self, info: DeviceInfo): """Update fields from device info.""" if self._applying: return if self._radio_dirty: return self._suppressing_changes = True if info.repeat: fw_freq = _repeat_freq_from_ranges(info.radio_freq, info.allowed_repeat_freqs) repeat_freq = fw_freq if fw_freq is not None else _off_grid_freq_for(info.radio_freq) self._freq_entry.set_text(format_number(repeat_freq, 3)) elif info.radio_freq: self._freq_entry.set_text(format_number(info.radio_freq, 3)) if info.radio_bw: self._bw_entry.set_text(format_number(info.radio_bw, 1)) if info.radio_sf: self._sf_spin.set_value(info.radio_sf) if info.radio_cr: self._cr_spin.set_value(info.radio_cr) if info.max_tx_power: adj = self._tx_spin.get_adjustment() adj.set_upper(info.max_tx_power) self._tx_spin.set_title(_("TX Power (dBm, max {})").format(info.max_tx_power)) if info.tx_power: self._tx_spin.set_value(info.tx_power) if info.fw_ver >= 9: self._repeat_switch.set_visible(True) self._repeat_switch.set_active(info.repeat) self._preset_row.set_sensitive(not info.repeat) self._freq_entry.set_sensitive(not info.repeat) if info.fw_ver >= 10: self._path_hash_combo.set_selected(info.path_hash_mode) self._path_hash_combo.set_sensitive(True) self._multi_ack_spin.set_value(info.multi_acks + 1) self._multi_ack_spin.set_sensitive(True) if info.name: self._name_entry.set_text(info.name) if info.ble_pin: self._pin_entry.set_text(str(info.ble_pin)) # Auto-detect matching preset from current radio params self._detect_preset(info) self._suppressing_changes = False self._take_snapshot() def update_transport_type(self, transport_type: str): """Show Bluetooth group only when connected via BLE.""" self._bt_group.set_visible(transport_type == "ble") def update_gps_settings(self, custom_vars: dict): """Update GPS toggle from custom vars.""" if "gps" in custom_vars: self._gps_switch.set_visible(True) self._suppressing_changes = True self._gps_switch.set_active(custom_vars.get("gps") == "1") self._suppressing_changes = False if self._clean_snapshot is not None: self._clean_snapshot["gps"] = self._gps_switch.get_active() def _on_gps_toggled(self, switch, *args): if self._suppressing_changes: return def _is_pin_valid(self, text: str) -> bool: if not text: return True if not text.isdigit(): return False val = int(text) return val == 0 or 100000 <= val <= 999999 def _on_pin_changed(self, entry, *args): text = entry.get_text().strip() if self._is_pin_valid(text): entry.remove_css_class("error") else: entry.add_css_class("error") self._check_dirty() def update_telemetry_modes(self, base: int, loc: int, env: int): """Populate the telemetry privacy combos from device self_info.""" if self._applying: return self._suppressing_changes = True self._tele_base_combo.set_selected(base) self._tele_loc_combo.set_selected(loc) self._tele_env_combo.set_selected(env) self._suppressing_changes = False self._take_snapshot() def update_multi_acks(self, value: int): if self._applying: return self._suppressing_changes = True self._multi_ack_spin.set_value(value + 1) self._suppressing_changes = False self._take_snapshot() def update_advert_location(self, lat: float, lon: float, loc_policy: int = 0): """Populate the lat/lon fields and location policy from the device.""" if self._applying: return self._suppressing_changes = True self._advert_loc_switch.set_active(loc_policy > 0) if abs(lat) > 1e-6 or abs(lon) > 1e-6: self._lat_entry.set_text(str(lat)) self._lon_entry.set_text(str(lon)) self._suppressing_changes = False self._take_snapshot() def _on_locate_clicked(self, btn): """Use platform location service to get the computer's location and fill lat/lon fields.""" from meshy.location import LocationService btn.set_sensitive(False) def _on_location(lat, lon, error): if error: self._window.show_toast(_("Location unavailable")) else: self._lat_entry.set_text(f"{lat:.6f}") self._lon_entry.set_text(f"{lon:.6f}") self._window.show_toast(_("Location filled, click Apply to save")) btn.set_sensitive(True) LocationService.get_location(_on_location) def _on_pick_map_clicked(self, btn): """Open a map dialog where the user can click to pick a location.""" from meshy.views import open_map_picker init_lat, init_lon = 0.0, 0.0 try: lat = float(self._lat_entry.get_text()) lon = float(self._lon_entry.get_text()) if abs(lat) > 1e-6 or abs(lon) > 1e-6: init_lat, init_lon = lat, lon except ValueError: pass def _on_pick(lat, lon): self._lat_entry.set_text(f"{lat:.6f}") self._lon_entry.set_text(f"{lon:.6f}") open_map_picker( self._window, _("Pick Location"), _("Set Location"), init_lat, init_lon, _on_pick ) def load_regions(self): """Load regions from storage and populate the UI.""" if not self._window.storage: return for name in self._window.storage.get_regions(): self._add_region_chip(name) def _on_region_icon_press(self, _entry, icon_pos): if icon_pos == Gtk.EntryIconPosition.SECONDARY: self._on_discover_regions(None) def _on_add_region(self, _entry): name = self._region_entry.get_text().strip() if not name: return if name.startswith("#"): name = name[1:] if not name: return if name in self._region_rows: return if self._window.storage: self._window.storage.add_region(name) self._add_region_chip(name) self._region_entry.set_text("") def _add_region_chip(self, name: str): if name in self._region_rows: return content = Gtk.Box(spacing=4) content.append(Gtk.Label(label=name)) content.append(Gtk.Image(icon_name="window-close-symbolic")) chip = Gtk.Button(child=content) chip.add_css_class("flat") chip.add_css_class("region-chip") chip.connect("clicked", lambda *_, n=name: self._remove_region(n)) self._regions_wrapbox.append(chip) self._region_rows[name] = chip self._scope_model.append(name) self._regions_wrapbox.set_visible(True) def _remove_region(self, name: str): chip = self._region_rows.pop(name, None) if chip: self._regions_wrapbox.remove(chip) if not self._region_rows: self._regions_wrapbox.set_visible(False) # Remove from scope combo for i in range(self._scope_model.get_n_items()): if self._scope_model.get_string(i) == name: if self._default_scope_combo.get_selected() == i: self._default_scope_combo.set_selected(0) self._scope_model.remove(i) break if self._window.storage: self._window.storage.remove_region(name) # Clear region from in-memory channels too affected = False for ch in self._window.channels: if ch.region == name: ch.region = "" affected = True if affected: self._window._channels_view.update_channels(self._window.channels) def get_regions(self) -> list[str]: return list(self._region_rows.keys()) @property def default_scope(self) -> str: idx = self._default_scope_combo.get_selected() if idx == 0 or idx == Gtk.INVALID_LIST_POSITION: return "" return self._scope_model.get_string(idx) or "" def _on_default_scope_changed(self, combo, *args): if self._suppressing_changes: return scope = self.default_scope if self._window.storage: self._window.storage.set_default_scope(scope) if self._window.is_connected: from meshy.protocol import build_set_default_flood_scope self._window.send_frame(build_set_default_flood_scope(scope)) def restore_default_scope(self): """Restore default scope selection from storage.""" if not self._window.storage: return scope = self._window.storage.get_default_scope() if not scope: return self._suppressing_changes = True for i in range(self._scope_model.get_n_items()): if self._scope_model.get_string(i) == scope: self._default_scope_combo.set_selected(i) break self._suppressing_changes = False def _on_discover_regions(self, *args): """Broadcast NODE_DISCOVER_REQ, then query responding repeaters for regions.""" from types import SimpleNamespace from meshy.models import ContactType if not self._window.is_connected: self._window.show_toast(_("Connect to a device first")) return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/discover-regions-dialog.ui" ) dialog = builder.get_object("dialog") spinner = builder.get_object("spinner") status_label = builder.get_object("status_label") list_box = builder.get_object("list_box") add_btn = builder.get_object("add_btn") status_label.set_label(_("Discovering repeaters in range...")) discovered_regions: dict[str, Gtk.CheckButton] = {} region_rows: dict[str, Adw.ActionRow] = {} region_sources: dict[str, list[str]] = {} seen_repeaters: set[str] = set() pending_regions = [0] finished = [False] query_queue: list[tuple[str, str]] = [] query_timer_id = [0] def _check_finished(): if pending_regions[0] <= 0 and finished[0]: spinner.set_spinning(False) spinner.set_visible(False) count = len(discovered_regions) if count: status_label.set_label( _("Found {} regions").format(count) if count != 1 else _("Found {} region").format(count) ) add_btn.set_label(_("Add Selected")) add_btn.set_sensitive(True) else: if self._window._table_full_error: status_label.set_label( _( "Contact list on the device is full. Remove some contacts to discover regions." ) ) elif seen_repeaters: status_label.set_label(_("No regions found")) else: status_label.set_label(_("No repeaters in range")) add_btn.set_label(_("Retry")) add_btn.set_sensitive(True) def _on_region_response(repeater_name, region_text): def _update_ui(): pending_regions[0] -= 1 if region_text: for line in region_text.replace(",", "\n").split("\n"): name = line.strip().strip("#") if not name or name == "*": continue if name in discovered_regions: sources = region_sources.get(name, []) if repeater_name not in sources: sources.append(repeater_name) subtitle = _("from {}").format(", ".join(sources)) if name in self._region_rows: subtitle += " " + _("(already added)") region_rows[name].set_subtitle(subtitle) continue already_added = name in self._region_rows check = Gtk.CheckButton(active=not already_added) check.set_valign(Gtk.Align.CENTER) region_sources[name] = [repeater_name] subtitle = _("from {}").format(repeater_name) if already_added: subtitle += " " + _("(already added)") row = Adw.ActionRow(title=name, subtitle=subtitle) row.add_prefix(check) if already_added: check.set_sensitive(False) list_box.append(row) discovered_regions[name] = check region_rows[name] = row _check_finished() GLib.idle_add(_update_ui) def _send_region_query(pub_key_hex, repeater_name): contact = self._window.find_contact_by_key_prefix(pub_key_hex) if not contact: pub_key = bytes.fromhex(pub_key_hex) contact = SimpleNamespace( public_key=pub_key, public_key_hex=pub_key_hex, ) name = getattr(contact, "name", None) or repeater_name pending_regions[0] += 1 responded = [False] def _handle_response(text): if responded[0]: return responded[0] = True _on_region_response(name, text) self._window.request_regions( contact, lambda text: _handle_response(text), ) def _retry(): if responded[0]: return False self._window.request_regions( contact, lambda text: _handle_response(text), ) return False def _give_up(): _handle_response(None) return False GLib.timeout_add(5000, _retry) GLib.timeout_add(10000, _give_up) def _process_query_queue(): if not query_queue: query_timer_id[0] = 0 return False pub_key_hex, repeater_name = query_queue.pop(0) _send_region_query(pub_key_hex, repeater_name) if not query_queue: query_timer_id[0] = 0 return False return True def _enqueue_repeater(pub_key_hex, repeater_name): if pub_key_hex in seen_repeaters: return seen_repeaters.add(pub_key_hex) query_queue.append((pub_key_hex, repeater_name)) status_label.set_label( _("Querying {} repeaters...").format(len(seen_repeaters)) if len(seen_repeaters) != 1 else _("Querying {} repeater...").format(len(seen_repeaters)) ) if not query_timer_id[0]: _process_query_queue() if query_queue: query_timer_id[0] = GLib.timeout_add(1000, _process_query_queue) def _on_discover_response(result): node_type = result.get("node_type", 0) if node_type not in (ContactType.REPEATER, ContactType.ROOM): return pub_key_hex = result.get("pub_key_hex", "") if not pub_key_hex: return contact = self._window.find_contact_by_key_prefix(pub_key_hex) name = contact.name if contact else f"0x{pub_key_hex[:12].upper()}" GLib.idle_add(lambda: _enqueue_repeater(pub_key_hex, name) or False) def _on_timeout(): self._window.stop_discover() self._window.set_advert_listener(None) finished[0] = True _check_finished() return False def _on_advert(contact): if contact.type not in (ContactType.REPEATER, ContactType.ROOM): return GLib.idle_add( lambda: _enqueue_repeater(contact.public_key_hex, contact.name) or False, ) def _start_discover(): discovered_regions.clear() region_rows.clear() region_sources.clear() seen_repeaters.clear() pending_regions[0] = 0 finished[0] = False self._window._table_full_error = False query_queue.clear() if query_timer_id[0]: GLib.source_remove(query_timer_id[0]) query_timer_id[0] = 0 clear_children(list_box) spinner.set_visible(True) spinner.set_spinning(True) status_label.set_label(_("Discovering repeaters in range...")) add_btn.set_label(_("Add Selected")) add_btn.set_sensitive(False) self._window.set_advert_listener(_on_advert) self._window.send_discover_req(_on_discover_response) GLib.timeout_add( 200, lambda: self._window.send_advert(flood=False, silent=True) or False, ) GLib.timeout_add( 3000, lambda: self._window.send_discover_req(_on_discover_response) or False, ) GLib.timeout_add( 6000, lambda: self._window.send_discover_req(_on_discover_response) or False, ) GLib.timeout_add_seconds(15, _on_timeout) def _on_dialog_closed(d): self._window.stop_discover() self._window.set_advert_listener(None) query_queue.clear() if query_timer_id[0]: GLib.source_remove(query_timer_id[0]) query_timer_id[0] = 0 dialog.connect("closed", _on_dialog_closed) _start_discover() def _on_add(*_args): if not discovered_regions: _start_discover() return count = 0 for name, check in discovered_regions.items(): if check.get_active() and name not in self._region_rows: if self._window.storage: self._window.storage.add_region(name) self._add_region_chip(name) count += 1 dialog.close() if count: self._window.show_toast( _("Added {} regions").format(count) if count != 1 else _("Added {} region").format(count) ) add_btn.connect("clicked", _on_add) dialog.present(self._window) def update_auto_add_config(self, config: dict): """Update auto-add switches from device config.""" self._suppressing_changes = True flags = config.get("config", 0) any_type = bool(flags & 0x1E) self._auto_add_switch.set_active(any_type) self._auto_add_expander.set_sensitive(any_type) self._auto_chat_switch.set_active(bool(flags & 0x02)) self._auto_repeater_switch.set_active(bool(flags & 0x04)) self._auto_room_switch.set_active(bool(flags & 0x08)) self._auto_sensor_switch.set_active(bool(flags & 0x10)) self._auto_overwrite_switch.set_active(bool(flags & 0x01)) max_hops = config.get("max_hops", 0) self._auto_hop_limit_entry.set_text(str(max_hops) if max_hops > 0 else "") self._suppressing_changes = False self._take_snapshot() # ─── Backup & Restore ───────────────────────────────────── _SECTION_KEYS = [ "device_settings", "contacts", "channels", "messages", "regions", "discovered_contacts", "path_history", "local_settings", "device_identity", ] _SECTION_GROUPS = { "group_settings": ["device_settings", "local_settings", "regions"], "group_contacts": ["contacts", "discovered_contacts"], "group_messages": ["messages", "path_history"], } @staticmethod def _connect_group_checks(builder, groups): for group_id, child_keys in groups.items(): group_cb = builder.get_object(f"check_{group_id}") children = [builder.get_object(f"check_{k}") for k in child_keys] def _on_group_toggled(cb, _pspec, kids=children): active = cb.get_active() for kid in kids: kid.set_active(active) def _on_child_toggled(cb, group=group_cb, kids=children): if not cb.get_active(): if all(not k.get_active() for k in kids): group.set_active(False) elif not group.get_active(): group.set_active(True) group_cb.connect("notify::active", _on_group_toggled) for child in children: child.connect("toggled", _on_child_toggled) def _on_manual_backup(self, *args): if not self._window.is_connected: dialog = Adw.AlertDialog( heading=_("Not Connected"), body=_("Connect to a device first to export a backup."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/manual-backup-dialog.ui" ) dialog = builder.get_object("manual_backup_dialog") checks = {key: builder.get_object(f"check_{key}") for key in self._SECTION_KEYS} self._connect_group_checks(builder, self._SECTION_GROUPS) def on_export(*_args): sections = {k for k, cb in checks.items() if cb.get_active()} if not sections: return dialog.close() self._save_backup_to_file(sections) builder.get_object("export_btn").connect("clicked", on_export) dialog.present(self._window) def _save_backup_to_file(self, sections: set[str]): import json from datetime import datetime backup = self._window.export_backup(sections=sections) def _do_save(backup_data): name = backup_data.get("name", "meshy").replace(" ", "_") ts = datetime.now().strftime("%Y-%m-%d-%H%M%S") default_name = f"{name}_meshcore_config_{ts}.json" file_dialog = Gtk.FileDialog() file_dialog.set_initial_name(default_name) json_filter = Gtk.FileFilter() json_filter.set_name(_("JSON files")) json_filter.add_pattern("*.json") filters = Gio.ListStore.new(Gtk.FileFilter) filters.append(json_filter) file_dialog.set_filters(filters) def on_save_response(dialog, result): try: gfile = dialog.save_finish(result) if gfile: path = gfile.get_path() with open(path, "w", encoding="utf-8") as f: json.dump(backup_data, f, indent=2, ensure_ascii=False) self._window.show_toast(_("Backup exported successfully")) except GLib.Error: pass except Exception as e: self._window.show_toast(_("Export failed: {}").format(e), timeout=5) file_dialog.save(self._window, None, on_save_response) if "device_identity" in sections: def _on_key(key_bytes): if key_bytes: backup["private_key"] = key_bytes.hex() else: self._window.show_toast(_("Device does not support key export"), timeout=3) _do_save(backup) self._window.export_private_key(_on_key) else: _do_save(backup) def _on_auto_backup_settings(self, *args): settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/auto-backup-dialog.ui" ) dialog = builder.get_object("auto_backup_dialog") enable_switch = builder.get_object("enable_switch") checks = {key: builder.get_object(f"check_{key}") for key in self._SECTION_KEYS} enable_switch.set_active(settings.get_boolean("auto-backup")) saved_sections = set(settings.get_strv("auto-backup-sections")) for key, cb in checks.items(): cb.set_active(key in saved_sections) self._connect_group_checks(builder, self._SECTION_GROUPS) def on_enable_changed(*_args): active = enable_switch.get_active() settings.set_boolean("auto-backup", active) if active and self._window.is_connected: self._window._auto_backup.schedule(self._window) elif not active: self._window._auto_backup.cancel() def on_section_changed(*_args): selected = [k for k, cb in checks.items() if cb.get_active()] settings.set_strv("auto-backup-sections", selected) enable_switch.connect("notify::active", on_enable_changed) for cb in checks.values(): cb.connect("toggled", on_section_changed) dialog.present(self._window) def _on_import_backup(self, *args): if not self._window.is_connected: dialog = Adw.AlertDialog( heading=_("Not Connected"), body=_("Connect to a device first to import a backup."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return file_dialog = Gtk.FileDialog() json_filter = Gtk.FileFilter() json_filter.set_name(_("JSON files")) json_filter.add_pattern("*.json") filters = Gio.ListStore.new(Gtk.FileFilter) filters.append(json_filter) file_dialog.set_filters(filters) def on_open_response(dialog, result): try: gfile = dialog.open_finish(result) if gfile: self._show_import_preview(gfile.get_path()) except GLib.Error: pass file_dialog.open(self._window, None, on_open_response) def _on_restore_auto_backup(self, *args): if not self._window.is_connected: dialog = Adw.AlertDialog( heading=_("Not Connected"), body=_("Connect to a device first to restore a backup."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return device_addr = self._window._transport.connected_address backups = self._window._auto_backup.list_backups(device_addr) if not backups: dialog = Adw.AlertDialog( heading=_("No Auto Backups"), body=_("No automatic backups found for this device."), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return dialog = Adw.AlertDialog(heading=_("Pick Snapshot")) dialog.add_response("cancel", _("Cancel")) listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) listbox.add_css_class("boxed-list") selected_path = [None] from meshy.views import _format_date_text for b in backups: row = Adw.ActionRow( title=_format_date_text(b["mtime_dt"]), subtitle=b["time"], ) row._backup_path = b["path"] listbox.append(row) if listbox.get_row_at_index(0): listbox.select_row(listbox.get_row_at_index(0)) def on_row_selected(lb, row): if row: selected_path[0] = row._backup_path listbox.connect("row-selected", on_row_selected) selected_path[0] = backups[0]["path"] dialog.add_response("restore", _("Restore")) dialog.set_response_appearance("restore", Adw.ResponseAppearance.SUGGESTED) dialog.set_extra_child(listbox) def on_response(d, response): if response == "restore" and selected_path[0]: self._show_import_preview(selected_path[0]) dialog.connect("response", on_response) dialog.present(self._window) def _show_import_preview(self, path: str): import json from meshy.backup import BackupManager try: with open(path, encoding="utf-8") as f: backup = json.load(f) except Exception as e: dialog = Adw.AlertDialog( heading=_("Import Failed"), body=_("Could not read backup file: {}").format(e), ) dialog.add_response("ok", _("OK")) dialog.present(self._window) return available = BackupManager.available_sections(backup) source_name = backup.get("name", _("Unknown")) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/import-backup-dialog.ui" ) dialog = builder.get_object("import_backup_dialog") checks = {key: builder.get_object(f"check_{key}") for key in self._SECTION_KEYS} self._connect_group_checks(builder, self._SECTION_GROUPS) builder.get_object("source_label").set_label(_("Source: {}").format(source_name)) for key, cb in checks.items(): if key not in available: cb.set_active(False) cb.set_sensitive(False) for group_id, child_keys in self._SECTION_GROUPS.items(): if not any(k in available for k in child_keys): group_cb = builder.get_object(f"check_{group_id}") group_cb.set_active(False) group_cb.set_sensitive(False) if "channels" not in available: row = builder.get_object("row_channels") row.set_sensitive(False) if "device_identity" not in available: row = builder.get_object("row_device_identity") row.set_sensitive(False) def on_restore(*_args): sections = {k for k, cb in checks.items() if cb.get_active()} if not sections: return dialog.close() def _show_result(result): contacts, channels = result parts = [] if contacts: parts.append(_("{} contacts").format(contacts)) if channels: parts.append(_("{} channels").format(channels)) if "messages" in sections: n_messages = sum(len(msgs) for msgs in backup.get("messages", {}).values()) if n_messages: parts.append(_("messages")) if "device_identity" in sections and backup.get("private_key"): parts.append(_("device identity")) if parts: self._window.show_toast(_("Imported: {}").format(", ".join(parts)), timeout=4) else: self._window.show_toast(_("Nothing new to import")) self._window.import_backup(backup, sections=sections, on_complete=_show_result) builder.get_object("restore_btn").connect("clicked", on_restore) dialog.present(self._window) meshy/src/window.py000066400000000000000000002345711523412555500146760ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Main application window with adaptive navigation.""" import collections import logging import time from datetime import datetime import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gio, GLib, Gtk, Pango from meshy.backup import AutoBackupManager, BackupManager from meshy.ble import BleDevice, BleTransport from meshy.connection_controller import ConnectionController from meshy.frame_handler import FrameHandler from meshy.message_controller import MessageController from meshy.models import ( Channel, Contact, ContactType, DeviceInfo, Message, battery_icon_name, snr_to_icon, snr_to_label, ) from meshy.notification import NotificationService from meshy.protocol import ( STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO, build_add_update_contact, build_anon_req_regions, build_export_contact, build_export_private_key, build_get_advert_path, build_get_channel, build_get_neighbors, build_get_stats, build_import_contact, build_import_private_key, build_path_discovery, build_reboot, build_remove_contact, build_reset_path, build_send_cli_command, build_send_discover_req, build_send_self_advert, build_send_status_req, build_set_advert_latlon, build_set_advert_name, build_set_channel, build_set_custom_var, build_set_default_flood_scope, build_set_device_pin, build_set_flood_scope, build_set_path_hash_mode, build_set_radio_params, build_set_radio_tx_power, build_trace_path, ) from meshy.storage import Storage from meshy.transport_base import ConnectionState from meshy.utils import clear_children, format_number from meshy.views.map_view import MapView log = logging.getLogger(__name__) @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/window.ui") class MeshyWindow(Adw.ApplicationWindow): __gtype_name__ = "MeshyWindow" _toast_overlay = Gtk.Template.Child("toast_overlay") _main_stack = Gtk.Template.Child("main_stack") _connection_box = Gtk.Template.Child("connection_box") _connection_screen_banner = Gtk.Template.Child("connection_screen_banner") _connecting_status = Gtk.Template.Child("connecting_status") _connecting_spinner = Gtk.Template.Child("connecting_spinner") _outer_split = Gtk.Template.Child("outer_split") _split_view = Gtk.Template.Child("split_view") _nav_rail = Gtk.Template.Child("nav_rail") _nav_device = Gtk.Template.Child("nav_device") _nav_contacts = Gtk.Template.Child("nav_contacts") _nav_contacts_dot = Gtk.Template.Child("nav_contacts_dot") _nav_channels = Gtk.Template.Child("nav_channels") _nav_channels_dot = Gtk.Template.Child("nav_channels_dot") _nav_map = Gtk.Template.Child("nav_map") _nav_settings = Gtk.Template.Child("nav_settings") _sidebar_header = Gtk.Template.Child("sidebar_header") _nav_rail_toggle = Gtk.Template.Child("nav_rail_toggle") _sidebar_banner = Gtk.Template.Child("sidebar_banner") _sidebar_page = Gtk.Template.Child("sidebar_page") _sidebar_box = Gtk.Template.Child("sidebar_box") _content_header = Gtk.Template.Child("content_header") _companion_box = Gtk.Template.Child("companion_box") _companion_name_label = Gtk.Template.Child("companion_name_label") _companion_separator = Gtk.Template.Child("companion_separator") _companion_battery_icon = Gtk.Template.Child("companion_battery_icon") _companion_signal_icon = Gtk.Template.Child("companion_signal_icon") _content_box = Gtk.Template.Child("content_box") _content_page = Gtk.Template.Child("content_page") def __init__(self, **kwargs): super().__init__(**kwargs) # Add top margin on macOS to avoid window controls overlapping nav rail from meshy.platforms import is_macos if is_macos(): self._nav_rail.set_margin_top(60) self._ble = BleTransport() # BLE transport (None if not available on macOS) self._usb = None # USB transport (created on demand) self._tcp = None # TCP transport (created on demand) self._transport = self._ble if self._ble else None # Active transport self._storage: Storage | None = None self._device_info = DeviceInfo() self._device_stats: dict = {} # stats from CMD_GET_STATS self._key_store = None # meshcoredecoder key store for LOG_DATA self._channel_hash_map = {} # channel_hash_hex → channel_index self._self_info: dict = {} self._contacts: list[Contact] = [] self._contacts_by_key: dict[str, Contact] = {} # pub_key_hex -> Contact self._discovered: list[Contact] = [] # heard adverts not yet added self._discovered_by_key: dict[str, Contact] = {} self._channels: list[Channel] = [] self._msg_ctrl = MessageController(self) self._conn_ctrl = ConnectionController(self) self._pending_clipboard_export: bool = False self._neighbors_callback = None self._advert_listener = None # callback for live advert monitoring self._discover_callback = None # callback for discover responses self._trace_callback = None # callback for trace path responses self._trace_timeout_callback = None # callback for trace timeout from device self._advert_path_callback = None # callback for advert path responses self._private_key_callback = None # callback for private key export response self._path_discovery_callback = None # callback for path discovery responses self._acl_callback = None # callback for ACL responses self._telemetry_callback = None # callback for telemetry responses self._login_callback = None # callback for room login response self._login_target_prefix = None # 6-byte prefix of room/repeater being logged into self._login_is_admin = False # whether last login was admin self._login_retry_timeout_id = None self._login_pub_key = None self._login_password = None self._login_on_retry = None self._login_was_flood_retry = False self._logged_in_rooms: dict[str, int] = {} # pub_key_hex -> acl_permissions self._cli_response_callback = None # callback for CLI responses from rooms/repeaters self._cli_prefix_counter = 0 # cycling 00-FF prefix for CLI correlation self._cli_pending_prefixes = {} # prefix_str -> callback(text) self._status_callback = None # callback for binary status responses self._region_callbacks: dict[str, callable] = {} # ack_hex -> callback for region responses self._table_full_error = False self._pending_binary_requests: dict[str, dict] = {} # tag_hex -> {type, callback} self._ignore_next_err = False # suppress next ERR toast (e.g. time sync rejection) self._rx_log = collections.deque(maxlen=500) self._rx_log_callback = None # live-update callback when Rx Log dialog is open self._radio_stats_callback = None self._collapsed_handler_ids: list[int] = [] # split_view signal handlers to clean up self._custom_vars: dict = {} # key -> value from firmware self._background_mode = False self._direct_repeaters: dict = {} # key -> (snr, monotonic_time, miss_count) self._repeaters_refreshed: set = set() self._signal_icon_timer_id = None self._settings = Gio.Settings.new("page.codeberg.sesivany.Meshy") Contact._global_smart_default = self._settings.get_string("default-routing-mode") == "smart" self._backup = BackupManager() self._auto_backup = AutoBackupManager() self._frame_handler = FrameHandler(self) self._notification_service = NotificationService(self.get_application()) if self._ble: self._conn_ctrl.setup_transport_callbacks(self._ble) self._build_ui() self._restore_window_state() # Add macOS-specific CSS class for traffic light button spacing from meshy.platforms import is_macos if is_macos(): self.add_css_class("macos-window") # Clear unread indicators when the window regains focus self.connect("notify::is-active", self._on_window_active_changed) # Auto-connect to last known device on startup GLib.timeout_add(500, self._conn_ctrl.auto_connect) @property def _connection_banner(self): page = self._main_stack.get_visible_child_name() if page == "connection": return self._connection_screen_banner return self._sidebar_banner def _restore_window_state(self): self.set_default_size( self._settings.get_int("window-width"), self._settings.get_int("window-height") ) # Restore maximized state if self._settings.get_boolean("window-maximized"): self.maximize() self.connect("close-request", self._on_close_request) def set_background_mode(self, enabled): self._background_mode = enabled def shutdown(self): """Full cleanup for application quit.""" self._conn_ctrl.manual_disconnect = True self._conn_ctrl.cancel_reconnect() if self._transport: self._transport.disconnect() if self._storage: self._storage.close() if self._conn_ctrl.batt_timer_id: GLib.source_remove(self._conn_ctrl.batt_timer_id) self._conn_ctrl.batt_timer_id = None if self._conn_ctrl.watchdog_timer_id: GLib.source_remove(self._conn_ctrl.watchdog_timer_id) self._conn_ctrl.watchdog_timer_id = None def _on_close_request(self, *args): self._settings.set_int("window-width", self.get_width()) self._settings.set_int("window-height", self.get_height()) self._settings.set_boolean("window-maximized", self.is_maximized()) if self._background_mode and self.is_connected: self.set_visible(False) return True app = self.get_application() if app and hasattr(app, "_background_held") and app._background_held: app.release() app._background_held = False self.shutdown() def _build_ui(self): # Views are created lazily on first access to reduce startup memory. # Only ConnectionView is needed immediately for the connect screen. self._device_view_inst = None self._contacts_view_inst = None self._chat_view_inst = None self._channels_view_inst = None self._settings_view_inst = None self._repeater_view_inst = None self._sensor_view_inst = None self._map_view_inst = None self._saved_map_state = None from meshy.views.connection_view import ConnectionView self._connection_view = ConnectionView(self) self._current_view = "device" self._content_header_extra = None self._content_title_btn = None self._content_title_label = None self._sidebar_header_extras = [] # Nav button dict for toggling self._nav_buttons = { "device": self._nav_device, "contacts": self._nav_contacts, "channels": self._nav_channels, "map": self._nav_map, "settings": self._nav_settings, } for view_id, btn in self._nav_buttons.items(): btn.connect("toggled", self._on_nav_toggled, view_id) # Actions disconnect_action = Gio.SimpleAction.new("disconnect", None) disconnect_action.connect("activate", self._on_menu_disconnect) self.add_action(disconnect_action) quit_action = Gio.SimpleAction.new("quit", None) quit_action.connect("activate", lambda *_: self.get_application()._do_full_quit()) self.add_action(quit_action) about_action = Gio.SimpleAction.new("about", None) about_action.connect("activate", lambda *_: self.get_application()._on_about(None, None)) self.add_action(about_action) help_action = Gio.SimpleAction.new("help", None) help_action.connect("activate", self._on_help) self.add_action(help_action) import meshy as _meshy_pkg if _meshy_pkg.SHORTCUTS_DIALOG_ENABLED: shortcuts_action = Gio.SimpleAction.new("show-help-overlay", None) shortcuts_action.connect("activate", self._on_show_shortcuts) self.add_action(shortcuts_action) for view_id in ("device", "contacts", "channels", "map", "settings"): action = Gio.SimpleAction.new(f"navigate-{view_id}", None) action.connect("activate", self._on_navigate_action, view_id) self.add_action(action) search_action = Gio.SimpleAction.new("search", None) search_action.connect("activate", self._on_search_action) self.add_action(search_action) new_msg_action = Gio.SimpleAction.new("new-message", None) new_msg_action.connect("activate", self._on_new_message_action) self.add_action(new_msg_action) for name, direction, unread in ( ("navigate-prev", -1, False), ("navigate-next", 1, False), ("navigate-prev-unread", -1, True), ("navigate-next-unread", 1, True), ): action = Gio.SimpleAction.new(name, None) action.connect("activate", self._on_list_navigate, direction, unread) self.add_action(action) custom_tiles_action = Gio.SimpleAction.new("custom-tiles", None) custom_tiles_action.connect("activate", self._on_custom_tiles) self.add_action(custom_tiles_action) # Signal connections for template widgets self._nav_rail_toggle.connect("clicked", self._on_nav_rail_toggle) self._sidebar_banner.connect("button-clicked", self._on_connect_clicked) self._connection_screen_banner.connect("button-clicked", self._on_connect_clicked) self._outer_split.connect("notify::collapsed", self._on_outer_split_collapsed_changed) self._outer_split.connect("notify::show-sidebar", self._on_outer_split_show_sidebar_changed) # Breakpoints bp_medium = Adw.Breakpoint.new(Adw.BreakpointCondition.parse("max-width: 860sp")) bp_medium.add_setter(self._outer_split, "collapsed", True) self.add_breakpoint(bp_medium) bp_narrow = Adw.Breakpoint.new(Adw.BreakpointCondition.parse("max-width: 600sp")) bp_narrow.add_setter(self._outer_split, "collapsed", True) bp_narrow.add_setter(self._split_view, "collapsed", True) self.add_breakpoint(bp_narrow) # Activate device view by default self._nav_buttons["device"].set_active(True) # Show connecting screen if we have a saved device, otherwise # show the connection screen last_addr = self._settings.get_string("last-device-address") if last_addr: self._show_connecting_screen(last_addr) else: self._connection_box.append(self._connection_view) def _on_nav_rail_toggle(self, button): """Toggle the nav rail visibility when in collapsed mode.""" show = not self._outer_split.get_show_sidebar() self._outer_split.set_show_sidebar(show) def _on_outer_split_collapsed_changed(self, split_view, *args): """Show/hide the nav rail toggle button based on collapsed state.""" collapsed = split_view.get_collapsed() self._nav_rail_toggle.set_visible(collapsed) if not collapsed: split_view.set_show_sidebar(True) def _on_outer_split_show_sidebar_changed(self, split_view, *args): """Update toggle button icon based on sidebar visibility.""" if split_view.get_show_sidebar(): self._nav_rail_toggle.set_icon_name("sidebar-show-symbolic") else: self._nav_rail_toggle.set_icon_name("sidebar-show-right-symbolic") @property def _device_view(self): if self._device_view_inst is None: from meshy.views.device_view import DeviceView self._device_view_inst = DeviceView(self) return self._device_view_inst @property def _contacts_view(self): if self._contacts_view_inst is None: from meshy.views.contacts_view import ContactsView self._contacts_view_inst = ContactsView(self) return self._contacts_view_inst @property def _chat_view(self): if self._chat_view_inst is None: from meshy.views.chat_view import ChatView self._chat_view_inst = ChatView(self) return self._chat_view_inst @property def _channels_view(self): if self._channels_view_inst is None: from meshy.views.channels_view import ChannelsView self._channels_view_inst = ChannelsView(self) return self._channels_view_inst @property def _settings_view(self): if self._settings_view_inst is None: from meshy.views.settings_view import SettingsView self._settings_view_inst = SettingsView(self) return self._settings_view_inst @property def _map_view(self): if self._map_view_inst is None: self._map_view_inst = MapView(self) return self._map_view_inst @property def _repeater_view(self): if self._repeater_view_inst is None: from meshy.views.repeater_view import RepeaterView self._repeater_view_inst = RepeaterView(self) return self._repeater_view_inst @property def _sensor_view(self): if self._sensor_view_inst is None: from meshy.views.sensor_view import SensorView self._sensor_view_inst = SensorView(self) return self._sensor_view_inst def _on_nav_toggled(self, button, view_id): if not button.get_active(): return # Untoggle other buttons for vid, btn in self._nav_buttons.items(): if vid != view_id: btn.handler_block_by_func(self._on_nav_toggled) btn.set_active(False) btn.handler_unblock_by_func(self._on_nav_toggled) self._current_view = view_id self._switch_view(view_id) # Auto-hide nav rail overlay after selection if self._outer_split.get_collapsed(): self._outer_split.set_show_sidebar(False) def _on_show_shortcuts(self, action, param): builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/gtk/help-overlay.ui") dialog = builder.get_object("help_overlay") dialog.present(self) def _on_help(self, action, param): Gtk.UriLauncher(uri="https://meshy-app.org/docs/").launch(self, None, None, None) def _on_navigate_action(self, action, param, view_id): self._nav_buttons[view_id].set_active(True) def _on_search_action(self, action, param): if self._current_view == "contacts" and self._contacts_view_inst: self._contacts_view_inst._search_bar.set_search_mode(True) self._contacts_view_inst._search_entry.grab_focus() elif self._current_view == "channels" and self._channels_view_inst: self._channels_view_inst._search_bar.set_search_mode(True) self._channels_view_inst._search_entry.grab_focus() def _on_new_message_action(self, action, param): if self._current_view == "contacts" and self._chat_view_inst: self._chat_view_inst._text_entry.grab_focus() elif self._current_view == "channels" and self._channels_view_inst: self._channels_view_inst._chat_widget._text_entry.grab_focus() def _on_list_navigate(self, action, param, direction, unread_only): if self._current_view == "contacts" and self._contacts_view: self._contacts_view.navigate(direction, unread_only) elif self._current_view == "channels" and self._channels_view_inst: self._channels_view.navigate(direction, unread_only) def _on_custom_tiles(self, action, param): from meshy.views import apply_custom_source builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/custom-tiles-dialog.ui" ) dialog = builder.get_object("dialog") type_row = builder.get_object("type_row") url_row = builder.get_object("url_row") hint = builder.get_object("hint_label") clear_btn = builder.get_object("clear_btn") apply_btn = builder.get_object("apply_btn") type_model = Gtk.StringList.new([_("Raster"), _("Vector")]) type_row.set_model(type_model) saved_type = self._settings.get_string("custom-tile-type") type_row.set_selected(1 if saved_type == "vector" else 0) url_row.set_text(self._settings.get_string("custom-tile-url")) raster_hint = _( "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" ) vector_hint = _( "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" ) def _on_type_changed(*_args): is_vector = type_row.get_selected() == 1 url_row.set_title(_("Style JSON URL") if is_vector else _("URL Template")) hint.set_label(vector_hint if is_vector else raster_hint) type_row.connect("notify::selected", _on_type_changed) _on_type_changed() def _on_clear(*_args): url_row.set_text("") self._settings.set_string("custom-tile-url", "") self._settings.set_string("custom-tile-type", "") apply_custom_source("", "") dialog.close() clear_btn.connect("clicked", _on_clear) def _on_apply(*_args): url = url_row.get_text().strip() tile_type = "vector" if type_row.get_selected() == 1 else "raster" self._settings.set_string("custom-tile-url", url) self._settings.set_string("custom-tile-type", tile_type if url else "") def _on_result(ok): if ok: self.show_toast(_("Custom map tiles applied")) else: self.show_toast(_("Failed to load custom map tiles")) apply_custom_source(url, tile_type, on_result=_on_result) dialog.close() apply_btn.connect("clicked", _on_apply) dialog.present(self) def _clear_box(self, box): clear_children(box) def _add_sidebar_nav_button(self, label: str): """Create a navigation button for collapsed mode (e.g. 'Device Information ›').""" btn = Gtk.Button(label=f"{label} \u203a") btn.add_css_class("flat") btn.set_margin_start(12) btn.set_margin_end(12) btn.set_margin_top(8) btn.connect("clicked", lambda *_: self._split_view.set_show_content(True)) btn.set_visible(self._split_view.get_collapsed()) self._collapsed_handler_ids.append( self._split_view.connect( "notify::collapsed", lambda sv, *_: btn.set_visible(sv.get_collapsed()) ) ) self._sidebar_box.append(btn) return btn def _create_list_controls(self, view): """Create search/filter/sort buttons in the sidebar header for a list view.""" search_btn = Gtk.ToggleButton(icon_name="system-search-symbolic", tooltip_text=_("Search")) search_btn.add_css_class("flat") view.set_search_button(search_btn) filter_btn = Gtk.MenuButton(icon_name="filter-symbolic", tooltip_text=_("Filter")) filter_btn.set_menu_model(view.build_filter_menu()) filter_btn.add_css_class("flat") sort_btn = Gtk.MenuButton(icon_name="view-sort-descending-symbolic", tooltip_text=_("Sort")) sort_btn.set_menu_model(view.build_sort_menu()) sort_btn.add_css_class("flat") for btn in (sort_btn, filter_btn, search_btn): self._sidebar_header.pack_end(btn) self._sidebar_header_extras.append(btn) def _switch_view(self, view_id: str): """Switch the sidebar and content based on the selected navigation.""" # Discard unapplied settings changes when navigating away if self._settings_view_inst is not None and view_id != "settings": self._settings_view_inst.restore_snapshot() if self._repeater_view_inst is not None: self._repeater_view_inst.clear() if self._sensor_view_inst is not None: self._sensor_view_inst.clear() if self._map_view_inst is not None and view_id != "map": vp = self._map_view_inst._map.get_viewport() self._saved_map_state = ( vp.get_zoom_level(), vp.get_latitude(), vp.get_longitude(), ) from meshy.views import deregister_map_widget deregister_map_widget(self._map_view_inst._map) self._map_view_inst.cleanup() self._map_view_inst = None import ctypes import gc gc.collect() try: ctypes.CDLL("libc.so.6").malloc_trim(0) except OSError: pass self._clear_box(self._sidebar_box) self._clear_box(self._content_box) # Disconnect previous collapsed handlers to prevent leaks for handler_id in self._collapsed_handler_ids: self._split_view.disconnect(handler_id) self._collapsed_handler_ids.clear() # Remove any extra buttons from content headerbar if hasattr(self, "_content_header_extra") and self._content_header_extra: self._content_header.remove(self._content_header_extra) self._content_header_extra = None # Remove extra buttons from sidebar headerbar for btn in self._sidebar_header_extras: self._sidebar_header.remove(btn) self._sidebar_header_extras.clear() # Reset custom title widget and sidebar title visibility self._content_header.set_title_widget(None) self._sidebar_header.set_show_title(True) self._content_title_btn = None # Remove view-specific action groups from window for prefix in ("contacts", "channel"): self.insert_action_group(prefix, None) if view_id == "device": # Sidebar = Device Info button + Actions, content = device info self._sidebar_page.set_title(_("Actions")) self._content_page.set_title(_("Device Information")) self._add_sidebar_nav_button(_("Device Information")) actions = self._device_view.build_sidebar_actions() actions.set_margin_start(12) actions.set_margin_end(12) actions.set_margin_top(8) self._sidebar_box.append(actions) self._content_box.append(self._device_view) self._split_view.set_show_content(True) elif view_id == "contacts": self._sidebar_page.set_title(_("Contacts")) self._content_page.set_title(_("Chat")) self._sidebar_header.set_show_title(False) self._sidebar_box.append(self._contacts_view) self._content_box.append(self._chat_view) self.insert_action_group("contacts", self._contacts_view._action_group) self._create_list_controls(self._contacts_view) if self._chat_view_inst and self._chat_view_inst._contact: self._set_contact_title_widget(self._chat_view_inst._contact) self._split_view.set_show_content(False) elif view_id == "channels": self._sidebar_page.set_title(_("Channels")) self._content_page.set_title(_("Channel")) self._sidebar_header.set_show_title(False) self._sidebar_box.append(self._channels_view) self._content_box.append(self._channels_view.get_chat_widget()) self.insert_action_group("channel", self._channels_view._action_group) self._create_list_controls(self._channels_view) selected = self._channels_view._channel_list.get_selected_row() if selected and hasattr(selected, "_channel"): self._set_channel_title_widget(selected._channel) self._split_view.set_show_content(False) elif view_id == "map": self._sidebar_page.set_title(_("Filter")) self._content_page.set_title(_("Map")) # Navigation button for collapsed mode self._add_sidebar_nav_button(_("Map")) # Filter controls self._sidebar_box.append(self._map_view.build_sidebar_filters()) self._map_view.update_contacts(self._contacts) self._map_view.update_discovered(self._discovered) if self._saved_map_state: zoom, lat, lon = self._saved_map_state vp = self._map_view._map.get_viewport() vp.set_zoom_level(zoom) vp.set_location(lat, lon) self._saved_map_state = None self._content_box.append(self._map_view) self._split_view.set_show_content(True) elif view_id == "settings": # Sidebar = Settings button + hint, content = settings with Apply in headerbar self._sidebar_page.set_title(_("Backup & Restore")) self._content_page.set_title(_("Settings")) self._add_sidebar_nav_button(_("Settings")) sb = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/settings-sidebar.ui" ) sb.get_object("manual_backup_btn").connect( "clicked", self._settings_view._on_manual_backup ) sb.get_object("auto_backup_btn").connect( "clicked", self._settings_view._on_auto_backup_settings ) sb.get_object("restore_file_btn").connect( "clicked", self._settings_view._on_import_backup ) sb.get_object("restore_auto_btn").connect( "clicked", self._settings_view._on_restore_auto_backup ) sb.get_object("factory_reset_btn").connect( "clicked", self._device_view._on_factory_reset ) self._sidebar_box.append(sb.get_object("backup_group")) self._sidebar_box.append(sb.get_object("spacer")) self._sidebar_box.append(sb.get_object("reset_group")) self._content_box.append(self._settings_view) self._settings_view.load_regions() self._settings_view.restore_default_scope() apply_btn = self._settings_view.create_apply_button() self._content_header.pack_end(apply_btn) self._content_header_extra = apply_btn self._split_view.set_show_content(True) def _build_section_nav(self, view) -> Gtk.Widget: """Build a section navigation list for views that have sections.""" builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/section-nav.ui") box = builder.get_object("section_nav") listbox = builder.get_object("listbox") section_icons = { "Connection": "network-wireless-symbolic", "Device Information": "computer-symbolic", "Battery & Storage": "battery-symbolic", "Radio Configuration": "preferences-system-symbolic", "Radio": "preferences-system-symbolic", "Actions": "system-run-symbolic", "Advert": "network-wireless-symbolic", "Auto-Add Contacts": "avatar-default-symbolic", "Location": "find-location-symbolic", "Telemetry Privacy": "preferences-other-symbolic", "Regions": "network-workgroup-symbolic", } for section_name in view.sections: row = Adw.ActionRow(title=section_name) icon = section_icons.get(section_name, "go-next-symbolic") row.add_prefix(Gtk.Image.new_from_icon_name(icon)) row.set_activatable(True) row._section_name = section_name listbox.append(row) def on_row_activated(lb, row): if hasattr(row, "_section_name"): view.scroll_to_section(row._section_name) self._split_view.set_show_content(True) listbox.connect("row-activated", on_row_activated) return box def _set_contact_title_widget(self, contact: Contact): """Set the content header to a clickable contact name title widget.""" self._content_page.set_title(contact.name) title_btn = Gtk.Button() title_btn.add_css_class("flat") title_box = Gtk.Box(spacing=4, halign=Gtk.Align.CENTER) self._content_title_label = Gtk.Label( label=_("{name} ({path})").format(name=contact.name, path=contact.path_label), ellipsize=Pango.EllipsizeMode.END, ) self._content_title_label.add_css_class("heading") title_box.append(self._content_title_label) title_box.append(Gtk.Image.new_from_icon_name("go-down-symbolic")) title_btn.set_child(title_box) contact_key = contact.public_key_hex title_btn.connect( "clicked", lambda *_, key=contact_key: self._contacts_view._show_contact_detail( self._contacts_by_key.get(key, contact) ), ) self._content_header.set_title_widget(title_btn) self._content_title_btn = title_btn def _set_channel_title_widget(self, channel): """Set the content header to a clickable channel name title widget.""" title = channel.name or _("Channel {}").format(channel.index) if channel.region: title = _("{name} ({region})").format(name=title, region=channel.region) self._content_page.set_title(title) title_btn = Gtk.Button() title_btn.add_css_class("flat") title_box = Gtk.Box(spacing=4, halign=Gtk.Align.CENTER) title_label = Gtk.Label( label=title, ellipsize=Pango.EllipsizeMode.END, ) title_label.add_css_class("heading") title_box.append(title_label) title_box.append(Gtk.Image.new_from_icon_name("go-down-symbolic")) title_btn.set_child(title_box) ch = channel title_btn.connect( "clicked", lambda *_, c=ch: self._channels_view._show_channel_detail(c), ) self._content_header.set_title_widget(title_btn) self._content_title_btn = title_btn def _swap_content_view(self, target_view): for view in (self._chat_view_inst, self._repeater_view_inst, self._sensor_view_inst): if view and view is not target_view and view.get_parent() == self._content_box: if hasattr(view, "clear"): view.clear() self._content_box.remove(view) if target_view.get_parent() != self._content_box: self._content_box.append(target_view) def show_chat(self, contact: Contact): """Show chat view for a specific contact.""" self._swap_content_view(self._chat_view) self._chat_view.set_contact(contact) self._set_contact_title_widget(contact) self._split_view.set_show_content(True) self._contacts_view.mark_unread(contact.public_key_hex, False) def _update_chat_title(self, contact): if not (self._chat_view_inst and self._chat_view_inst._contact): return if self._chat_view_inst._contact.public_key_hex != contact.public_key_hex: return title = _("{name} ({path})").format(name=contact.name, path=contact.path_label) if hasattr(self, "_content_title_label") and self._content_title_label: self._content_title_label.set_label(title) else: self._content_page.set_title(title) def _refresh_chat_header(self): """Update the chat header title if the current contact's path changed.""" if not (self._chat_view_inst and self._chat_view_inst._contact): return key = self._chat_view_inst._contact.public_key_hex c = self._contacts_by_key.get(key) if c: self._chat_view_inst._contact = c self._update_chat_title(c) def show_repeater(self, contact: Contact, login_timestamp=None, is_admin=True): """Show repeater management view for a repeater contact.""" self._swap_content_view(self._repeater_view) self._repeater_view.set_repeater(contact, login_timestamp, is_admin) self._content_header.set_title_widget(None) self._content_title_btn = None role = _("Management") if is_admin else _("Guest") self._content_page.set_title(_("{name} \u2014 {role}").format(name=contact.name, role=role)) self._split_view.set_show_content(True) def show_sensor(self, contact: Contact): """Show sensor telemetry view for a sensor contact.""" self._swap_content_view(self._sensor_view) self._sensor_view.set_sensor(contact) self._content_header.set_title_widget(None) self._content_title_btn = None self._content_page.set_title(contact.name) self._split_view.set_show_content(True) def mark_contact_read(self, public_key_hex: str): self._storage.set_contact_last_read_at(public_key_hex, datetime.now().timestamp()) def get_contact_last_read_at(self, public_key_hex: str) -> float | None: return self._storage.get_contact_last_read_at(public_key_hex) def mark_channel_read(self, channel_index: int): self._storage.set_channel_last_read_at(channel_index, datetime.now().timestamp()) def get_channel_last_read_at(self, channel_index: int) -> float | None: return self._storage.get_channel_last_read_at(channel_index) def _on_window_active_changed(self, window, pspec): """Clear unread indicator for the currently visible chat when window gains focus.""" if not self.is_active(): return if ( self._current_view == "contacts" and self._chat_view_inst and self._chat_view_inst._contact ): self._contacts_view.mark_unread(self._chat_view_inst._contact.public_key_hex, False) elif self._current_view == "channels" and self._channels_view_inst: chat = self._channels_view_inst.get_chat_widget() if chat._current_channel: self._channels_view_inst.mark_unread(chat._current_channel.index, False) # ─── BLE Connection ─────────────────────────────────────── def _setup_transport_callbacks(self, transport): self._conn_ctrl.setup_transport_callbacks(transport) def _setup_ble_callbacks(self): if self._ble: self._conn_ctrl.setup_transport_callbacks(self._ble) def _auto_connect(self): return self._conn_ctrl.auto_connect() def _update_companion_label(self): """Update the companion name + battery/signal icons in the header.""" name = self._device_info.name if not name: self._companion_box.set_visible(False) return self._settings.set_string("last-device-name", name) self._companion_name_label.set_label(name) has_battery = self._device_info.battery_mv > 0 if has_battery: pct = self._device_info.battery_percent icon = battery_icon_name(pct) self._companion_battery_icon.set_from_icon_name(icon) self._companion_battery_icon.set_tooltip_text(f"{pct}%") self._companion_battery_icon.set_visible(has_battery) has_icons = has_battery or self._companion_signal_icon.get_visible() self._companion_separator.set_visible(has_icons) self._companion_box.set_visible(True) def _update_signal_icon_tick(self): """Periodic callback to refresh the signal strength icon.""" self._update_signal_icon() for key in list(self._direct_repeaters): snr, ts, miss = self._direct_repeaters[key] if key in self._repeaters_refreshed: self._direct_repeaters[key] = (snr, ts, 0) else: miss += 1 if miss >= 5: del self._direct_repeaters[key] else: self._direct_repeaters[key] = (snr, ts, miss) self._repeaters_refreshed.clear() self._signal_icon_timer_id = GLib.timeout_add_seconds(60, self._update_signal_icon_tick) return GLib.SOURCE_REMOVE def _update_signal_icon(self): """Update the signal strength icon from repeater SNR data.""" import time now = time.monotonic() stale = [k for k, (_, ts, _mc) in self._direct_repeaters.items() if now - ts > 600] for k in stale: del self._direct_repeaters[k] if self._direct_repeaters: best_snr = max(snr for snr, _, _mc in self._direct_repeaters.values()) sf = self._device_info.radio_sf or 12 icon = snr_to_icon(best_snr, sf) self._companion_signal_icon.set_from_icon_name(icon) quality = { "Excellent": _("Excellent"), "Good": _("Good"), "Fair": _("Fair"), "Poor": _("Poor"), "Very poor": _("Very poor"), }.get(snr_to_label(best_snr, sf), "") self._companion_signal_icon.set_tooltip_text( f"{quality}, SNR: {format_number(best_snr)} dB" ) self._companion_signal_icon.set_visible(True) else: self._companion_signal_icon.set_from_icon_name("strength-bars-none-symbolic") self._companion_signal_icon.set_tooltip_text(None) self._companion_signal_icon.set_visible(True) has_icons = ( self._companion_battery_icon.get_visible() or self._companion_signal_icon.get_visible() ) self._companion_separator.set_visible(has_icons) def _save_tcp_companion_if_needed(self): """Save or update the current TCP companion in GSettings.""" if self._settings.get_string("last-transport") != "tcp": return addr = self._transport.connected_address name = self._device_info.name if not addr or not name: return entry = f"{name}|{addr}" companions = list(self._settings.get_strv("tcp-companions")) companions = [c for c in companions if not c.endswith(f"|{addr}")] companions.append(entry) self._settings.set_strv("tcp-companions", companions) def _remove_tcp_companion(self, address: str): """Remove a saved TCP companion by address.""" companions = list(self._settings.get_strv("tcp-companions")) companions = [c for c in companions if not c.endswith(f"|{address}")] self._settings.set_strv("tcp-companions", companions) def _update_tcp_companion_address(self, old_address: str, new_address: str): """Update the address of a saved TCP companion.""" companions = list(self._settings.get_strv("tcp-companions")) companions = [ c.rsplit("|", 1)[0] + f"|{new_address}" if c.endswith(f"|{old_address}") else c for c in companions ] self._settings.set_strv("tcp-companions", companions) def _show_connecting_screen(self, address=""): """Show the auto-connecting status screen.""" name = self._settings.get_string("last-device-name") if name: self._connecting_status.set_description(f"{name} ({address})" if address else name) else: self._connecting_status.set_description(address) self._connecting_spinner.set_spinning(True) self._main_stack.set_visible_child_name("connecting") def _show_connection_screen(self): """Show the full-window connection screen.""" self._connecting_spinner.set_spinning(False) if self._connection_view.get_parent(): self._connection_view.get_parent().remove(self._connection_view) self._connection_box.append(self._connection_view) self._connection_screen_banner.set_revealed(False) self._main_stack.set_visible_child_name("connection") self._connection_view.refresh_paired_devices() def _hide_connection_screen(self): """Hide connection/connecting screen and switch to main app layout.""" from_connection = self._main_stack.get_visible_child_name() == "connection" self._connecting_spinner.set_spinning(False) self._connection_view.stop_polling() if self._connection_view.get_parent(): self._connection_view.get_parent().remove(self._connection_view) if from_connection: self._sidebar_banner.set_title(self._connection_screen_banner.get_title()) self._sidebar_banner.set_button_label(self._connection_screen_banner.get_button_label()) self._sidebar_banner.set_revealed(self._connection_screen_banner.get_revealed()) self._main_stack.set_visible_child_name("main") self._current_view = "device" self._switch_view("device") self._nav_buttons["device"].set_active(True) def connect_to_device(self, device, transport=None): self._conn_ctrl.connect_to_device(device, transport) def _on_menu_disconnect(self, *_args): """Handle Disconnect from hamburger menu.""" self._conn_ctrl.manual_disconnect = True if self._conn_ctrl._timeouts.is_active("reconnect"): self._conn_ctrl.cancel_reconnect() self._conn_ctrl.reconnect_attempts = 0 self._transport.disconnect() def _on_connect_clicked(self, banner): # Cancel button during auto-reconnect delay if self._conn_ctrl._timeouts.is_active("reconnect"): self._conn_ctrl.cancel_reconnect() self._conn_ctrl.reconnect_attempts = 0 self._conn_ctrl.manual_disconnect = True self._settings.set_string("last-device-address", "") self._connection_banner.set_title(_("Not connected")) self._connection_banner.set_button_label(_("Connect")) self._show_connection_screen() return # Cancel button during auto-reconnect connecting phase if ( self._transport.state == ConnectionState.CONNECTING and self._conn_ctrl.reconnect_attempts > 0 ): self._conn_ctrl.reconnect_attempts = 0 self._conn_ctrl.manual_disconnect = True self._transport.disconnect() self._settings.set_string("last-device-address", "") self._connection_banner.set_title(_("Not connected")) self._connection_banner.set_button_label(_("Connect")) self._show_connection_screen() return if self._transport.state == ConnectionState.CONNECTED: self._conn_ctrl.manual_disconnect = True self._transport.disconnect() elif self._transport.state == ConnectionState.DISCONNECTED: last_addr = self._settings.get_string("last-device-address") if last_addr: self._connection_banner.set_title(_("Connecting...")) self._transport.connect_by_address(last_addr) else: self._show_scan_dialog() elif self._transport.state == ConnectionState.SCANNING: self._transport.stop_scan() def _show_scan_dialog(self): self._conn_ctrl.show_scan_dialog() def _on_scan_device_selected(self, listbox, row): self._conn_ctrl._on_scan_device_selected(listbox, row) def _on_ble_device_discovered(self, device: BleDevice): self._conn_ctrl.on_ble_device_discovered(device) def _on_ble_state_changed(self, state: ConnectionState): self._conn_ctrl.on_ble_state_changed(state) def _on_connected(self): self._conn_ctrl.on_connected() def _apply_default_scope(self): scope = self._settings_view.default_scope # Suppress error for older firmware that doesn't support SET_DEFAULT_FLOOD_SCOPE self._ignore_next_err = True self.send_frame(build_set_default_flood_scope(scope)) return False def _reset_session_scope(self): """Reset session flood scope to null so firmware uses the persistent default.""" self.send_frame(build_set_flood_scope("")) return False def _poll_battery(self) -> bool: return self._conn_ctrl.poll_battery() def _connection_watchdog(self) -> bool: return self._conn_ctrl.connection_watchdog() # ─── Data Handling ───────────────────────────────────────── def _on_ble_data_received(self, data: bytes): """Handle incoming data from the BLE device.""" self._conn_ctrl.last_device_rx = time.monotonic() GLib.idle_add(self._handle_frame, data) def _debug_log(self, msg: str): log.debug(msg) def _handle_frame(self, data: bytes): """Process a received protocol frame on the main thread.""" return self._frame_handler.handle_frame(data) def _send_notification(self, title: str, body: str, notif_id: str): """Send a desktop notification using platform-specific service.""" self._notification_service.send_notification(title, body, notif_id) # ─── Public API for views ───────────────────────────────── def send_message(self, contact: Contact, text: str, route_override: str | None = None): self._msg_ctrl.send_message(contact, text, route_override=route_override) def resend_message(self, contact: Contact, message: Message): self._msg_ctrl.resend_message(contact, message) def send_channel_message(self, channel_index: int, text: str): self._msg_ctrl.send_channel_message(channel_index, text) def resend_channel_message(self, channel_index: int, text: str) -> int: return self._msg_ctrl.resend_channel_message(channel_index, text) def delete_message(self, message_id: str): self._msg_ctrl.delete_message(message_id) def delete_channel_message(self, channel_index: int, timestamp: float, text: str): self._msg_ctrl.delete_channel_message(channel_index, timestamp, text) def send_cli_command(self, contact: Contact, command: str): """Send a CLI command to a repeater.""" frame = build_send_cli_command(contact.public_key, command) self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(frame) def send_cli_with_prefix(self, contact_pub_key: bytes, command: str, callback): """Send a CLI command with a correlation prefix. The repeater firmware echoes the prefix back, allowing reliable matching of responses to commands. """ prefix = f"{self._cli_prefix_counter:02X}|" self._cli_prefix_counter = (self._cli_prefix_counter + 1) & 0xFF self._cli_pending_prefixes[prefix] = callback frame = build_send_cli_command(contact_pub_key, prefix + command) self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(frame) def cancel_cli_prefixes(self, prefixes): """Remove pending prefix callbacks (e.g. when cancelling a fetch).""" for p in prefixes: self._cli_pending_prefixes.pop(p, None) def send_advert(self, flood: bool = False, silent: bool = False): self.send_frame(build_send_self_advert(flood)) if not silent: mode = _("flood routed") if flood else _("zero-hop") self.show_toast(_("Advert sent ({})").format(mode)) @property def discovered_contacts(self) -> list: return self._discovered def _add_local_contact(self, contact: Contact): """Add a contact to the local list and UI immediately.""" key = contact.public_key_hex if key not in self._contacts_by_key: self._contacts.append(contact) self._contacts_by_key[key] = contact self._storage.save_contact(contact) self._contacts_view.update_contacts(self._contacts) def add_discovered_contact(self, contact: Contact): """Move a contact from discovered to the device's contact list.""" self.send_frame( build_add_update_contact( contact.public_key, contact_type=contact.type, name=contact.name, path_len=0xFF, lat=contact.latitude, lon=contact.longitude, ) ) # Remove from discovered list self._discovered = [ c for c in self._discovered if c.public_key_hex != contact.public_key_hex ] self._discovered_by_key.pop(contact.public_key_hex, None) # Add to local list immediately self._add_local_contact(contact) self.schedule_device_contacts_refetch() def request_regions(self, contact, callback, use_path=False): """Send an anonymous region request to a repeater. When *use_path* is False (default) a zero-hop request is sent, reaching only directly reachable repeaters. When True, the contact's stored path is used so multi-hop repeaters can be reached. """ callback_key = contact.public_key_hex[:12] self._region_callbacks[callback_key] = callback if use_path and getattr(contact, "path_length", -1) > 0: path = getattr(contact, "path", b"") frame = build_anon_req_regions(contact.public_key, contact.path_length, path) else: frame = build_anon_req_regions(contact.public_key, 0, b"") self._msg_ctrl.sent_msg_queue.append(f"region:{callback_key}") self.send_frame(frame) def send_discover_req(self, callback): """Send a NODE_DISCOVER_REQ control packet. Callback receives each response.""" self._discover_callback = callback self.send_frame(build_send_discover_req(prefix_only=False)) def stop_discover(self): """Stop listening for discover responses.""" self._discover_callback = None def send_trace_path(self, path_bytes: bytes, callback, timeout_callback=None, flags: int = 0): """Send a SEND_TRACE_PATH command. Callback receives trace result dict.""" self._trace_callback = callback self._trace_timeout_callback = timeout_callback tag = int(datetime.now().timestamp()) self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(build_trace_path(tag, path_bytes, flags)) def stop_trace_path(self): """Stop listening for trace responses.""" self._trace_callback = None self._trace_timeout_callback = None def set_flood_scope(self, scope: str): """Set flood scope. Empty to disable, '#name' for hashtag scope.""" self.send_frame(build_set_flood_scope(scope)) def get_advert_path(self, pub_key: bytes, callback): """Get the advertisement path cached for a contact.""" self._advert_path_callback = callback self.send_frame(build_get_advert_path(pub_key)) def send_path_discovery(self, pub_key: bytes, callback): """Send a path discovery request. Callback receives raw response data.""" self._path_discovery_callback = callback self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(build_path_discovery(pub_key)) def stop_path_discovery(self): """Stop listening for path discovery responses.""" self._path_discovery_callback = None _LOGIN_FLOOD_RETRY_MS = 7000 def send_login(self, pub_key: bytes, password: str, callback, on_retry=None): """Send a room/repeater login. Callback receives True (success) or False (fail). If the first (directed) attempt gets no response within 7s, the cached path is reset and the login is retried via flood. *on_retry* is called (no args) when the flood retry fires, so the UI can update its status. """ from meshy.protocol import build_send_login self._login_callback = callback self._login_target_prefix = pub_key[:6] self._login_pub_key = pub_key self._login_password = password self._login_on_retry = on_retry frame = build_send_login(pub_key, password) log.info(f"Sending login to {pub_key.hex()[:16]}...") self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(frame) self._login_retry_timeout_id = GLib.timeout_add( self._LOGIN_FLOOD_RETRY_MS, self._on_login_flood_retry ) def _on_login_flood_retry(self): self._login_retry_timeout_id = None if not self._login_callback: return False log.info("Login directed attempt timed out, resetting path and retrying via flood") from meshy.protocol import build_reset_path, build_send_login self.send_frame(build_reset_path(self._login_pub_key)) frame = build_send_login(self._login_pub_key, self._login_password) self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(frame) self._login_was_flood_retry = True if self._login_on_retry: self._login_on_retry() return False def stop_login(self): """Stop listening for login responses.""" self._login_callback = None self._login_target_prefix = None self._login_pub_key = None self._login_password = None self._login_on_retry = None if self._login_retry_timeout_id: GLib.source_remove(self._login_retry_timeout_id) self._login_retry_timeout_id = None def is_room_logged_in(self, pub_key_hex: str) -> bool: return pub_key_hex in self._logged_in_rooms def is_room_admin(self, pub_key_hex: str) -> bool: return self._logged_in_rooms.get(pub_key_hex, 0) == 3 def is_room_guest(self, pub_key_hex: str) -> bool: return self._logged_in_rooms.get(pub_key_hex, -1) == 0 def mark_room_logged_in(self, pub_key_hex: str, acl_perms: int = 3): self._logged_in_rooms[pub_key_hex] = acl_perms def logout_repeater(self, pub_key_hex: str): """Log out of a repeater/room — disconnect on device and clear local state.""" from meshy.protocol import build_logout contact = self._contacts_by_key.get(pub_key_hex) if contact and self.is_connected: self.send_frame(build_logout(contact.public_key)) self._logged_in_rooms.pop(pub_key_hex, None) self._swap_content_view(self._chat_view) self._split_view.set_show_content(False) def send_status_req(self, pub_key: bytes, callback): """Send a status request to a repeater. Callback receives parsed status dict.""" self._status_callback = callback self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(build_send_status_req(pub_key)) def get_neighbors(self, repeater_pub_key: bytes, callback, offset: int = 0): """Request neighbor list from a repeater. Callback receives dict with 'total' and 'neighbors'.""" self._neighbors_callback = callback self._msg_ctrl.sent_msg_queue.append({"type": "neighbors", "callback": callback}) self.send_frame(build_get_neighbors(repeater_pub_key, offset=offset)) def get_acl(self, repeater_pub_key: bytes, callback): """Request ACL from a repeater. Callback receives raw binary response.""" from meshy.protocol import build_get_acl self._acl_callback = callback self._msg_ctrl.sent_msg_queue.append({"type": "acl", "callback": callback}) self.send_frame(build_get_acl(repeater_pub_key)) def get_telemetry(self, pub_key: bytes, callback, contact_type: int = None): """Request telemetry. Uses CMD_SEND_TELEMETRY_REQ for chat companions, CMD_SEND_BINARY_REQ for repeaters/rooms/sensors.""" from meshy.models import ContactType from meshy.protocol import build_get_telemetry, build_get_telemetry_chat if contact_type == ContactType.CHAT: self._telemetry_callback = callback self.send_frame(build_get_telemetry_chat(pub_key)) else: self._telemetry_callback = callback self._msg_ctrl.sent_msg_queue.append({"type": "telemetry", "callback": callback}) self.send_frame(build_get_telemetry(pub_key)) def get_self_telemetry(self, callback): """Request telemetry from the connected companion device itself.""" from meshy.protocol import build_get_telemetry_chat self._telemetry_callback = callback self.send_frame(build_get_telemetry_chat()) def find_contact_by_key_prefix(self, prefix_hex: str) -> Contact | None: """Find a contact whose public key starts with the given hex prefix.""" for c in self._contacts: if c.public_key_hex.startswith(prefix_hex): return c for c in self._discovered: if c.public_key_hex.startswith(prefix_hex): return c return None def resolve_hop_prefixes(self, hop_hexes: list[str]) -> list[Contact | None]: """Resolve hop hex prefixes to contacts using geographic proximity. When multiple contacts match a prefix, pick the one closest to the previous hop. """ import math def _distance_sq(lat1, lon1, lat2, lon2): dlat = lat1 - lat2 dlon = (lon1 - lon2) * math.cos(math.radians((lat1 + lat2) / 2)) return dlat * dlat + dlon * dlon ref_lat, ref_lon = None, None si = self._self_info ref_lat = si.get("adv_lat") ref_lon = si.get("adv_lon") if not ref_lat or not ref_lon or (abs(ref_lat) < 1e-6 and abs(ref_lon) < 1e-6): ref_lat, ref_lon = None, None all_contacts = list(self._contacts) + list(self._discovered) result = [] for hop_hex in hop_hexes: prefix = hop_hex.lower() candidates = [ c for c in all_contacts if ( c.type in (ContactType.REPEATER, ContactType.ROOM) and c.public_key_hex.startswith(prefix) ) ] if not candidates: result.append(None) elif len(candidates) == 1: found = candidates[0] result.append(found) if found.has_location: ref_lat, ref_lon = found.latitude, found.longitude else: if ref_lat is not None and ref_lon is not None: located = [c for c in candidates if c.has_location] if located: found = min( located, key=lambda c: _distance_sq(ref_lat, ref_lon, c.latitude, c.longitude), ) else: found = candidates[0] else: found = candidates[0] result.append(found) if found.has_location: ref_lat, ref_lon = found.latitude, found.longitude return result def is_contact_known(self, pub_key_hex: str) -> bool: """Check if a contact with this public key already exists.""" return pub_key_hex in self._contacts_by_key def add_contact(self, pub_key: bytes, contact_type: int, name: str): """Add a contact manually by public key.""" self.send_frame( build_add_update_contact( pub_key, contact_type=contact_type, name=name, path_len=0xFF, # flood - no known path to new contact ) ) # Add to local list immediately contact = Contact( public_key=pub_key, name=name, type=contact_type, path_length=-1, is_active=True, ) self._add_local_contact(contact) self.schedule_device_contacts_refetch() def import_contact(self, contact_frame: bytes): """Import a contact from a meshcore:// URI payload.""" self.send_frame(build_import_contact(contact_frame)) # Try to add to local list immediately by parsing the advert frame from meshy.application import MeshyApplication name, contact_type = MeshyApplication._parse_advert_name(contact_frame) if name: try: # Extract pub key from frame (after header + path) i = 0 i += 1 # header path_len_byte = contact_frame[i] i += 1 hop_count = path_len_byte & 0x3F hash_size = ((path_len_byte >> 6) & 0x03) + 1 i += hop_count * hash_size pub_key = contact_frame[i : i + 32] if len(pub_key) == 32: contact = Contact( public_key=pub_key, name=name, type=contact_type, path_length=-1, is_active=True, ) self._add_local_contact(contact) except (IndexError, ValueError): pass self.schedule_device_contacts_refetch() def export_self_to_clipboard(self): """Export self contact as meshcore:// URI to clipboard.""" self._pending_clipboard_export = True self.send_frame(build_export_contact(b"")) # empty key = export self def set_custom_var(self, key_value: str): """Set a custom variable on the firmware (e.g. 'gps:1').""" self.send_frame(build_set_custom_var(key_value)) def set_advert_name(self, name: str): self.send_frame(build_set_advert_name(name)) def set_advert_latlon(self, lat: float, lon: float): self.send_frame(build_set_advert_latlon(lat, lon)) def _build_telemetry_byte(self) -> int: si = self._self_info return ( (si.get("telemetry_mode_env", 0) & 0x03) << 4 | (si.get("telemetry_mode_loc", 0) & 0x03) << 2 | (si.get("telemetry_mode_base", 0) & 0x03) ) def _send_other_params(self): """Send CMD_SET_OTHER_PARAMS with current self_info values.""" from meshy.protocol import build_set_other_params si = self._self_info self.send_frame( build_set_other_params( manual_add_contacts=int(si.get("manual_add_contacts", False)), telemetry_flags=self._build_telemetry_byte(), advert_loc_policy=si.get("adv_loc_policy", 0), multi_acks=si.get("multi_acks", 0), ) ) def set_advert_loc_policy(self, policy: int): """Set advert location policy (0=don't include, 1=include).""" self._self_info["adv_loc_policy"] = policy self._send_other_params() def set_telemetry_mode(self, base: int, loc: int, env: int): """Set telemetry privacy modes (0=deny, 1=allow by flags, 2=allow all).""" self._self_info["telemetry_mode_base"] = base self._self_info["telemetry_mode_loc"] = loc self._self_info["telemetry_mode_env"] = env self._send_other_params() def set_multi_acks(self, value: int): self._self_info["multi_acks"] = value self._send_other_params() def set_radio_params( self, freq_hz: int, bw_hz: int, sf: int, cr: int, client_repeat: bool | None = None ): self.send_frame(build_set_radio_params(freq_hz, bw_hz, sf, cr, client_repeat)) def set_tx_power(self, power_dbm: int): self.send_frame(build_set_radio_tx_power(power_dbm)) def set_device_pin(self, pin: int): self.send_frame(build_set_device_pin(pin)) def remove_contact(self, contact: Contact): self.send_frame(build_remove_contact(contact.public_key)) self._storage.remove_contact(contact.public_key_hex) self._contacts = [c for c in self._contacts if c.public_key_hex != contact.public_key_hex] self._contacts_by_key.pop(contact.public_key_hex, None) # Keep in _discovered so the name is available for re-adding or discovery if contact.public_key_hex not in self._discovered_by_key: self._frame_handler.add_or_update_discovered(contact) self._contacts_view.update_contacts(self._contacts) def reset_contact_path(self, contact: Contact): self.send_frame(build_reset_path(contact.public_key)) # Update local state immediately so the UI reflects the change contact.path_length = -1 contact.path = b"" for i, c in enumerate(self._contacts): if c.public_key_hex == contact.public_key_hex: self._contacts[i].path_length = -1 self._contacts[i].path = b"" break self._contacts_view.update_contacts(self._contacts) self._update_chat_title(contact) def update_contact_flags(self, contact: Contact): """Update contact flags on the device (telemetry permissions, favorite, etc.).""" self.send_frame( build_add_update_contact( pub_key=contact.public_key, contact_type=contact.type, flags=contact.flags, path_len=(((contact.path_hash_size - 1) << 6) | (contact.path_length & 0x3F)) if contact.path_length >= 0 else 0xFF, path=contact.path, name=contact.name, lat=contact.latitude, lon=contact.longitude, ) ) def set_contact_path( self, contact: Contact, path_len: int, path: bytes = b"", name: str | None = None ): """Set a custom path and/or name for a contact. path_len=0xFF for flood.""" self.send_frame( build_add_update_contact( pub_key=contact.public_key, contact_type=contact.type, flags=contact.flags, path_len=path_len, path=path, name=name or contact.name, lat=contact.latitude, lon=contact.longitude, ) ) # Optimistic update: reflect the change in local state immediately if path_len == 0xFF: new_path_length = -1 new_path = b"" new_hash_size = 1 else: new_path_length = path_len & 0x3F new_hash_size = ((path_len >> 6) & 0x03) + 1 new_path = path contact.path_length = new_path_length contact.path = new_path contact.path_hash_size = new_hash_size for i, c in enumerate(self._contacts): if c.public_key_hex == contact.public_key_hex: self._contacts[i].path_length = new_path_length self._contacts[i].path = new_path self._contacts[i].path_hash_size = new_hash_size break self._contacts_view.update_contacts(self._contacts) self._update_chat_title(contact) def set_channel(self, index: int, name: str, psk: bytes): self.send_frame(build_set_channel(index, name, psk)) # Re-fetch channel info so the UI updates GLib.timeout_add(500, lambda: self.send_frame(build_get_channel(index)) or False) def add_channel_from_qr(self, name: str, secret: bytes): """Add a channel scanned from a QR code, using the first empty slot.""" for idx in range(self._device_info.max_channels): ch = next((c for c in self._channels if c.index == idx), None) if ch is None or ch.is_empty: self.set_channel(idx, name, secret) self.show_toast(_('Channel "{}" added').format(name)) # Refresh channels after a delay GLib.timeout_add(700, self._fetch_all_channels) return self.show_toast(_("No empty channel slots available")) def _fetch_channels_sequential(self, index): self._conn_ctrl.fetch_channels_sequential(index) def _send_sync_next_message(self): self._conn_ctrl.send_sync_next_message() def _finish_msg_sync(self): self._conn_ctrl.finish_msg_sync() def _fetch_all_channels(self): self._conn_ctrl.fetch_channels_sequential(0) return False def remove_channel(self, channel: Channel): # Clear channel on device by setting empty name and zero PSK self.send_frame(build_set_channel(channel.index, "", bytes(16))) self._storage.remove_channel(channel.index) self._channels = [ch for ch in self._channels if ch.index != channel.index] self._frame_handler.refresh_channels() def set_channel_notification_level(self, channel: Channel, level): channel.notification_level = level self._storage.set_channel_notification_level(channel.index, level) def reboot_device(self): self.send_frame(build_reboot()) def factory_reset_device(self): from meshy.protocol import build_factory_reset self.send_frame(build_factory_reset()) def refresh_contacts(self): self._frame_handler.send_get_contacts() def set_message_status(self, msg_id, status, detail=""): self._storage.update_message_status(msg_id, status) self._chat_view.update_message_status(msg_id, status, detail) def schedule_device_contacts_refetch(self): GLib.timeout_add(500, lambda: self._frame_handler.send_get_contacts() or False) def refresh_stats(self): self.send_frame(build_get_stats(STATS_TYPE_CORE)) GLib.timeout_add(200, lambda: self.send_frame(build_get_stats(STATS_TYPE_RADIO)) or False) GLib.timeout_add(400, lambda: self.send_frame(build_get_stats(STATS_TYPE_PACKETS)) or False) def set_path_hash_mode(self, mode: int): """Set the device's path hash mode: 0=1-byte, 1=2-byte, 2=3-byte.""" self.send_frame(build_set_path_hash_mode(mode)) self._device_info.path_hash_mode = mode self._device_view.update_device_info(self._device_info) # ─── Public API for views ───────────────────────────────── @property def storage(self) -> Storage | None: return self._storage @property def self_info(self) -> dict: return self._self_info @property def split_view(self): return self._split_view @property def content_page(self): return self._content_page @property def toast_overlay(self): return self._toast_overlay @property def ble_transport(self): return self._ble @property def rx_log(self) -> list: return self._rx_log @property def cli_prefix_counter(self) -> int: return self._cli_prefix_counter @cli_prefix_counter.setter def cli_prefix_counter(self, value: int): self._cli_prefix_counter = value def show_toast(self, message: str, timeout: int = 3): toast = Adw.Toast(title=GLib.markup_escape_text(message), timeout=timeout) self._toast_overlay.add_toast(toast) def update_nav_unread_contacts(self): self._nav_contacts_dot.set_visible(bool(self._contacts_view._unread_keys)) def update_nav_unread_channels(self): self._nav_channels_dot.set_visible(bool(self._channels_view._unread_indices)) def send_frame(self, frame: bytes): self._transport.send_data(frame) def refresh_chat_header(self): self._refresh_chat_header() def show_contact_detail(self, contact: Contact): self._contacts_view._show_contact_detail(contact) def get_regions(self) -> list[str]: if self._storage: return self._storage.get_regions() return [] def set_cli_response_callback(self, callback): self._cli_response_callback = callback def set_rx_log_callback(self, callback): self._rx_log_callback = callback def set_radio_stats_callback(self, callback): self._radio_stats_callback = callback def set_advert_listener(self, callback): self._advert_listener = callback def set_status_callback(self, callback): self._status_callback = callback def set_neighbors_callback(self, callback): self._neighbors_callback = callback if callback is None: self._cancel_pending_binary("neighbors") def set_acl_callback(self, callback): self._acl_callback = callback if callback is None: self._cancel_pending_binary("acl") def _cancel_pending_binary(self, req_type: str): stale = [k for k, v in self._pending_binary_requests.items() if v["type"] == req_type] for k in stale: del self._pending_binary_requests[k] def set_advert_path_callback(self, callback): self._advert_path_callback = callback def remove_tcp_companion(self, address: str): self._remove_tcp_companion(address) def update_tcp_companion_address(self, old_address: str, new_address: str): self._update_tcp_companion_address(old_address, new_address) def get_messages_for_contact(self, contact, limit=30, offset=0): return self._storage.get_messages(contact.public_key_hex, limit, offset) def get_channel_messages(self, channel_index, limit=30, offset=0): return self._storage.get_channel_messages(channel_index, limit, offset) @property def contacts(self) -> list[Contact]: return self._contacts def update_contacts_from_storage(self): """Reload contact flags and names from storage and refresh the view.""" for i, c in enumerate(self._contacts): stored = self._storage.get_contact(c.public_key_hex) if stored: self._contacts[i].flags = stored.flags self._contacts[i].name = stored.name self._contacts_view.update_contacts(self._contacts) @property def channels(self) -> list[Channel]: return self._channels @property def device_info(self) -> DeviceInfo: return self._device_info @property def is_connected(self) -> bool: return self._transport.is_connected # ─── Backup & Restore ────────────────────────────────────── def get_auto_add_config(self) -> dict: return self._settings_view.get_auto_add_config() def export_backup(self, sections: set[str] | None = None) -> dict: return self._backup.export_backup(self, sections=sections) def import_backup(self, backup: dict, sections: set[str] | None = None, on_complete=None): return self._backup.import_backup(self, backup, sections=sections, on_complete=on_complete) def export_private_key(self, callback): self._private_key_callback = callback self.send_frame(build_export_private_key()) def import_private_key(self, key: bytes): self.send_frame(build_import_private_key(key)) meshy/tests/000077500000000000000000000000001523412555500133545ustar00rootroot00000000000000meshy/tests/conftest.py000066400000000000000000000013421523412555500155530ustar00rootroot00000000000000import sys from pathlib import Path # The meshy package lives at src/ (imported as "meshy.*" by the installed app). # For tests, make "import meshy.storage" work by pointing at src's parent # with src aliased as "meshy" via a symlink-free approach. _src = Path(__file__).resolve().parent.parent / "src" if str(_src.parent) not in sys.path: sys.path.insert(0, str(_src.parent)) # Register src/ as the "meshy" package import types if "meshy" not in sys.modules: pkg = types.ModuleType("meshy") pkg.__path__ = [str(_src)] pkg.__package__ = "meshy" sys.modules["meshy"] = pkg import pytest from meshy.storage import Storage @pytest.fixture def storage(): s = Storage(db_path=":memory:") yield s s.close() meshy/tests/test_app_trip_time.py000066400000000000000000000217361523412555500176320ustar00rootroot00000000000000"""Tests for app-side trip time measurement and ACK hash collision handling.""" import builtins import gettext import time from unittest.mock import MagicMock, patch if not hasattr(builtins, "_"): gettext.install("meshy") from meshy.frame_handler import FrameHandler from meshy.message_controller import MessageController from meshy.models import Contact, ContactType, MessageStatus from meshy.protocol import PUSH_CODE_SEND_CONFIRMED from meshy.storage import Storage def _make_contact(path_length=-1, path=b""): return Contact( public_key=bytes(range(32)), name="Test", type=ContactType.CHAT, path_length=path_length, path=path, ) def _make_ctrl(): storage = Storage(db_path=":memory:") win = MagicMock() win._storage = storage win._contacts_by_key = {} ctrl = MessageController(win) return ctrl, storage # ─── _do_send_message records send time ────────────────────── class TestSendTimeRecording: def setup_method(self): self.ctrl, self.storage = _make_ctrl() def teardown_method(self): self.storage.close() def test_do_send_message_records_last_send_mono(self): contact = _make_contact() msg_id = "msg-1" self.ctrl.retry_context[msg_id] = { "contact": contact, "text": "hello", "timestamp": int(time.time()), "attempt": 0, "strategy": "force_flood", } before = time.monotonic() self.ctrl._do_send_message(msg_id) after = time.monotonic() ctx = self.ctrl.retry_context[msg_id] assert "last_send_mono" in ctx assert before <= ctx["last_send_mono"] <= after # ─── register_ack_key stores send time ─────────────────────── class TestRegisterAckKey: def setup_method(self): self.ctrl, self.storage = _make_ctrl() def teardown_method(self): self.storage.close() def test_register_ack_key_stores_send_time(self): msg_id = "msg-1" send_time = time.monotonic() self.ctrl.retry_context[msg_id] = { "attempt": 0, "attempt_paths": {0: {"path_length": 2, "path_bytes": "aabb"}}, "last_send_mono": send_time, } self.ctrl.register_ack_key(msg_id, "deadbeef") ctx = self.ctrl.retry_context[msg_id] assert ctx["ack_send_times"]["deadbeef"] == send_time assert ctx["ack_paths"]["deadbeef"] == {"path_length": 2, "path_bytes": "aabb"} def test_collision_overwrites_with_latest_send_time(self): msg_id = "msg-1" t0 = time.monotonic() self.ctrl.retry_context[msg_id] = { "attempt": 0, "attempt_paths": { 0: {"path_length": 2, "path_bytes": "aabb"}, }, "last_send_mono": t0, } self.ctrl.register_ack_key(msg_id, "deadbeef") t4 = time.monotonic() ctx = self.ctrl.retry_context[msg_id] ctx["attempt"] = 4 ctx["attempt_paths"][4] = {"path_length": -1, "path_bytes": ""} ctx["last_send_mono"] = t4 self.ctrl.register_ack_key(msg_id, "deadbeef") assert ctx["ack_send_times"]["deadbeef"] == t4 assert ctx["ack_paths"]["deadbeef"]["path_length"] == -1 # ─── Grace context preserves send times ────────────────────── class TestGraceContextSendTimes: def setup_method(self): self.ctrl, self.storage = _make_ctrl() def teardown_method(self): self.storage.close() @patch("meshy.message_controller.GLib") def test_grace_context_includes_ack_send_times(self, mock_glib): contact = _make_contact() msg_id = "msg-1" send_time = 12345.678 self.ctrl.retry_context[msg_id] = { "contact": contact, "attempt": 2, "strategy": "force_flood", "ack_paths": {"aabb": {"path_length": -1}}, "ack_send_times": {"aabb": send_time}, "path_used": {"path_length": -1}, } self.ctrl._acks_by_msg[msg_id] = {"aabb"} self.ctrl.pending_acks["aabb"] = msg_id self.ctrl._on_message_timeout(msg_id, "aabb") assert msg_id in self.ctrl.grace_contexts assert self.ctrl.grace_contexts[msg_id]["ack_send_times"]["aabb"] == send_time # ─── Frame handler uses app-side trip time ─────────────────── def _make_fh(): storage = Storage(db_path=":memory:") win = MagicMock() win._storage = storage win._self_info = {"name": "Me"} win._channels_view.on_path_variant_received.return_value = False win._channels = [] win._channels_view_inst = None win._conn_ctrl.channel_sync_index = -1 win._device_info.path_hash_mode = 0 ctrl = MessageController(win) win._msg_ctrl = ctrl fh = FrameHandler(win) return fh, ctrl, storage def _ack_frame(ack_key_hex, trip_time_ms): return ( bytes([PUSH_CODE_SEND_CONFIRMED]) + bytes.fromhex(ack_key_hex) + trip_time_ms.to_bytes(4, "little") ) class TestFrameHandlerAppTripTime: def setup_method(self): self.fh, self.ctrl, self.storage = _make_fh() def teardown_method(self): self.storage.close() def test_primary_ack_uses_app_side_trip_time(self): msg_id = "msg-1" ack_key = "deadbeef" send_mono = time.monotonic() - 2.5 contact = _make_contact() self.ctrl.pending_acks[ack_key] = msg_id self.ctrl._acks_by_msg[msg_id] = {ack_key} self.ctrl.retry_context[msg_id] = { "contact": contact, "attempt": 0, "strategy": "force_flood", "path_used": {"path_length": -1, "path_bytes": "", "hop_count": 0}, "ack_paths": {ack_key: {"path_length": -1, "path_bytes": ""}}, "ack_send_times": {ack_key: send_mono}, } self.fh.handle_frame(_ack_frame(ack_key, 99999)) status_call = self.fh._win.set_message_status.call_args assert status_call[0][1] == MessageStatus.DELIVERED detail = status_call[0][2] # App-side ~2500ms, not firmware's 99999ms assert "99" not in detail def test_fallback_to_firmware_trip_time_without_send_time(self): msg_id = "msg-2" ack_key = "cafebabe" contact = _make_contact() self.ctrl.pending_acks[ack_key] = msg_id self.ctrl._acks_by_msg[msg_id] = {ack_key} self.ctrl.retry_context[msg_id] = { "contact": contact, "attempt": 0, "strategy": "force_flood", "path_used": {"path_length": -1, "path_bytes": "", "hop_count": 0}, } self.fh.handle_frame(_ack_frame(ack_key, 5000)) status_call = self.fh._win.set_message_status.call_args detail = status_call[0][2] assert "5.0" in detail and "s" in detail def test_grace_ack_uses_app_side_trip_time(self): msg_id = "msg-3" ack_key = "11223344" send_mono = time.monotonic() - 1.2 self.ctrl.grace_acks[ack_key] = msg_id self.ctrl._grace_by_msg[msg_id] = {ack_key} self.ctrl.grace_contexts[msg_id] = { "contact_key": "00" * 32, "ack_paths": {ack_key: {"path_length": 2, "path_bytes": "aabb", "hop_count": 2}}, "ack_send_times": {ack_key: send_mono}, "path_used": {"path_length": 2, "path_bytes": "aabb", "hop_count": 2}, } self.fh.handle_frame(_ack_frame(ack_key, 88888)) status_call = self.fh._win.set_message_status.call_args detail = status_call[0][2] assert "late" in detail # App-side ~1200ms, not firmware's 88888ms assert "88" not in detail def test_collision_scenario_uses_latest_attempt_time(self): """Attempts 0 and 4 produce the same ACK hash (attempt & 3 collision). Attempt 0 was sent ~100s ago (timed out), attempt 4 was sent ~2s ago. The ACK arrives and should report ~2s, not ~100s. """ msg_id = "msg-4" ack_key = "aabbccdd" now = time.monotonic() t4 = now - 2 # attempt 4: 2s ago (attempt 0's time was overwritten) contact = _make_contact() self.ctrl.pending_acks[ack_key] = msg_id self.ctrl._acks_by_msg[msg_id] = {ack_key} self.ctrl.retry_context[msg_id] = { "contact": contact, "attempt": 4, "strategy": "smart", "path_used": {"path_length": -1, "path_bytes": "", "hop_count": 0}, "ack_paths": {ack_key: {"path_length": -1, "path_bytes": ""}}, "ack_send_times": {ack_key: t4}, } self.fh.handle_frame(_ack_frame(ack_key, 100000)) status_call = self.fh._win.set_message_status.call_args detail = status_call[0][2] trip_val = float(detail.replace(" ", "").replace(" ", "").rstrip("s").replace(",", ".")) assert trip_val < 10 # app-side ~2s, not firmware's 100s meshy/tests/test_backup_blocked_senders.py000066400000000000000000000115411523412555500214420ustar00rootroot00000000000000"""Tests for blocked channel senders in backup/restore.""" from types import SimpleNamespace from unittest.mock import MagicMock, patch from meshy.backup import BackupManager from meshy.models import Channel def _make_win(storage): win = MagicMock() win.storage = storage win._storage = storage win.channels = [] win.contacts = [] win.self_info = {} win.device_info = SimpleNamespace( name="", public_key="", radio_freq=0, radio_bw=0, radio_sf=0, radio_cr=0, tx_power=0, latitude=0, longitude=0, ) win._device_info = win.device_info return win class TestBackupBlockedSenders: def test_export_includes_blocked_senders(self, storage): win = _make_win(storage) win.channels = [Channel(index=0, name="General", psk=bytes(16))] storage.save_channel(win.channels[0]) storage.block_channel_sender("Spammer") storage.block_channel_sender("Troll") mgr = BackupManager() backup = mgr.export_backup(win, sections={"messages"}) assert "blocked_channel_senders" in backup assert sorted(backup["blocked_channel_senders"]) == ["Spammer", "Troll"] def test_export_omits_key_when_no_blocked(self, storage): win = _make_win(storage) win.channels = [] mgr = BackupManager() backup = mgr.export_backup(win, sections={"messages"}) assert "blocked_channel_senders" not in backup @patch("meshy.backup.GLib") def test_import_restores_blocked_senders(self, _mock_glib, storage): win = _make_win(storage) backup = {"blocked_channel_senders": ["Alice", "Bob"]} mgr = BackupManager() mgr._do_import_data(win, backup, {"messages"}) assert storage.get_blocked_channel_senders() == {"Alice", "Bob"} @patch("meshy.backup.GLib") def test_import_updates_frame_handler_cache(self, _mock_glib, storage): win = _make_win(storage) backup = {"blocked_channel_senders": ["Spammer"]} mgr = BackupManager() mgr._do_import_data(win, backup, {"messages"}) win._frame_handler.update_blocked_senders.assert_called_once() @patch("meshy.backup.GLib") def test_import_without_blocked_skips_cache_update(self, _mock_glib, storage): win = _make_win(storage) backup = {} mgr = BackupManager() mgr._do_import_data(win, backup, {"messages"}) win._frame_handler.update_blocked_senders.assert_not_called() @patch("meshy.backup.GLib") def test_round_trip(self, _mock_glib, storage): win = _make_win(storage) win.channels = [Channel(index=0, name="General", psk=bytes(16))] storage.save_channel(win.channels[0]) storage.block_channel_sender("Spammer") storage.block_channel_sender("Troll") mgr = BackupManager() backup = mgr.export_backup(win, sections={"messages"}) storage.unblock_channel_sender("Spammer") storage.unblock_channel_sender("Troll") assert storage.get_blocked_channel_senders() == set() mgr._do_import_data(win, backup, {"messages"}) assert storage.get_blocked_channel_senders() == {"Spammer", "Troll"} def test_available_sections_includes_messages_for_blocked(self): backup = {"blocked_channel_senders": ["Spammer"]} sections = BackupManager.available_sections(backup) assert "messages" in sections class TestBackupRetention: def test_export_includes_retention_levels(self, storage): win = _make_win(storage) win.channels = [Channel(index=0, name="General", psk=bytes(16), retention_days=30)] storage.save_channel(win.channels[0]) mgr = BackupManager() backup = mgr.export_backup(win, sections={"channels"}) assert "retention_levels" in backup assert backup["retention_levels"] == {"0": 30} def test_export_omits_default_retention(self, storage): win = _make_win(storage) win.channels = [Channel(index=0, name="General", psk=bytes(16), retention_days=0)] storage.save_channel(win.channels[0]) mgr = BackupManager() backup = mgr.export_backup(win, sections={"channels"}) assert "retention_levels" not in backup @patch("meshy.backup.GLib") def test_import_restores_retention(self, _mock_glib, storage): win = _make_win(storage) storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) backup = {"retention_levels": {"0": 7}} mgr = BackupManager() mgr._do_import_data(win, backup, {"channels"}) ch = storage.get_channel(0) assert ch.retention_days == 7 def test_available_sections_includes_channels_for_retention(self): backup = {"retention_levels": {"0": 30}} sections = BackupManager.available_sections(backup) assert "channels" in sections meshy/tests/test_contact_sorting.py000066400000000000000000000154751523412555500202010ustar00rootroot00000000000000"""Tests for contact sorting and last_message_at preservation.""" import builtins import gettext import importlib.util import sys from datetime import datetime, timedelta from pathlib import Path from unittest.mock import MagicMock if not hasattr(builtins, "_"): gettext.install("meshy") from meshy.models import Contact, ContactType from meshy.storage import Storage # contacts_view.py contains pure-Python sort logic but its package __init__ # pulls in GTK/Shumate. Load the single file directly to skip that chain. _src = Path(__file__).resolve().parent.parent / "src" / "views" / "contacts_view.py" _spec = importlib.util.spec_from_file_location("_contacts_view_isolated", _src) _mod = importlib.util.module_from_spec(_spec) _GI_STUBS = ["gi", "gi.repository"] _gi_originals = {m: sys.modules.pop(m, None) for m in _GI_STUBS} for m in _GI_STUBS: sys.modules[m] = MagicMock() _spec.loader.exec_module(_mod) for m in _GI_STUBS: sys.modules.pop(m, None) if _gi_originals[m] is not None: sys.modules[m] = _gi_originals[m] contact_sort_key = _mod.contact_sort_key SORT_AZ = _mod.SORT_AZ SORT_HEARD = _mod.SORT_HEARD SORT_MESSAGES = _mod.SORT_MESSAGES def _contact(name, *, fav=False, last_msg=None, last_seen=None, ctype=ContactType.CHAT): return Contact( public_key=bytes(range(32)), name=name, type=ctype, flags=1 if fav else 0, last_message_at=last_msg, last_seen=last_seen, ) # ─── Sort by name (A-Z) ──────────────────────────────────── class TestSortAZ: def test_alphabetical_order(self): contacts = [_contact("Charlie"), _contact("Alice"), _contact("Bob")] contacts.sort(key=lambda c: contact_sort_key(c, SORT_AZ, False)) assert [c.name for c in contacts] == ["Alice", "Bob", "Charlie"] def test_case_insensitive(self): contacts = [_contact("bob"), _contact("Alice")] contacts.sort(key=lambda c: contact_sort_key(c, SORT_AZ, False)) assert [c.name for c in contacts] == ["Alice", "bob"] def test_favorites_first(self): contacts = [ _contact("Zara", fav=False), _contact("Alice", fav=True), _contact("Bob", fav=False), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_AZ, True)) assert [c.name for c in contacts] == ["Alice", "Bob", "Zara"] def test_favorites_sorted_among_themselves(self): contacts = [ _contact("Zara", fav=True), _contact("Alice", fav=True), _contact("Bob", fav=False), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_AZ, True)) assert [c.name for c in contacts] == ["Alice", "Zara", "Bob"] # ─── Sort by latest message ──────────────────────────────── class TestSortMessages: def test_newest_message_first(self): now = datetime.now() contacts = [ _contact("Old", last_msg=now - timedelta(hours=2)), _contact("New", last_msg=now), _contact("Mid", last_msg=now - timedelta(hours=1)), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_MESSAGES, False)) assert [c.name for c in contacts] == ["New", "Mid", "Old"] def test_no_message_sorts_last(self): now = datetime.now() contacts = [ _contact("NoMsg"), _contact("HasMsg", last_msg=now), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_MESSAGES, False)) assert [c.name for c in contacts] == ["HasMsg", "NoMsg"] def test_favorites_first_with_messages(self): now = datetime.now() contacts = [ _contact("NonFav", last_msg=now), _contact("Fav", fav=True, last_msg=now - timedelta(hours=5)), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_MESSAGES, True)) assert [c.name for c in contacts] == ["Fav", "NonFav"] def test_repeater_without_message_below_chat_with_message(self): now = datetime.now() contacts = [ _contact("Repeater", fav=True, ctype=ContactType.REPEATER), _contact("ChatContact", fav=True, last_msg=now), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_MESSAGES, True)) assert [c.name for c in contacts] == ["ChatContact", "Repeater"] # ─── Sort by last heard ───────────────────────────────────── class TestSortHeard: def test_most_recently_heard_first(self): now = datetime.now() contacts = [ _contact("Old", last_seen=now - timedelta(hours=3)), _contact("New", last_seen=now), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_HEARD, False)) assert [c.name for c in contacts] == ["New", "Old"] def test_no_last_seen_sorts_last(self): now = datetime.now() contacts = [ _contact("NeverSeen"), _contact("Seen", last_seen=now), ] contacts.sort(key=lambda c: contact_sort_key(c, SORT_HEARD, False)) assert [c.name for c in contacts] == ["Seen", "NeverSeen"] # ─── last_message_at preservation (storage) ───────────────── class TestStorageLastMessageAtPreservation: def setup_method(self): self.storage = Storage(db_path=":memory:") def teardown_method(self): self.storage.close() def test_save_contact_preserves_last_message_at_via_coalesce(self): now = datetime.now() c = Contact( public_key=bytes(range(32)), name="Alice", type=ContactType.CHAT, last_message_at=now, ) self.storage.save_contact(c) c_update = Contact( public_key=bytes(range(32)), name="Alice", type=ContactType.CHAT, last_message_at=None, ) self.storage.save_contact(c_update) loaded = self.storage.get_contacts() alice = [x for x in loaded if x.name == "Alice"][0] assert alice.last_message_at is not None assert abs(alice.last_message_at.timestamp() - now.timestamp()) < 1 def test_save_contact_updates_last_message_at_when_provided(self): old = datetime(2025, 1, 1) new = datetime(2026, 6, 15) c = Contact( public_key=bytes(range(32)), name="Bob", type=ContactType.CHAT, last_message_at=old, ) self.storage.save_contact(c) c.last_message_at = new self.storage.save_contact(c) loaded = self.storage.get_contacts() bob = [x for x in loaded if x.name == "Bob"][0] assert abs(bob.last_message_at.timestamp() - new.timestamp()) < 1 meshy/tests/test_format_trip_time.py000066400000000000000000000033151523412555500203330ustar00rootroot00000000000000"""Tests for the shared format_trip_time utility.""" import locale from meshy.utils import format_trip_time NNBSP = " " class TestFormatTripTime: def test_none_returns_empty(self): assert format_trip_time(None) == "" def test_zero_returns_empty(self): assert format_trip_time(0) == "" def test_sub_second(self): result = format_trip_time(450) assert result.endswith(NNBSP + "s") num = result.removesuffix(NNBSP + "s") assert float(num.replace(",", ".")) == 0.4 or float(num.replace(",", ".")) == 0.5 def test_one_second(self): result = format_trip_time(1000) num = result.removesuffix(NNBSP + "s") assert float(num.replace(",", ".")) == 1.0 def test_multi_second(self): result = format_trip_time(2500) num = result.removesuffix(NNBSP + "s") assert float(num.replace(",", ".")) == 2.5 def test_large_value(self): result = format_trip_time(15000) num = result.removesuffix(NNBSP + "s") assert float(num.replace(",", ".")) == 15.0 def test_small_value(self): result = format_trip_time(50) assert result.endswith(NNBSP + "s") num = result.removesuffix(NNBSP + "s") assert float(num.replace(",", ".")) == 0.1 or float(num.replace(",", ".")) == 0.0 def test_narrow_no_break_space_before_unit(self): result = format_trip_time(1000) assert NNBSP + "s" in result def test_one_decimal_place(self): result = format_trip_time(3000) num = result.removesuffix(NNBSP + "s") sep = locale.localeconv()["decimal_point"] parts = num.split(sep) assert len(parts) == 2 assert len(parts[1]) == 1 meshy/tests/test_frame_handler_blocking.py000066400000000000000000000113531523412555500214270ustar00rootroot00000000000000"""Tests for channel sender blocking and ghost message filtering in FrameHandler.""" import time from unittest.mock import MagicMock from meshy.frame_handler import FrameHandler from meshy.models import Channel, NotificationLevel def _make_channel(index=0, name="General", psk=None): return Channel( index=index, name=name, psk=psk or bytes(range(16)), notification_level=NotificationLevel.NONE, ) def _make_win(storage): win = MagicMock() win._storage = storage win._self_info = {"name": "Me"} win._channels_view.on_path_variant_received.return_value = False win._channels = [_make_channel()] win._channels_view_inst = None win._conn_ctrl.channel_sync_index = -1 return win def _make_msg(sender_name, channel_idx=0, text="hello"): return { "sender_name": sender_name, "msg_text": text, "sender_timestamp": time.time(), "channel_idx": channel_idx, } class TestBlockedSenderFiltering: def test_blocked_message_is_dropped(self, storage): win = _make_win(storage) fh = FrameHandler(win) storage.block_channel_sender("Spammer") fh.load_blocked_senders() storage.save_channel_message = MagicMock() fh.handle_incoming_channel_message(_make_msg("Spammer")) storage.save_channel_message.assert_not_called() assert not win._channels_view.on_channel_message_received.called def test_unblocked_message_is_saved(self, storage): win = _make_win(storage) fh = FrameHandler(win) fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("Alice")) win._channels_view.on_channel_message_received.assert_called_once() def test_update_blocked_senders_reflects_changes(self, storage): win = _make_win(storage) fh = FrameHandler(win) fh.load_blocked_senders() assert fh._blocked_senders == set() storage.block_channel_sender("Spammer") fh.update_blocked_senders() assert "Spammer" in fh._blocked_senders storage.unblock_channel_sender("Spammer") fh.update_blocked_senders() assert "Spammer" not in fh._blocked_senders def test_echo_not_affected_by_block(self, storage): win = _make_win(storage) fh = FrameHandler(win) storage.block_channel_sender("Me") fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("Me")) win._channels_view.on_repeat_received.assert_called_once() win._channels_view.on_channel_message_received.assert_not_called() def test_empty_sender_name_not_blocked(self, storage): win = _make_win(storage) fh = FrameHandler(win) storage.block_channel_sender("") fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("")) win._channels_view.on_channel_message_received.assert_called_once() class TestGhostMessageFiltering: """Guard against firmware delivering messages for unconfigured channels. Empty channel slots (all-zero PSK) get hash 0x37 in the firmware and can receive crafted group messages. After channel sync completes Meshy must drop these. """ def test_message_for_unconfigured_channel_dropped(self, storage): win = _make_win(storage) fh = FrameHandler(win) fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("Alice", channel_idx=5)) win._channels_view.on_channel_message_received.assert_not_called() def test_message_for_configured_channel_accepted(self, storage): win = _make_win(storage) fh = FrameHandler(win) fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("Alice", channel_idx=0)) win._channels_view.on_channel_message_received.assert_called_once() def test_empty_channel_slot_treated_as_unconfigured(self, storage): """A slot with empty name and zero PSK must not accept messages.""" win = _make_win(storage) ghost = Channel.empty(3) win._channels.append(ghost) fh = FrameHandler(win) fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("Alice", channel_idx=3)) win._channels_view.on_channel_message_received.assert_not_called() def test_guard_inactive_during_channel_sync(self, storage): """During sync, messages pass through even for unknown indices.""" win = _make_win(storage) win._conn_ctrl.channel_sync_index = 2 # sync in progress fh = FrameHandler(win) fh.load_blocked_senders() fh.handle_incoming_channel_message(_make_msg("Alice", channel_idx=5)) win._channels_view.on_channel_message_received.assert_called_once() meshy/tests/test_imports.py000066400000000000000000000025401523412555500164630ustar00rootroot00000000000000"""Smoke-test that all meshy modules can be imported. Catches missing symbols, broken re-exports, and circular imports that would otherwise only surface at runtime. """ import importlib import pkgutil import meshy # Modules that need optional GI typelibs (Shumate, WebKit, etc.) # or platform-specific frameworks (PyObjC) — skip when unavailable. _OPTIONAL_DEPS = {"Shumate", "WebKit", "CoreBluetooth", "CoreLocation", "ServiceManagement", "objc"} def _is_optional_dep_error(exc): msg = str(exc) if any(dep in msg for dep in _OPTIONAL_DEPS): return True if "g-resource-error-quark" in msg: return True return False def _iter_meshy_modules(): """Yield dotted names of all meshy submodules (recursive).""" prefix = meshy.__name__ + "." for info in pkgutil.walk_packages(meshy.__path__, prefix, onerror=lambda _name: None): yield info.name def test_all_modules_importable(): failures = {} for name in _iter_meshy_modules(): try: importlib.import_module(name) except Exception as exc: if _is_optional_dep_error(exc): continue failures[name] = exc if failures: lines = [f" {mod}: {err}" for mod, err in sorted(failures.items())] raise AssertionError(f"{len(failures)} module(s) failed to import:\n" + "\n".join(lines)) meshy/tests/test_mesh_crypto.py000066400000000000000000000114271523412555500173260ustar00rootroot00000000000000import struct from meshy.mesh_crypto import ( PAYLOAD_ADVERT, PAYLOAD_GROUP_TEXT, channel_hash, decode_log_packet, parse_advert_payload, ) from meshy.models import Channel class TestChannelHash: def test_deterministic(self): psk = "aa" * 16 assert channel_hash(psk) == channel_hash(psk) def test_public_channel_psk(self): h = channel_hash(Channel.PUBLIC_CHANNEL_PSK) assert len(h) == 2 assert all(c in "0123456789abcdef" for c in h) def test_different_psk_different_hash(self): assert channel_hash("aa" * 16) != channel_hash("bb" * 16) class TestDecodeLogPacket: def test_too_short(self): assert decode_log_packet(b"\x00") is None def test_advert_packet(self): header = (PAYLOAD_ADVERT << 2) | 0x01 # route_type FLOOD (no transport) path_len_byte = 0 # direct, hash_size 1 pub_key = bytes(32) timestamp = struct.pack(" self.service.score(bad) def test_faster_path_scores_higher(self): fast = _make_record(success=10, failure=0, trip_ms=100) slow = _make_record(success=10, failure=0, trip_ms=10000) assert self.service.score(fast) > self.service.score(slow) def test_fresher_path_scores_higher(self): now = time.time() fresh = _make_record(last_success_at=now) stale = _make_record(last_success_at=now - 30 * 86400) assert self.service.score(fresh) > self.service.score(stale) def test_no_trip_ms_uses_default(self): now = time.time() rec_none = _make_record(trip_ms=None, last_success_at=now) rec_zero = _make_record(trip_ms=0, last_success_at=now) rec_fast = _make_record(trip_ms=100, last_success_at=now) score_none = self.service.score(rec_none) score_zero = self.service.score(rec_zero) score_fast = self.service.score(rec_fast) assert score_none == pytest.approx(score_zero) assert score_fast > score_none def test_no_last_success_zero_freshness(self): rec = _make_record(last_success_at=None) rec_fresh = _make_record() score_none = self.service.score(rec) score_fresh = self.service.score(rec_fresh) assert score_none < score_fresh reliability = (10 + 1) / (10 + 2) * 0.6 latency = 1.0 / (1.0 + 500 / 10000.0) * 0.2 expected = reliability + latency + 0.0 assert abs(score_none - expected) < 0.001 # ─── select_path() ──────────────────────────────────────────── class TestSelectPath: def setup_method(self): self.storage = _make_storage() self.service = PathHistoryService(self.storage) def teardown_method(self): self.storage.close() def test_empty_returns_none(self): assert self.service.select_path(CONTACT_HEX) is None def test_returns_best_scored(self): self.storage.upsert_path_record(CONTACT_HEX, "good", 1, 1, success=True, trip_ms=100) self.storage.upsert_path_record(CONTACT_HEX, "bad", 1, 3, success=False) # Add more successes to "good" for _ in range(5): self.storage.upsert_path_record(CONTACT_HEX, "good", 1, 1, success=True, trip_ms=100) best = self.service.select_path(CONTACT_HEX) assert best is not None assert best.path_bytes == "good" def test_exclude_paths(self): self.storage.upsert_path_record(CONTACT_HEX, "path_a", 1, 1, success=True, trip_ms=100) self.storage.upsert_path_record(CONTACT_HEX, "path_b", 1, 2, success=True, trip_ms=200) result = self.service.select_path(CONTACT_HEX, exclude_paths=["path_a"]) assert result is not None assert result.path_bytes == "path_b" def test_exclude_all_returns_none(self): self.storage.upsert_path_record(CONTACT_HEX, "only", 1, 1, success=True) assert self.service.select_path(CONTACT_HEX, exclude_paths=["only"]) is None # ─── record_result() ────────────────────────────────────────── class TestRecordResult: def setup_method(self): self.storage = _make_storage() self.service = PathHistoryService(self.storage) def teardown_method(self): self.storage.close() def test_success_increments(self): self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=True, trip_ms=300) self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=True, trip_ms=250) history = self.storage.get_path_history(CONTACT_HEX) assert history[0]["success_count"] == 2 def test_failure_increments(self): self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=True) self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=False) history = self.storage.get_path_history(CONTACT_HEX) assert history[0]["failure_count"] == 1 def test_weight_zero_deletes_record(self): self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=True) for _ in range(20): self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=False) assert self.storage.get_path_history(CONTACT_HEX) == [] assert not self.service.has_paths(CONTACT_HEX) def test_weight_zero_deletes_only_dead_path(self): self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=True) self.service.record_result(CONTACT_HEX, "path2", 1, 1, success=True, trip_ms=200) for _ in range(20): self.service.record_result(CONTACT_HEX, "path1", 1, 2, success=False) history = self.storage.get_path_history(CONTACT_HEX) assert len(history) == 1 assert history[0]["path_bytes"] == "path2" meshy/tests/test_protocol.py000066400000000000000000000347221523412555500166360ustar00rootroot00000000000000import struct from meshy.meshcore_packets import PacketType from meshy.protocol import ( CMD_GET_ALLOWED_REPEAT_FREQ, CMD_GET_CONTACT_BY_KEY, CMD_LOGOUT, CMD_SEND_TXT_MSG, CMD_SET_ADVERT_LATLON, CMD_SET_RADIO_TX_POWER, LPP_GPS, LPP_HUMIDITY, LPP_TEMPERATURE, RESP_CODE_ALLOWED_REPEAT_FREQ, RESP_CODE_BATT_AND_STORAGE, RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3, RESP_CODE_CONTACT, RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CUSTOM_VARS, RESP_CODE_DEVICE_INFO, RESP_CODE_ERR, RESP_CODE_SENT, TXT_TYPE_PLAIN, _parse_path_length_byte, _parse_snr, build_get_allowed_repeat_freq, build_get_contact_by_key, build_logout, build_send_text_msg, build_set_advert_latlon, build_set_radio_tx_power, max_channel_text_bytes, max_dm_text_bytes, parse_advert_path_response, parse_allowed_repeat_freq, parse_batt_and_storage, parse_cayenne_lpp, parse_channel_message, parse_contact_frame, parse_contact_message, parse_custom_vars, parse_device_info, parse_error_response, parse_sent_response, ) # ─── Helpers ────────────────────────────────────────────────── class TestParsePathLengthByte: def test_flood(self): assert _parse_path_length_byte(255) == (-1, 1) def test_direct(self): hop, hs = _parse_path_length_byte(0) assert hop == 0 and hs == 1 def test_normal_1byte_hash(self): hop, hs = _parse_path_length_byte(3) assert hop == 3 and hs == 1 def test_2byte_hash(self): hop, hs = _parse_path_length_byte(0b01_000010) assert hop == 2 and hs == 2 def test_3byte_hash(self): hop, hs = _parse_path_length_byte(0b10_000001) assert hop == 1 and hs == 3 class TestParseSNR: def test_positive(self): assert _parse_snr(bytes([20])) == 5.0 def test_negative(self): val = _parse_snr(b"\xf0") # -16 signed assert val == -4.0 def test_zero(self): assert _parse_snr(bytes([0])) == 0.0 # ─── Frame Parsers ──────────────────────────────────────────── class TestParseContactFrame: def _build_frame(self): frame = bytes([RESP_CODE_CONTACT]) frame += bytes(32) # public_key frame += bytes([1]) # type = CHAT frame += bytes([0]) # flags frame += bytes([2]) # path_len_byte: 2 hops, hash_size 1 frame += bytes(64) # path (64 bytes) frame += b"TestNode" + bytes(24) # name (32 bytes) frame += struct.pack("h", 256) result = parse_cayenne_lpp(data) assert len(result) == 1 assert result[0]["name"] == "Temperature" assert result[0]["value"] == 25.6 assert result[0]["unit"] == "°C" def test_humidity(self): data = bytes([2, LPP_HUMIDITY, 140]) result = parse_cayenne_lpp(data) assert result[0]["value"] == 70.0 assert result[0]["unit"] == "%" def test_gps(self): lat_raw = int(50.1234 * 10000).to_bytes(3, "big", signed=True) lon_raw = int(14.4567 * 10000).to_bytes(3, "big", signed=True) alt_raw = int(250.0 * 100).to_bytes(3, "big", signed=True) data = bytes([3, LPP_GPS]) + lat_raw + lon_raw + alt_raw result = parse_cayenne_lpp(data) assert abs(result[0]["value"]["lat"] - 50.1234) < 0.001 assert abs(result[0]["value"]["lon"] - 14.4567) < 0.001 def test_empty_data(self): assert parse_cayenne_lpp(bytes([0, 0])) == [] # ─── Frame Builders ─────────────────────────────────────────── class TestBuildGetContactByKey: def test_layout(self): pub_key = bytes(range(32)) frame = build_get_contact_by_key(pub_key) assert len(frame) == 33 assert frame[0] == CMD_GET_CONTACT_BY_KEY assert frame[1:] == pub_key class TestBuildSendTextMsg: def test_layout(self): pub_key = bytes(range(32)) frame = build_send_text_msg(pub_key, "Hi", attempt=2, timestamp=1700000000) assert frame[0] == CMD_SEND_TXT_MSG assert frame[1] == TXT_TYPE_PLAIN assert frame[2] == 2 # attempt ts = struct.unpack_from(" OK", "signature": sender_key[:8], } ) saved_msg = storage.get_messages(room_key) assert len(saved_msg) == 1 assert saved_msg[0].room_sender_name == "" # ─── ACL Response Parsing ───────────────────────────────── class TestParseAclResponse: def test_single_entry(self): data = _build_acl_response([("aabbccddeeff", 3)]) result = parse_acl_response(data) assert result == [{"key": "aabbccddeeff", "perm": 3}] def test_multiple_entries(self): data = _build_acl_response( [ ("aabbccddeeff", 3), ("112233445566", 2), ("ffeeddccbbaa", 1), ] ) result = parse_acl_response(data) assert len(result) == 3 assert result[0]["key"] == "aabbccddeeff" assert result[1]["perm"] == 2 assert result[2]["key"] == "ffeeddccbbaa" def test_zero_key_filtered(self): data = _build_acl_response( [ ("aabbccddeeff", 3), ("000000000000", 1), ] ) result = parse_acl_response(data) assert len(result) == 1 def test_empty_payload(self): data = bytes(6) # header only assert parse_acl_response(data) == [] def test_partial_entry_ignored(self): data = bytes(6) + bytes(5) # header + incomplete entry assert parse_acl_response(data) == [] def test_permission_bitmask(self): data = _build_acl_response([("aabbccddeeff", 0x07)]) result = parse_acl_response(data) assert ACL_ROLES[result[0]["perm"] & 3] == "Admin" # ─── ACL Constants ───────────────────────────────────────── class TestAclConstants: def test_acl_roles_complete(self): assert set(ACL_ROLES.keys()) == {1, 2, 3} def test_role_perms_mapping(self): assert ROLE_PERMS[0] == 3 # Admin assert ROLE_PERMS[1] == 2 # Read-Write assert ROLE_PERMS[2] == 1 # Read Only # ─── Region Text Parsing ────────────────────────────────── class TestParseRegionText: def test_simple_region(self): entries, _ = parse_region_text("US") assert entries == [{"name": "US", "indent": 0, "home": False, "flood": False}] def test_flood_suffix(self): entries, _ = parse_region_text("EU F") assert entries[0]["flood"] is True assert entries[0]["name"] == "EU" def test_home_suffix(self): entries, _ = parse_region_text("US^") assert entries[0]["home"] is True assert entries[0]["name"] == "US" def test_flood_and_home(self): entries, _ = parse_region_text("US^ F") assert entries[0]["flood"] is True assert entries[0]["home"] is True assert entries[0]["name"] == "US" def test_indented_regions(self): text = "*\n US\n East" entries, _ = parse_region_text(text) assert len(entries) == 3 assert entries[0]["indent"] == 0 assert entries[1]["indent"] == 2 assert entries[2]["indent"] == 4 def test_empty_lines_skipped(self): entries, _ = parse_region_text("US\n\nEU") assert len(entries) == 2 def test_default_scope_parsed(self): _, default = parse_region_text("US", "blah blah default scope is US") assert default == "US" def test_default_scope_null(self): _, default = parse_region_text("US", "default scope is ") assert default == "" def test_default_scope_missing(self): _, default = parse_region_text("US", "") assert default == "" def test_empty_region_text(self): entries, default = parse_region_text("", "default scope is US") assert entries == [] assert default == "US" meshy/tests/test_sensor.py000066400000000000000000000276351523412555500163130ustar00rootroot00000000000000import struct from meshy.models import Contact, ContactType, battery_percent_from_mv from meshy.protocol import ( CMD_SEND_BINARY_REQ, CMD_SEND_LOGIN, LPP_ACCELEROMETER, LPP_COLOUR, LPP_CONCENTRATION, LPP_CURRENT, LPP_DIGITAL_INPUT, LPP_DISTANCE, LPP_FREQUENCY, LPP_GPS, LPP_HUMIDITY, LPP_LUMINOSITY, LPP_PERCENTAGE, LPP_PRESSURE, LPP_SWITCH, LPP_TEMPERATURE, LPP_UNIX_TIME, LPP_VOLTAGE, REQ_TYPE_TELEMETRY, build_get_telemetry, build_send_login, parse_cayenne_lpp, ) # ─── Contact model: sensor type ───────────────────────────── class TestSensorContact: def _make(self, **kwargs): defaults = dict(public_key=bytes(32), name="Weather-1", type=ContactType.SENSOR) defaults.update(kwargs) return Contact(**defaults) def test_type_label(self): c = self._make() assert c.type_label == "Sensor" def test_type_value(self): assert ContactType.SENSOR == 4 def test_public_key_hex(self): key = bytes(range(32)) c = self._make(public_key=key) assert c.public_key_hex == key.hex() def test_path_label_flood(self): c = self._make(path_length=-1) assert c.path_label == "Flood" def test_path_label_direct(self): c = self._make(path_length=0) assert c.path_label == "Direct" def test_has_location_valid(self): c = self._make(latitude=50.0, longitude=14.0) assert c.has_location is True def test_has_location_none(self): c = self._make() assert c.has_location is False # ─── Protocol: build_get_telemetry ─────────────────────────── class TestBuildGetTelemetry: def test_frame_layout(self): pub_key = bytes(range(32)) frame = build_get_telemetry(pub_key) assert frame[0] == CMD_SEND_BINARY_REQ assert frame[1:33] == pub_key assert frame[33] == REQ_TYPE_TELEMETRY assert len(frame) == 34 def test_different_keys(self): k1 = bytes([0xAA] * 32) k2 = bytes([0xBB] * 32) f1 = build_get_telemetry(k1) f2 = build_get_telemetry(k2) assert f1[1:33] != f2[1:33] assert f1[0] == f2[0] == CMD_SEND_BINARY_REQ assert f1[33] == f2[33] == REQ_TYPE_TELEMETRY # ─── Protocol: build_send_login (sensor admin) ─────────────── class TestSensorLogin: def test_frame_layout(self): pub_key = bytes(range(32)) frame = build_send_login(pub_key, "secret") assert frame[0] == CMD_SEND_LOGIN assert frame[1:33] == pub_key assert frame[33:-1] == b"secret" assert frame[-1] == 0 # null terminator def test_empty_password(self): frame = build_send_login(bytes(32), "") assert frame[-1] == 0 assert len(frame) == 34 # cmd + 32 key + null # ─── Cayenne LPP: sensor-typical payloads ──────────────────── class TestCayenneLPPSensorPayloads: def test_voltage_battery(self): data = bytes([1, LPP_VOLTAGE]) + struct.pack(">h", 380) result = parse_cayenne_lpp(data) assert len(result) == 1 assert result[0]["channel"] == 1 assert result[0]["name"] == "Voltage" assert abs(result[0]["value"] - 3.80) < 0.01 assert result[0]["unit"] == "V" def test_battery_percent_from_voltage(self): pct = battery_percent_from_mv(3800) assert pct == 50 def test_multi_channel_payload(self): data = b"" data += bytes([1, LPP_VOLTAGE]) + struct.pack(">h", 420) data += bytes([2, LPP_TEMPERATURE]) + struct.pack(">h", 235) data += bytes([3, LPP_HUMIDITY, 120]) result = parse_cayenne_lpp(data) assert len(result) == 3 assert result[0]["channel"] == 1 assert result[0]["type"] == LPP_VOLTAGE assert result[1]["channel"] == 2 assert abs(result[1]["value"] - 23.5) < 0.01 assert result[2]["channel"] == 3 assert result[2]["value"] == 60.0 def test_pressure(self): data = bytes([1, LPP_PRESSURE]) + struct.pack(">H", 10132) result = parse_cayenne_lpp(data) assert abs(result[0]["value"] - 1013.2) < 0.1 assert result[0]["unit"] == "hPa" def test_luminosity(self): data = bytes([1, LPP_LUMINOSITY]) + struct.pack(">H", 450) result = parse_cayenne_lpp(data) assert result[0]["value"] == 450 assert result[0]["unit"] == "lux" def test_digital_input(self): data = bytes([1, LPP_DIGITAL_INPUT, 1]) result = parse_cayenne_lpp(data) assert result[0]["value"] == 1 def test_switch(self): data = bytes([5, LPP_SWITCH, 0]) result = parse_cayenne_lpp(data) assert result[0]["value"] == 0 assert result[0]["name"] == "Switch" def test_percentage(self): data = bytes([1, LPP_PERCENTAGE, 75]) result = parse_cayenne_lpp(data) assert result[0]["value"] == 75 assert result[0]["unit"] == "%" def test_current(self): data = bytes([1, LPP_CURRENT]) + struct.pack(">h", 1500) result = parse_cayenne_lpp(data) assert abs(result[0]["value"] - 1.5) < 0.001 assert result[0]["unit"] == "A" def test_frequency(self): data = bytes([1, LPP_FREQUENCY]) + struct.pack(">I", 868000000) result = parse_cayenne_lpp(data) assert result[0]["value"] == 868000000 assert result[0]["unit"] == "Hz" def test_concentration_ppm(self): data = bytes([1, LPP_CONCENTRATION]) + struct.pack(">H", 400) result = parse_cayenne_lpp(data) assert result[0]["value"] == 400 assert result[0]["unit"] == "ppm" def test_distance(self): data = bytes([1, LPP_DISTANCE]) + struct.pack(">I", 12500) result = parse_cayenne_lpp(data) assert abs(result[0]["value"] - 12.5) < 0.01 assert result[0]["unit"] == "m" def test_unix_time(self): ts = 1719000000 data = bytes([1, LPP_UNIX_TIME]) + struct.pack(">I", ts) result = parse_cayenne_lpp(data) assert result[0]["value"] == ts def test_accelerometer(self): data = bytes([1, LPP_ACCELEROMETER]) data += struct.pack(">h", 100) # x = 0.100G data += struct.pack(">h", -50) # y = -0.050G data += struct.pack(">h", 9810) # z = 9.810G result = parse_cayenne_lpp(data) val = result[0]["value"] assert abs(val["x"] - 0.100) < 0.001 assert abs(val["y"] + 0.050) < 0.001 assert abs(val["z"] - 9.810) < 0.001 def test_colour_rgb(self): data = bytes([1, LPP_COLOUR, 255, 128, 0]) result = parse_cayenne_lpp(data) val = result[0]["value"] assert val == {"r": 255, "g": 128, "b": 0} def test_gps_with_altitude(self): lat_raw = int(49.1953 * 10000).to_bytes(3, "big", signed=True) lon_raw = int(16.6068 * 10000).to_bytes(3, "big", signed=True) alt_raw = int(237.0 * 100).to_bytes(3, "big", signed=True) data = bytes([1, LPP_GPS]) + lat_raw + lon_raw + alt_raw result = parse_cayenne_lpp(data) val = result[0]["value"] assert abs(val["lat"] - 49.1953) < 0.001 assert abs(val["lon"] - 16.6068) < 0.001 assert abs(val["alt"] - 237.0) < 0.1 def test_negative_temperature(self): data = bytes([1, LPP_TEMPERATURE]) + struct.pack(">h", -50) result = parse_cayenne_lpp(data) assert abs(result[0]["value"] - (-5.0)) < 0.01 def test_full_sensor_payload(self): """Realistic sensor payload: battery + temp + humidity + pressure + GPS.""" data = b"" data += bytes([1, LPP_VOLTAGE]) + struct.pack(">h", 395) data += bytes([2, LPP_TEMPERATURE]) + struct.pack(">h", 218) data += bytes([3, LPP_HUMIDITY, 130]) data += bytes([4, LPP_PRESSURE]) + struct.pack(">H", 10153) lat = int(50.0875 * 10000).to_bytes(3, "big", signed=True) lon = int(14.4213 * 10000).to_bytes(3, "big", signed=True) alt = int(195.0 * 100).to_bytes(3, "big", signed=True) data += bytes([5, LPP_GPS]) + lat + lon + alt result = parse_cayenne_lpp(data) assert len(result) == 5 assert result[0]["type"] == LPP_VOLTAGE assert abs(result[0]["value"] - 3.95) < 0.01 assert result[1]["type"] == LPP_TEMPERATURE assert abs(result[1]["value"] - 21.8) < 0.1 assert result[2]["type"] == LPP_HUMIDITY assert result[2]["value"] == 65.0 assert result[3]["type"] == LPP_PRESSURE assert abs(result[3]["value"] - 1015.3) < 0.1 gps = result[4]["value"] assert abs(gps["lat"] - 50.0875) < 0.001 assert abs(gps["lon"] - 14.4213) < 0.001 def test_truncated_payload(self): data = bytes([1, LPP_TEMPERATURE, 0x01]) # missing second byte result = parse_cayenne_lpp(data) assert result == [] def test_unknown_type_stops_parsing(self): data = bytes([1, 0xFE, 0x00, 0x00]) # unknown type result = parse_cayenne_lpp(data) assert result == [] def test_same_channel_multiple_readings(self): data = b"" data += bytes([1, LPP_TEMPERATURE]) + struct.pack(">h", 200) data += bytes([1, LPP_HUMIDITY, 100]) result = parse_cayenne_lpp(data) assert len(result) == 2 assert result[0]["channel"] == result[1]["channel"] == 1 # ─── Storage: sensor contacts ──────────────────────────────── class TestStorageSensorContacts: def test_save_and_load_sensor(self, storage): c = Contact( public_key=bytes(range(32)), name="Outdoor Sensor", type=ContactType.SENSOR, ) storage.save_contact(c) loaded = storage.get_contacts() assert len(loaded) == 1 assert loaded[0].name == "Outdoor Sensor" assert loaded[0].type == ContactType.SENSOR def test_sensor_type_persisted(self, storage): c = Contact( public_key=bytes([0xAA] * 32), name="Indoor", type=ContactType.SENSOR, ) storage.save_contact(c) loaded = storage.get_contacts() assert loaded[0].type == 4 assert loaded[0].type == ContactType.SENSOR def test_sensor_alongside_other_types(self, storage): chat = Contact(public_key=bytes([1] * 32), name="Alice", type=ContactType.CHAT) repeater = Contact(public_key=bytes([2] * 32), name="Relay-1", type=ContactType.REPEATER) sensor = Contact(public_key=bytes([3] * 32), name="Weather", type=ContactType.SENSOR) for c in (chat, repeater, sensor): storage.save_contact(c) loaded = storage.get_contacts() types = {c.name: c.type for c in loaded} assert types["Alice"] == ContactType.CHAT assert types["Relay-1"] == ContactType.REPEATER assert types["Weather"] == ContactType.SENSOR def test_sensor_update_preserves_type(self, storage): key = bytes([0xCC] * 32) c = Contact(public_key=key, name="Sensor-Old", type=ContactType.SENSOR) storage.save_contact(c) c2 = Contact(public_key=key, name="Sensor-New", type=ContactType.SENSOR) storage.save_contact(c2) loaded = storage.get_contacts() assert len(loaded) == 1 assert loaded[0].name == "Sensor-New" assert loaded[0].type == ContactType.SENSOR def test_sensor_with_location(self, storage): c = Contact( public_key=bytes([0xDD] * 32), name="GPS Sensor", type=ContactType.SENSOR, latitude=50.0875, longitude=14.4213, ) storage.save_contact(c) loaded = storage.get_contacts() assert loaded[0].has_location is True assert abs(loaded[0].latitude - 50.0875) < 0.001 meshy/tests/test_smart_routing.py000066400000000000000000000204021523412555500176600ustar00rootroot00000000000000"""Tests for smart routing: standalone CONTACT handling, PATH_UPDATED parsing, and flood attribution chain.""" import struct from unittest.mock import MagicMock, patch from meshy.frame_handler import FrameHandler from meshy.message_controller import MessageController from meshy.protocol import ( PUSH_CODE_PATH_UPDATED, RESP_CODE_CONTACT, ) from meshy.storage import Storage PUB_KEY = bytes(range(32)) PUB_KEY_HEX = PUB_KEY.hex() def _contact_dict(path_len=2, path_hex="aabb", lastmod=100): return { "public_key": PUB_KEY_HEX, "type": 1, "flags": 0, "out_path_len": path_len, "path_hash_size": 1, "out_path": path_hex, "adv_name": "Test", "adv_lat": 0.0, "adv_lon": 0.0, "last_advert": 0, "lastmod": lastmod, } def _build_contact_frame(pub_key=PUB_KEY, path_len_byte=2, path=None): """Build a raw CONTACT frame (code 3) matching the firmware format.""" frame = bytes([RESP_CODE_CONTACT]) frame += pub_key frame += bytes([1]) # type = CHAT frame += bytes([0]) # flags frame += bytes([path_len_byte]) frame += (path or bytes(64)).ljust(64, b"\x00")[:64] frame += b"TestNode" + bytes(24) # name (32 bytes) frame += struct.pack("= 0.""" self.win._msg_ctrl.pending_flood_attributions[PUB_KEY_HEX] = [(5000, False)] self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="aabb")) assert PUB_KEY_HEX not in self.win._msg_ctrl.pending_flood_attributions history = self.storage.get_path_history(PUB_KEY_HEX) assert len(history) == 1 assert history[0]["last_trip_ms"] == 5000 def test_attribution_not_consumed_when_flood(self): """pending_flood_attributions should remain when contact has path_length < 0.""" self.win._msg_ctrl.pending_flood_attributions[PUB_KEY_HEX] = [(5000, False)] self.fh.add_or_update_contact(_contact_dict(path_len=-1, path_hex="")) assert PUB_KEY_HEX in self.win._msg_ctrl.pending_flood_attributions assert self.storage.get_path_history(PUB_KEY_HEX) == [] def test_seed_from_contact_when_no_pending_attribution(self): """Without pending attributions, a contact with a path should be seeded.""" self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="ccdd")) history = self.storage.get_path_history(PUB_KEY_HEX) assert len(history) == 1 assert history[0]["path_bytes"] == "ccdd" assert history[0]["route_weight"] == 1.0 def test_late_attribution_uses_reduced_weight(self): """Late flood attribution should use GRACE_WEIGHT_INCREMENT (0.2), not the default 0.5, when updating an existing path record.""" self.storage.upsert_path_record( PUB_KEY_HEX, "eeff", 1, 2, success=True, trip_ms=5000, ) baseline = self.storage.get_path_history(PUB_KEY_HEX)[0]["route_weight"] self.win._msg_ctrl.pending_flood_attributions[PUB_KEY_HEX] = [(8000, True)] self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="eeff")) history = self.storage.get_path_history(PUB_KEY_HEX) assert len(history) == 1 assert history[0]["route_weight"] == baseline + 0.2 assert history[0]["last_trip_ms"] == 8000 meshy/tests/test_smaz.py000066400000000000000000000031071523412555500157400ustar00rootroot00000000000000import base64 from meshy.smaz import _decompress, try_decode_prefixed class TestDecompress: def test_codebook_lookup(self): result = _decompress(bytes([0])) assert result == b" " def test_codebook_the(self): result = _decompress(bytes([1])) assert result == b"the" def test_verbatim_single(self): result = _decompress(bytes([254, ord("X")])) assert result == b"X" def test_verbatim_run(self): result = _decompress(bytes([255, 2, ord("A"), ord("B"), ord("C")])) assert result == b"ABC" def test_mixed(self): result = _decompress(bytes([1, 0, 2])) assert result == b"the e" def test_empty(self): assert _decompress(b"") == b"" class TestTryDecodePrefixed: def test_no_prefix_returns_original(self): assert try_decode_prefixed("hello world") == "hello world" def test_plain_s_prefix_with_valid_smaz(self): compressed = bytes([1]) # "the" encoded = base64.b64encode(compressed).decode() result = try_decode_prefixed(f"s:{encoded}") assert result == "the" def test_non_base64_chars_stripped_by_lenient_decode(self): result = try_decode_prefixed("s:@@@") assert result == "" def test_url_safe_base64(self): compressed = bytes([254, ord("Z")]) encoded = base64.urlsafe_b64encode(compressed).decode().rstrip("=") result = try_decode_prefixed(f"s:{encoded}") assert result == "Z" def test_empty_after_prefix_returns_original(self): assert try_decode_prefixed("s:") == "s:" meshy/tests/test_storage.py000066400000000000000000000520541523412555500164370ustar00rootroot00000000000000from unittest.mock import patch from meshy.models import Channel, Contact, MessageStatus def _insert_msg(storage, contact_hex, text, ts, is_outgoing=False): storage._db.execute( """INSERT INTO messages (contact_key_hex, sender_key_hex, text, timestamp, is_outgoing, is_cli, status, message_id, room_sender_name) VALUES (?, ?, ?, ?, ?, 0, 0, NULL, '')""", (contact_hex, contact_hex, text, ts, int(is_outgoing)), ) storage._db.commit() CONTACT_HEX = "aa" * 32 OTHER_HEX = "bb" * 32 # ─── has_duplicate_message ───────────────────────────────────── class TestHasDuplicateMessage: def test_empty_db_returns_false(self, storage): assert storage.has_duplicate_message(CONTACT_HEX, 1000.0, "hello") is False def test_layer1_exact_timestamp_match(self, storage): _insert_msg(storage, CONTACT_HEX, "hello", 1000.0) assert storage.has_duplicate_message(CONTACT_HEX, 1000.0, "hello") is True def test_layer1_different_text_not_duplicate(self, storage): _insert_msg(storage, CONTACT_HEX, "hello", 1000.0) assert storage.has_duplicate_message(CONTACT_HEX, 1000.0, "world") is False @patch("meshy.storage.time") def test_layer2_manual_resend_within_30min(self, mock_time, storage): ts = 1000.0 mock_time.time.return_value = ts + 600 # 10 min later _insert_msg(storage, CONTACT_HEX, "hello", ts) assert storage.has_duplicate_message(CONTACT_HEX, ts + 60, "hello") is True @patch("meshy.storage.time") def test_layer2_not_duplicate_after_30min(self, mock_time, storage): ts = 1000.0 mock_time.time.return_value = ts + 2000 # > 30 min _insert_msg(storage, CONTACT_HEX, "hello", ts) assert storage.has_duplicate_message(CONTACT_HEX, ts + 60, "hello") is False @patch("meshy.storage.time") def test_layer2_not_duplicate_after_our_reply(self, mock_time, storage): ts = 1000.0 mock_time.time.return_value = ts + 300 _insert_msg(storage, CONTACT_HEX, "hello", ts) _insert_msg(storage, CONTACT_HEX, "reply", ts + 100, is_outgoing=True) assert storage.has_duplicate_message(CONTACT_HEX, ts + 200, "hello") is False @patch("meshy.storage.time") def test_layer2_different_text_not_duplicate(self, mock_time, storage): ts = 1000.0 mock_time.time.return_value = ts + 300 _insert_msg(storage, CONTACT_HEX, "hello", ts) assert storage.has_duplicate_message(CONTACT_HEX, ts + 60, "world") is False # ─── message_exists ──────────────────────────────────────────── class TestMessageExists: def test_within_5s_window(self, storage): _insert_msg(storage, CONTACT_HEX, "test", 1000.0) assert storage.message_exists(CONTACT_HEX, 1003.0, "test") is True def test_outside_5s_window(self, storage): _insert_msg(storage, CONTACT_HEX, "test", 1000.0) assert storage.message_exists(CONTACT_HEX, 1010.0, "test") is False def test_different_text(self, storage): _insert_msg(storage, CONTACT_HEX, "test", 1000.0) assert storage.message_exists(CONTACT_HEX, 1000.0, "other") is False # ─── count_unread_messages ───────────────────────────────────── class TestCountUnread: def test_all_unread_without_last_read(self, storage): storage.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) _insert_msg(storage, CONTACT_HEX, "m1", 100.0) _insert_msg(storage, CONTACT_HEX, "m2", 200.0) _insert_msg(storage, CONTACT_HEX, "m3", 300.0, is_outgoing=True) assert storage.count_unread_messages(CONTACT_HEX) == 2 def test_partial_unread_with_last_read(self, storage): storage.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) _insert_msg(storage, CONTACT_HEX, "m1", 100.0) _insert_msg(storage, CONTACT_HEX, "m2", 200.0) _insert_msg(storage, CONTACT_HEX, "m3", 300.0) storage.set_contact_last_read_at(CONTACT_HEX, 150.0) assert storage.count_unread_messages(CONTACT_HEX) == 2 def test_channel_unread(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) VALUES (0, 'Bob', 'hi', 100.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) VALUES (0, 'Bob', 'there', 200.0, 0)", ) storage._db.commit() assert storage.count_unread_channel_messages(0) == 2 storage.set_channel_last_read_at(0, 150.0) assert storage.count_unread_channel_messages(0) == 1 # ─── upsert_path_record / get_path_history ───────────────────── class TestPathHistory: def test_insert_new_record(self, storage): storage.upsert_path_record(CONTACT_HEX, "aabb", 1, 2, success=True, trip_ms=500) history = storage.get_path_history(CONTACT_HEX) assert len(history) == 1 rec = history[0] assert rec["path_bytes"] == "aabb" assert rec["success_count"] == 1 assert rec["failure_count"] == 0 assert rec["last_trip_ms"] == 500 def test_update_increments_counts(self, storage): storage.upsert_path_record(CONTACT_HEX, "aabb", 1, 2, success=True, trip_ms=500) storage.upsert_path_record(CONTACT_HEX, "aabb", 1, 2, success=True, trip_ms=300) storage.upsert_path_record(CONTACT_HEX, "aabb", 1, 2, success=False) history = storage.get_path_history(CONTACT_HEX) assert len(history) == 1 assert history[0]["success_count"] == 2 assert history[0]["failure_count"] == 1 def test_history_limit_100(self, storage): for i in range(110): storage.upsert_path_record(CONTACT_HEX, f"path{i:03d}", 1, 1, success=True) history = storage.get_path_history(CONTACT_HEX) assert len(history) == 100 # ─── Migration / schema ─────────────────────────────────────── class TestMigration: def test_tables_created(self, storage): tables = { row[0] for row in storage._db.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() } for expected in ( "contacts", "messages", "channels", "channel_messages", "path_history", "room_passwords", "settings", "regions", "blocked_channel_senders", ): assert expected in tables, f"Missing table: {expected}" def test_contacts_has_migrated_columns(self, storage): cols = {row[1] for row in storage._db.execute("PRAGMA table_info(contacts)").fetchall()} for col in ( "last_read_at", "path_hash_size", "device_lastmod", "force_flood", "auto_reset_path", "smart_route_selection", ): assert col in cols, f"Missing column: {col}" # ─── Blocked Channel Senders ───────────────────────────────── class TestBlockedChannelSenders: def test_empty_by_default(self, storage): assert storage.get_blocked_channel_senders() == set() def test_block_sender(self, storage): storage.block_channel_sender("Spammer") assert storage.is_channel_sender_blocked("Spammer") is True assert "Spammer" in storage.get_blocked_channel_senders() def test_unblock_sender(self, storage): storage.block_channel_sender("Spammer") storage.unblock_channel_sender("Spammer") assert storage.is_channel_sender_blocked("Spammer") is False assert storage.get_blocked_channel_senders() == set() def test_block_duplicate_is_noop(self, storage): storage.block_channel_sender("Spammer") storage.block_channel_sender("Spammer") assert storage.get_blocked_channel_senders() == {"Spammer"} def test_unblock_nonexistent_is_noop(self, storage): storage.unblock_channel_sender("Nobody") assert storage.get_blocked_channel_senders() == set() def test_multiple_blocked(self, storage): storage.block_channel_sender("Alice") storage.block_channel_sender("Bob") blocked = storage.get_blocked_channel_senders() assert blocked == {"Alice", "Bob"} def test_get_channel_senders(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'hi', 100.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'hello', 200.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Bob', 'hey', 150.0, 0)", ) storage._db.commit() senders = storage.get_channel_senders(0) assert len(senders) == 2 assert senders[0]["sender_name"] == "Alice" assert senders[0]["message_count"] == 2 assert senders[0]["last_seen"] == 200.0 assert senders[1]["sender_name"] == "Bob" assert senders[1]["message_count"] == 1 def test_get_channel_senders_excludes_outgoing(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Me', 'hi', 100.0, 1)", ) storage._db.commit() senders = storage.get_channel_senders(0) assert len(senders) == 0 def test_get_channel_participants_includes_blocked_without_messages(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'hi', 100.0, 0)", ) storage._db.commit() storage.block_channel_sender("Alice") storage.block_channel_sender("Ghost") participants = storage.get_channel_participants(0) assert len(participants) == 2 alice = next(p for p in participants if p["sender_name"] == "Alice") assert alice["message_count"] == 1 assert alice["is_blocked"] is True ghost = next(p for p in participants if p["sender_name"] == "Ghost") assert ghost["message_count"] == 0 assert ghost["last_seen"] is None assert ghost["is_blocked"] is True def test_get_channel_participants_unblocked_not_flagged(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Bob', 'hey', 100.0, 0)", ) storage._db.commit() participants = storage.get_channel_participants(0) assert len(participants) == 1 assert participants[0]["is_blocked"] is False def test_delete_messages_by_sender(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Spammer', 'spam1', 100.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Spammer', 'spam2', 200.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'hello', 150.0, 0)", ) storage._db.commit() storage.delete_channel_messages_by_sender(0, "Spammer") msgs = storage.get_channel_messages(0) assert len(msgs) == 1 assert msgs[0].sender_name == "Alice" # ─── Channel Retention ────────────────────────────────────── class TestChannelRetention: def test_default_retention_days(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) ch = storage.get_channel(0) assert ch.retention_days == 0 def test_set_channel_retention_days(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage.set_channel_retention_days(0, 30) ch = storage.get_channel(0) assert ch.retention_days == 30 def test_cleanup_prunes_old_messages(self, storage): import time as _time now = _time.time() storage.save_channel(Channel(index=0, name="Test", psk=bytes(16), retention_days=7)) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'old', ?, 0)", (now - 8 * 86400,), ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Bob', 'recent', ?, 0)", (now - 3 * 86400,), ) storage._db.commit() storage._cleanup_old_data() msgs = storage.get_channel_messages(0) assert len(msgs) == 1 assert msgs[0].sender_name == "Bob" def test_cleanup_skips_forever_channels(self, storage): import time as _time now = _time.time() storage.save_channel(Channel(index=0, name="Permanent", psk=bytes(16), retention_days=0)) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'ancient', ?, 0)", (now - 365 * 86400,), ) storage._db.commit() storage._cleanup_old_data() msgs = storage.get_channel_messages(0) assert len(msgs) == 1 def test_clear_channel_messages(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage.save_channel(Channel(index=1, name="Other", psk=bytes(16))) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Alice', 'hi', 100.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (0, 'Bob', 'hey', 200.0, 0)", ) storage._db.execute( "INSERT INTO channel_messages (channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (1, 'Carol', 'hello', 150.0, 0)", ) storage._db.commit() storage.clear_channel_messages(0) assert len(storage.get_channel_messages(0)) == 0 assert len(storage.get_channel_messages(1)) == 1 # ─── find_contact_by_prefix ────────────────────────────────── class TestFindContactByPrefix: def test_finds_matching_contact(self, storage): key = bytes.fromhex(CONTACT_HEX) storage.save_contact(Contact(public_key=key, name="Alice")) found = storage.find_contact_by_prefix(key[:6]) assert found is not None assert found.public_key_hex == CONTACT_HEX def test_no_match_returns_none(self, storage): storage.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) found = storage.find_contact_by_prefix(bytes.fromhex("bb" * 6)) assert found is None def test_prefix_ending_with_f(self, storage): key_hex = "ff" * 32 storage.save_contact(Contact(public_key=bytes.fromhex(key_hex), name="Max")) found = storage.find_contact_by_prefix(bytes.fromhex(key_hex[:12])) assert found is not None assert found.name == "Max" def test_multiple_contacts_returns_one(self, storage): storage.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) storage.save_contact(Contact(public_key=bytes.fromhex(OTHER_HEX), name="Bob")) found = storage.find_contact_by_prefix(bytes.fromhex(CONTACT_HEX[:12])) assert found is not None assert found.public_key_hex == CONTACT_HEX # ─── fail_orphaned_messages ────────────────────────────────── def _insert_msg_with_status(storage, contact_hex, text, ts, status, is_outgoing=True): storage._db.execute( """INSERT INTO messages (contact_key_hex, sender_key_hex, text, timestamp, is_outgoing, is_cli, status, message_id, room_sender_name) VALUES (?, ?, ?, ?, ?, 0, ?, NULL, '')""", (contact_hex, contact_hex, text, ts, int(is_outgoing), int(status)), ) storage._db.commit() class TestFailOrphanedMessages: def test_pending_marked_as_failed(self, storage): _insert_msg_with_status(storage, CONTACT_HEX, "msg1", 100.0, MessageStatus.PENDING) storage.fail_orphaned_messages() row = storage._db.execute("SELECT status FROM messages").fetchone() assert row[0] == int(MessageStatus.FAILED) def test_sent_marked_as_failed(self, storage): _insert_msg_with_status(storage, CONTACT_HEX, "msg1", 100.0, MessageStatus.SENT) storage.fail_orphaned_messages() row = storage._db.execute("SELECT status FROM messages").fetchone() assert row[0] == int(MessageStatus.FAILED) def test_delivered_not_affected(self, storage): _insert_msg_with_status(storage, CONTACT_HEX, "msg1", 100.0, MessageStatus.DELIVERED) storage.fail_orphaned_messages() row = storage._db.execute("SELECT status FROM messages").fetchone() assert row[0] == int(MessageStatus.DELIVERED) def test_incoming_not_affected(self, storage): _insert_msg_with_status( storage, CONTACT_HEX, "msg1", 100.0, MessageStatus.PENDING, is_outgoing=False, ) storage.fail_orphaned_messages() row = storage._db.execute("SELECT status FROM messages").fetchone() assert row[0] == int(MessageStatus.PENDING) def test_mixed_messages(self, storage): _insert_msg_with_status(storage, CONTACT_HEX, "a", 100.0, MessageStatus.PENDING) _insert_msg_with_status(storage, CONTACT_HEX, "b", 200.0, MessageStatus.SENT) _insert_msg_with_status(storage, CONTACT_HEX, "c", 300.0, MessageStatus.DELIVERED) storage.fail_orphaned_messages() rows = storage._db.execute( "SELECT text, status FROM messages ORDER BY timestamp" ).fetchall() assert rows[0][1] == int(MessageStatus.FAILED) assert rows[1][1] == int(MessageStatus.FAILED) assert rows[2][1] == int(MessageStatus.DELIVERED) # ─── Storage.batch() ───────────────────────────────────────── class TestBatch: def test_defers_commit(self, storage): storage.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) with storage.batch(): storage._db.execute( "UPDATE contacts SET name = ? WHERE public_key_hex = ?", ("Updated", CONTACT_HEX), ) storage._commit() assert storage._batch_depth == 1 assert storage._batch_depth == 0 def test_nested_batch(self, storage): with storage.batch(): assert storage._batch_depth == 1 with storage.batch(): assert storage._batch_depth == 2 assert storage._batch_depth == 1 assert storage._batch_depth == 0 def test_depth_restores_after_exception(self, storage): try: with storage.batch(): assert storage._batch_depth == 1 raise ValueError("test error") except ValueError: pass assert storage._batch_depth == 0 def test_commit_inside_batch_is_noop(self, storage): storage.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) with storage.batch(): storage._db.execute( "UPDATE contacts SET name = ? WHERE public_key_hex = ?", ("BatchUpdated", CONTACT_HEX), ) storage._commit() assert storage._batch_depth == 1 meshy/tests/test_utils.py000066400000000000000000000053441523412555500161330ustar00rootroot00000000000000import math from meshy.utils import ( extract_emoji, haversine_distance, parse_shared_contact, parse_shared_location, ) # ─── parse_shared_contact ───────────────────────────────────── class TestParseSharedContact: def test_valid(self): key = "aa" * 32 result = parse_shared_contact(f"<{key}:1:Alice>") assert result == (key, 1, "Alice") def test_with_spaces_in_name(self): key = "bb" * 32 result = parse_shared_contact(f"<{key}:2:Bob Smith>") assert result == (key, 2, "Bob Smith") def test_invalid_format(self): assert parse_shared_contact("not a contact") is None def test_short_key(self): assert parse_shared_contact("") is None # ─── parse_shared_location ──────────────────────────────────── class TestParseSharedLocation: def test_valid(self): result = parse_shared_location("50.123456, 14.456789") assert result is not None assert abs(result[0] - 50.123456) < 1e-6 assert abs(result[1] - 14.456789) < 1e-6 def test_negative_coords(self): result = parse_shared_location("-33.868820, 151.209290") assert result is not None def test_out_of_range_lat(self): assert parse_shared_location("100.0, 14.0") is None def test_out_of_range_lon(self): assert parse_shared_location("50.0, 200.0") is None def test_invalid_format(self): assert parse_shared_location("not coords") is None # ─── haversine_distance ─────────────────────────────────────── class TestHaversineDistance: def test_same_point(self): assert haversine_distance(50.0, 14.0, 50.0, 14.0) == 0.0 def test_prague_brno(self): d = haversine_distance(50.0755, 14.4378, 49.1951, 16.6068) assert 180 < d < 200 # ~186 km def test_antipodes(self): d = haversine_distance(0.0, 0.0, 0.0, 180.0) assert abs(d - math.pi * 6371.0) < 1.0 # ─── extract_emoji ──────────────────────────────────────────── class TestExtractEmoji: def test_no_emoji(self): assert extract_emoji("Hello") is None def test_empty(self): assert extract_emoji("") is None def test_smiley(self): assert extract_emoji("Node 😀 test") == "😀" def test_weather_symbol(self): assert extract_emoji("☀ Sunny") == "☀" def test_rocket(self): assert extract_emoji("🚀 Launch") == "🚀" meshy/uv.lock000066400000000000000000001134741523412555500135300ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.11" [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "distlib" version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/86/b2/d6fc3f2347f43dada79e5ff118493e8109c98400a0e29a1d5264a3aa479b/distlib-0.4.1.tar.gz", hash = "sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b", size = 610526, upload-time = "2026-06-02T11:17:40.691Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl", hash = "sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97", size = 469216, upload-time = "2026-06-02T11:17:38.779Z" }, ] [[package]] name = "filelock" version = "3.29.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, ] [[package]] name = "identify" version = "2.6.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "meshy" version = "0.0.0" source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "pre-commit" }, { name = "pycryptodome" }, { name = "pyserial" }, { name = "pyzbar" }, { name = "ruff" }, { name = "segno" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pyserial", specifier = ">=3.5" }, { name = "pyzbar", specifier = ">=0.1.9" }, { name = "ruff", specifier = ">=0.15.15" }, { name = "segno", specifier = ">=1.6.6" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "platformdirs" version = "4.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "pre-commit" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "pycryptodome" version = "3.23.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] [[package]] name = "pyserial" version = "3.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, ] [[package]] name = "python-discovery" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "pyzbar" version = "0.1.9" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/24/81ebe6a1c00760471a3028a23cbe0b94e5fa2926e5ba47adc895920887bc/pyzbar-0.1.9-py2.py3-none-any.whl", hash = "sha256:4559628b8192feb25766d954b36a3753baaf5c97c03135aec7e4a026036b475d", size = 32560, upload-time = "2022-03-15T14:53:40.637Z" }, { url = "https://files.pythonhosted.org/packages/8e/87/7b596730179ddf17857eea33ba820354dd4e1cf941e57f51ffccce26c409/pyzbar-0.1.9-py2.py3-none-win32.whl", hash = "sha256:8f4c5264c9c7c6b9f20d01efc52a4eba1ded47d9ba857a94130afe33703eb518", size = 810633, upload-time = "2022-03-15T14:53:43.446Z" }, { url = "https://files.pythonhosted.org/packages/0a/e2/1c6a8e94197612dbdfc51eab8dfb674168829885fac2c4f50ac8366c25ca/pyzbar-0.1.9-py2.py3-none-win_amd64.whl", hash = "sha256:13e3ee5a2f3a545204a285f41814d5c0db571967e8d4af8699a03afc55182a9c", size = 817363, upload-time = "2022-03-15T14:53:46.691Z" }, ] [[package]] name = "ruff" version = "0.15.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] name = "segno" version = "1.6.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" }, ] [[package]] name = "virtualenv" version = "21.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, ]