pax_global_header00006660000000000000000000000064152621264170014520gustar00rootroot0000000000000052 comment=aff34b7ece2a9285542c5cacd93063d0fc935648 meshy/000077500000000000000000000000001526212641700122115ustar00rootroot00000000000000meshy/.gitattributes000066400000000000000000000004121526212641700151010ustar00rootroot00000000000000web/ 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/.gitignore000066400000000000000000000003511526212641700142000ustar00rootroot00000000000000result .flatpak-builder repo *.flatpak *.mo *.po~ builddir-appimage dist/ dist-homebrew/ .cache/appimage/ __pycache__/ .DS_Store builddir-macos/ data/page.codeberg.sesivany.Meshy.gresource data/gschemas.compiled data/meshy.gresource meshy/.python-version000066400000000000000000000000051526212641700152110ustar00rootroot000000000000003.14 meshy/LICENSE000066400000000000000000001034301526212641700132170ustar00rootroot00000000000000GNU 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.md000066400000000000000000000237601526212641700135000ustar00rootroot00000000000000# 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`) * python3-msgpack (pip: `msgpack`) 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 python3-msgpack desktop-file-utils appstream gettext pip install pycryptodome pyzbar segno msgpack ``` 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 \ python3-msgpack 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 msgpack 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 The project has an automated `pytest` test suite under `tests/` covering the pure-Python core modules (protocol, crypto, storage, models, backup, message handling, and more). The tests do **not** require GTK/PyGObject to be installed: `tests/conftest.py` installs a minimal `gi` stub (`tests/gi_stub.py`) so the pure-Python modules can be imported and tested standalone. It also registers `src/` as the `meshy` package and provides an in-memory `storage` fixture. Run the full suite: ```bash pytest ``` Or with `uv`: ```bash uv run pytest ``` Beyond the automated tests, GUI behaviour is still validated by manual testing. ### 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/000077500000000000000000000000001526212641700141035ustar00rootroot00000000000000meshy/build-aux/appimage/000077500000000000000000000000001526212641700156665ustar00rootroot00000000000000meshy/build-aux/appimage/AppRun000077500000000000000000000027321526212641700170250ustar00rootroot00000000000000#!/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.sh000077500000000000000000000246321526212641700211140ustar00rootroot00000000000000#!/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/000077500000000000000000000000001526212641700155255ustar00rootroot00000000000000meshy/build-aux/flatpak/page.codeberg.sesivany.Meshy-flathub.json000066400000000000000000000147221526212641700254620ustar00rootroot00000000000000{ "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", "--system-talk-name=org.freedesktop.login1", "--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/34/e0/0d0bd5b1089a4bf5ef48164459289ddf02a9110ca1db854edaad25127e64/pycryptodome-3.24.0.tar.gz", "sha256": "9140779b40405476a799305b9ac1bcaab4ee6791dc3d38b12a9aa84ffbd6aabf" } ] }, { "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": "python-msgpack", "buildsystem": "simple", "build-commands": [ "pip3 install --no-index --no-build-isolation --prefix=/app ." ], "sources": [ { "type": "archive", "url": "https://files.pythonhosted.org/packages/0a/e7/bb605a7bab2d8425a64b3fa762b39dc1bf1c7e3f11ba6fb5413d6db0ff8c/msgpack-1.2.3.tar.gz", "sha256": "32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186" } ] }, { "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.7/libshumate-1.7.0.tar.xz", "sha256": "3896cc4b6a7bf83141c27df23ce48cdd68467603bc6b764960307cdcbbef80e5" } ] }, { "name": "meshy", "buildsystem": "meson", "sources": [ { "type": "git", "url": "https://codeberg.org/sesivany/meshy.git", "tag": "26.09", "commit": "a3cc116c284c5034cd4b59f9b3acc6fe9791892c" } ] } ] } meshy/build-aux/macos/000077500000000000000000000000001526212641700152055ustar00rootroot00000000000000meshy/build-aux/macos/README.md000066400000000000000000000217711526212641700164740ustar00rootroot00000000000000# 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 msgpack # 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 msgpack 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.md000066400000000000000000000130101526212641700174530ustar00rootroot00000000000000# 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.sh000077500000000000000000000076071526212641700220140ustar00rootroot00000000000000#!/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.sh000077500000000000000000000035171526212641700212420ustar00rootroot00000000000000#!/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.sh000077500000000000000000000140471526212641700206760ustar00rootroot00000000000000#!/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 # Generate macOS app icon (.icns) from SVG info "Generating app icon" APP_SVG="data/icons/scalable/apps/page.codeberg.sesivany.Meshy.svg" if [[ ! -f "$APP_SVG" ]]; then error "SVG icon not found: $APP_SVG" fi command -v rsvg-convert >/dev/null 2>&1 || error "rsvg-convert not found. Install with: brew install librsvg" ICONSET_DIR=$(mktemp -d)/Meshy.iconset mkdir -p "$ICONSET_DIR" for size in 16 32 128 256 512; do rsvg-convert -w $size -h $size "$APP_SVG" -o "$ICONSET_DIR/icon_${size}x${size}.png" rsvg-convert -w $((size*2)) -h $((size*2)) "$APP_SVG" -o "$ICONSET_DIR/icon_${size}x${size}@2x.png" done iconutil -c icns "$ICONSET_DIR" -o "$APPDIR/Contents/Resources/Meshy.icns" rm -rf "$(dirname "$ICONSET_DIR")" info "Generated Meshy.icns from SVG" # 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.sh000077500000000000000000000426451526212641700217330ustar00rootroot00000000000000#!/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; } # Submit an artifact to the Apple notary service and wait for the verdict. # Usage: notarize_submit notarize_submit() { local target="$1" what="$2" submit_output info "Submitting $what to Apple notary service (this may take a few minutes)..." submit_output=$(xcrun notarytool submit "$target" \ --keychain-profile "$KEYCHAIN_PROFILE" --wait 2>&1) echo "$submit_output" | grep -q "status: Accepted" \ || error "Notarization of $what failed. Output:\n$submit_output" info "✓ Notarization accepted for $what" } [[ "$(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-15.0-arm64" arch -arm64 /opt/homebrew/bin/python3.14 -m pip install \ --target="$RESOURCES_DIR/lib/python-packages" \ pycryptodome \ pyserial \ segno \ msgpack \ pyobjc-framework-Cocoa \ pyobjc-framework-CoreBluetooth \ pyobjc-framework-CoreLocation \ pyobjc-framework-AVFoundation \ pyobjc-framework-Vision \ pyobjc-framework-UserNotifications \ pyobjc-framework-libdispatch # 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" # Compile ObjC launcher — spawns Python and proxies UNUserNotificationCenter calls # -mmacosx-version-min pins the deployment target so the binary runs on macOS 15+ # regardless of the builder's OS. Without it, cc stamps LC_BUILD_VERSION minos to # the build machine's version, silently restricting the app to that OS or newer. cc -O2 -arch arm64 -fobjc-arc -mmacosx-version-min=15.0 \ -framework Foundation \ -framework UserNotifications \ -o "$MACOS_DIR/meshy" \ build-aux/macos/launcher.m # Create a Python stub bundle so macOS shows "Meshy" in the Dock instead # of "Python". macOS reads the Dock label from the Info.plist of the # bundle that surrounds the running executable. STUB_DIR="$RESOURCES_DIR/Meshy.app/Contents" mkdir -p "$STUB_DIR/MacOS" # Compile a minimal C stub that calls Py_BytesMain(). A custom binary # prevents Python.framework from overriding the bundle identity. STUB_SRC=$(mktemp "${TMPDIR:-/tmp}/meshy_pystub.XXXXXX.c") cat > "$STUB_SRC" << 'CSTUB' #include int main(int argc, char *argv[]) { return Py_BytesMain(argc, argv); } CSTUB cc -O2 -arch arm64 -mmacosx-version-min=15.0 \ $(/opt/homebrew/bin/python3.14-config --includes) \ $(/opt/homebrew/bin/python3.14-config --ldflags --embed) \ -o "$STUB_DIR/MacOS/Meshy" "$STUB_SRC" rm "$STUB_SRC" cat > "$STUB_DIR/Info.plist" << 'STUBPLIST' CFBundleExecutable Meshy CFBundleIdentifier page.codeberg.sesivany.Meshy.python CFBundleName Meshy LSMinimumSystemVersion 15.0 LSUIElement 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. STUBPLIST # Sign the stub bundle so macOS binds the Info.plist (privacy descriptions) # to the binary — without this, BLE/camera/location prompts never appear. codesign -s - --force "$RESOURCES_DIR/Meshy.app" 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 import gi gi.require_version("Gtk", "4.0") from gi.repository import GLib GLib.set_prgname('meshy') GLib.set_application_name('Meshy') def _fix_macos_menu_name(): try: from AppKit import NSApplication app = NSApplication.sharedApplication() menu = app.mainMenu() if menu and menu.numberOfItems() > 0: menu.itemAtIndex_(0).setTitle_('Meshy') except Exception: pass return False GLib.idle_add(_fix_macos_menu_name) 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('__MESHY_VERSION__')) PYTHON_EOF sed -i '' "s/__MESHY_VERSION__/$VERSION/" "$RESOURCES_DIR/lib/python/meshy/__main__.py" STEP_NUM=5 if [[ "$NOTARIZE" == "true" ]]; then TOTAL_STEPS=7 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 com.apple.security.cs.allow-dyld-environment-variables EOF # Sign all .so files and dylibs in Python packages (required for notarization). # --timestamp contacts Apple's timestamp server on every invocation, and the # bundle has ~200 extensions, so an occasional transient failure is expected. # Retry before giving up, and never swallow the error: an extension left # ad-hoc signed is only caught ~10 minutes later by the notary service, which # rejects the whole submission. Note the loop reads from a process # substitution rather than a pipe — in a pipe it runs in a subshell, where # error()'s exit would only kill the subshell and the build would continue. info "Signing Python native extensions..." sign_native_lib() { local target="$1" attempt output for attempt in 1 2 3; do if output=$(codesign --force --sign "$SIGN_IDENTITY" --timestamp \ --options runtime --entitlements "$ENTITLEMENTS_FILE" \ "$target" 2>&1); then return 0 fi warn "codesign failed for ${target#"$RESOURCES_DIR"/} (attempt $attempt/3): $output" sleep $((attempt * 5)) done return 1 } while IFS= read -r lib; do sign_native_lib "$lib" || error "Failed to sign $lib after 3 attempts" done < <(find "$RESOURCES_DIR/lib/python-packages" -type f \ \( -name "*.so" -o -name "*.dylib" \)) # Belt and braces: prove nothing is left ad-hoc or linker-signed before we # spend ten minutes waiting on the notary service to tell us the same thing. info "Verifying every native extension carries a Developer ID signature..." # Team ID from the identity string, e.g. "Developer ID Application: Name (TEAMID)" TEAM_ID=$(sed -n 's/.*(\([A-Z0-9]*\))$/\1/p' <<< "$SIGN_IDENTITY") [[ -n "$TEAM_ID" ]] || error "Cannot extract team ID from identity: $SIGN_IDENTITY" unsigned="" while IFS= read -r lib; do codesign --display --verbose=2 "$lib" 2>&1 \ | grep -q "^TeamIdentifier=${TEAM_ID}$" || unsigned+=" $lib"$'\n' done < <(find "$RESOURCES_DIR/lib/python-packages" -type f \ \( -name "*.so" -o -name "*.dylib" \)) [[ -z "$unsigned" ]] || error "Native extensions missing a Developer ID signature:\n$unsigned" # Sign the inner stub bundle (Python process — needs plist binding for privacy prompts) codesign --force --sign "$SIGN_IDENTITY" --timestamp --options runtime \ --entitlements "$ENTITLEMENTS_FILE" "$RESOURCES_DIR/Meshy.app" || error "Failed to sign stub bundle" # 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" # Notarize and staple the .app itself, before it is placed in the DMG. # Stapling only the DMG is not enough: `brew install --cask` copies # Meshy.app out of the image, and that copy carries no ticket. Gatekeeper # then has to verify it online on first launch, which fails for users who # are offline or behind a filtering proxy. A stapled .app validates with # no network at all. if [[ "$NOTARIZE" == "true" ]]; then info "Step 6/$TOTAL_STEPS: Notarizing app bundle" APP_ZIP="$DIST_DIR/${APP_NAME}-notarize.zip" # notarytool rejects a bare .app; ditto zips it preserving signatures. ditto -c -k --keepParent "$APP_DIR" "$APP_ZIP" \ || error "Failed to create notarization archive" notarize_submit "$APP_ZIP" "${APP_NAME}.app" rm -f "$APP_ZIP" # The ticket is stapled to the .app, not to the zip we submitted. xcrun stapler staple "$APP_DIR" \ || error "Failed to staple ticket to ${APP_NAME}.app" info "✓ Stapled notarization ticket to ${APP_NAME}.app" fi else # Ad-hoc sign so macOS binds Info.plist (privacy descriptions) to the # launcher — TCC checks the "responsible process" (parent) for permissions. info "Ad-hoc signing (use --sign for distribution signing)" codesign -s - --force "$APP_DIR" 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 7/$TOTAL_STEPS: Notarizing DMG" notarize_submit "$DMG_PATH" "$DMG_NAME" xcrun stapler staple "$DMG_PATH" \ || error "Failed to staple ticket to $DMG_NAME" info "" info "✅ DMG notarized successfully!" info "" 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.sh000077500000000000000000000521301526212641700224050ustar00rootroot00000000000000#!/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 \ msgpack \ 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.m000066400000000000000000000304171526212641700171710ustar00rootroot00000000000000// Meshy launcher — native executable that spawns Python and proxies notifications. // Must be the CFBundleExecutable so macOS grants UNUserNotificationCenter access. // Notification IPC: Python writes JSON commands to MESHY_NOTIFY_WR_FD, launcher // reads them and calls UNUserNotificationCenter, responses go to MESHY_NOTIFY_RD_FD. #import #import #include #include #include #include #include #include #include #include #include extern char **environ; static int notifyReadFd = -1; static int notifyWriteFd = -1; static void writeJSON(NSDictionary *dict); @interface NotificationDelegate : NSObject @end @implementation NotificationDelegate - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))handler { handler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound); } - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))handler { NSString *thread = response.notification.request.content.threadIdentifier; NSString *identifier = response.notification.request.identifier; if ([response isKindOfClass:[UNTextInputNotificationResponse class]]) { NSString *text = ((UNTextInputNotificationResponse *)response).userText; NSMutableDictionary *event = [@{ @"event": @"reply", @"id": identifier, @"text": text ?: @"" } mutableCopy]; if (thread.length > 0) event[@"thread"] = thread; writeJSON(event); } else { NSMutableDictionary *event = [@{ @"event": @"activate", @"id": identifier } mutableCopy]; if (thread.length > 0) event[@"thread"] = thread; writeJSON(event); } handler(); } @end static void writeJSON(NSDictionary *dict) { if (notifyWriteFd < 0) return; NSData *json = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; if (!json) return; NSMutableData *line = [json mutableCopy]; [line appendBytes:"\n" length:1]; write(notifyWriteFd, line.bytes, line.length); } static void handleNotifyCommand(NSDictionary *cmd, UNUserNotificationCenter *center) { NSString *action = cmd[@"action"]; if (!action) { writeJSON(@{@"ok": @NO, @"error": @"missing action"}); return; } if ([action isEqualToString:@"auth"]) { UNAuthorizationOptions opts = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge; [center requestAuthorizationWithOptions:opts completionHandler:^(BOOL granted, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ if (error) { writeJSON(@{@"ok": @NO, @"error": error.localizedDescription}); } else { writeJSON(@{@"ok": @YES, @"granted": @(granted)}); } }); }]; } else if ([action isEqualToString:@"send"]) { NSString *title = cmd[@"title"]; NSString *body = cmd[@"body"]; NSString *identifier = cmd[@"id"]; if (!title || !body || !identifier) { writeJSON(@{@"ok": @NO, @"error": @"missing title, body, or id"}); return; } UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; content.title = title; content.body = body; content.sound = [UNNotificationSound defaultSound]; NSString *thread = cmd[@"thread"]; if (thread) content.threadIdentifier = thread; NSString *category = cmd[@"category"]; if (category) content.categoryIdentifier = category; UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil]; [center addNotificationRequest:request withCompletionHandler:^(NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ if (error) { writeJSON(@{@"ok": @NO, @"error": error.localizedDescription}); } else { writeJSON(@{@"ok": @YES}); } }); }]; } else { writeJSON(@{@"ok": @NO, @"error": @"unknown action"}); } } int main(int argc, char *argv[]) { @autoreleasepool { signal(SIGPIPE, SIG_IGN); 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; } char exec_path_copy[1024]; strlcpy(exec_path_copy, exec_path, sizeof(exec_path_copy)); char *macos_dir = dirname(exec_path_copy); 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), "%s/Meshy.app/Contents/MacOS/Meshy", resources_dir); snprintf(script_path, sizeof(script_path), "%s/lib/python/meshy/__main__.py", resources_dir); char pythonhome[1024], pythonpath[2048]; snprintf(pythonhome, sizeof(pythonhome), "/opt/homebrew/opt/python@3.14/Frameworks/Python.framework/Versions/3.14"); // Homebrew's site-packages supplies pygobject3/py3cairo (both are cask // dependencies). We deliberately do not bundle our own copies: those // have no cp314 wheels, so pip would build them from source and stamp // them with the build machine's deployment target, pinning the whole // app to that OS. Using Homebrew's means each user gets extensions // built for their own macOS. Appended last so bundled packages win. snprintf(pythonpath, sizeof(pythonpath), "%s/lib/python-packages:%s/lib/python:" "/opt/homebrew/lib/python3.14/site-packages", resources_dir, resources_dir); setenv("PYTHONHOME", pythonhome, 1); setenv("PYTHONPATH", pythonpath, 1); setenv("PYTHONNOUSERSITE", "1", 1); char dyld_fallback[1024], gi_typelib[1024], xdg_data[2048]; // Keep dyld's built-in defaults (/usr/local/lib:/usr/lib) after ours; // setting this variable replaces them entirely. snprintf(dyld_fallback, sizeof(dyld_fallback), "/opt/homebrew/lib:/usr/local/lib:/usr/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); // Use the *fallback* search path, not DYLD_LIBRARY_PATH. The latter // takes priority over every other lookup in the process, including // Apple's own frameworks, so ImageIO and friends can end up bound to // Homebrew's libpng/libjpeg/libtiff instead of the copies they expect // and crash. The fallback path is consulted only after normal // resolution fails, which is exactly what GObject-Introspection needs // when it dlopens libraries by bare SONAME. setenv("DYLD_FALLBACK_LIBRARY_PATH", dyld_fallback, 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); // Notification IPC pipes int cmd_pipe[2], resp_pipe[2]; if (pipe(cmd_pipe) != 0 || pipe(resp_pipe) != 0) { perror("Failed to create notification pipes"); return 1; } notifyReadFd = cmd_pipe[0]; notifyWriteFd = resp_pipe[1]; char cmd_fd_str[16], resp_fd_str[16]; snprintf(cmd_fd_str, sizeof(cmd_fd_str), "%d", cmd_pipe[1]); snprintf(resp_fd_str, sizeof(resp_fd_str), "%d", resp_pipe[0]); setenv("MESHY_NOTIFY_WR_FD", cmd_fd_str, 1); setenv("MESHY_NOTIFY_RD_FD", resp_fd_str, 1); char **python_argv = malloc(sizeof(char*) * (argc + 3)); python_argv[0] = "Meshy"; python_argv[1] = "-S"; python_argv[2] = script_path; for (int i = 1; i < argc; i++) { python_argv[i + 2] = argv[i]; } python_argv[argc + 2] = NULL; posix_spawn_file_actions_t file_actions; posix_spawn_file_actions_init(&file_actions); posix_spawn_file_actions_addclose(&file_actions, cmd_pipe[0]); posix_spawn_file_actions_addclose(&file_actions, resp_pipe[1]); pid_t childPid; if (posix_spawn(&childPid, python_path, &file_actions, NULL, python_argv, environ) != 0) { perror("Failed to spawn Python"); return 1; } posix_spawn_file_actions_destroy(&file_actions); free(python_argv); close(cmd_pipe[1]); close(resp_pipe[0]); UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; NotificationDelegate *delegate = [[NotificationDelegate alloc] init]; center.delegate = delegate; UNTextInputNotificationAction *replyAction = [UNTextInputNotificationAction actionWithIdentifier:@"REPLY" title:@"Reply" options:0 textInputButtonTitle:@"Send" textInputPlaceholder:@"Message..."]; UNNotificationCategory *messageCategory = [UNNotificationCategory categoryWithIdentifier:@"MESSAGE" actions:@[replyAction] intentIdentifiers:@[] options:0]; [center setNotificationCategories:[NSSet setWithObject:messageCategory]]; // Exit when child process exits dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ int childStatus = 0; waitpid(childPid, &childStatus, 0); int code = WIFEXITED(childStatus) ? WEXITSTATUS(childStatus) : 1; dispatch_async(dispatch_get_main_queue(), ^{ close(notifyReadFd); close(notifyWriteFd); exit(code); }); }); // Read notification commands from Python dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ FILE *cmdStream = fdopen(notifyReadFd, "r"); if (!cmdStream) return; char *line = NULL; size_t cap = 0; while (getline(&line, &cap, cmdStream) != -1) { NSData *data = [NSData dataWithBytes:line length:strlen(line)]; NSDictionary *cmd = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; if (!cmd) { dispatch_async(dispatch_get_main_queue(), ^{ writeJSON(@{@"ok": @NO, @"error": @"invalid JSON"}); }); continue; } NSDictionary *cmdCopy = [cmd copy]; dispatch_async(dispatch_get_main_queue(), ^{ handleNotifyCommand(cmdCopy, center); }); } free(line); fclose(cmdStream); }); [[NSRunLoop mainRunLoop] run]; } return 0; } meshy/build-aux/nix/000077500000000000000000000000001526212641700147015ustar00rootroot00000000000000meshy/data/000077500000000000000000000000001526212641700131225ustar00rootroot00000000000000meshy/data/gtk/000077500000000000000000000000001526212641700137075ustar00rootroot00000000000000meshy/data/gtk/help-overlay.ui000066400000000000000000000112071526212641700166560ustar00rootroot00000000000000 General Disconnect <Primary>d Quit <Primary>q Help F1 Keyboard Shortcuts <Primary>question Toggle Fullscreen <Primary><Shift>f 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/000077500000000000000000000000001526212641700142355ustar00rootroot00000000000000meshy/data/icons/scalable/000077500000000000000000000000001526212641700160035ustar00rootroot00000000000000meshy/data/icons/scalable/actions/000077500000000000000000000000001526212641700174435ustar00rootroot00000000000000meshy/data/icons/scalable/actions/meshy-action-unavailable-symbolic.svg000066400000000000000000000011401526212641700266600ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-avatar-default-symbolic.svg000066400000000000000000000006501526212641700260270ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-100-symbolic.svg000066400000000000000000000014561526212641700262110ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-20-symbolic.svg000066400000000000000000000014561526212641700261320ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-30-symbolic.svg000066400000000000000000000015401526212641700261250ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-50-symbolic.svg000066400000000000000000000014561526212641700261350ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-60-symbolic.svg000066400000000000000000000014621526212641700261330ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-70-symbolic.svg000066400000000000000000000014621526212641700261340ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-80-symbolic.svg000066400000000000000000000014621526212641700261350ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-battery-level-low-symbolic.svg000066400000000000000000000020011526212641700264750ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-bluetooth-active-symbolic.svg000066400000000000000000000020721526212641700264050ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-bluetooth-disabled-symbolic.svg000066400000000000000000000021101526212641700266720ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-bluetooth-symbolic.svg000066400000000000000000000020721526212641700251340ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-broadcast-flood-symbolic.svg000066400000000000000000000026311526212641700261730ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-broadcast-symbolic.svg000066400000000000000000000027121526212641700250720ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-camera-switch-symbolic.svg000066400000000000000000000025011526212641700256530ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-charge-symbolic.svg000066400000000000000000000013351526212641700243610ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-chat-bubbles-text-symbolic.svg000066400000000000000000000013521526212641700264440ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-check-plain-symbolic.svg000066400000000000000000000013361526212641700253070ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-circle-crossed-symbolic.svg000066400000000000000000000011401526212641700260230ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-computer-symbolic.svg000066400000000000000000000010371526212641700247650ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-document-open-symbolic.svg000066400000000000000000000031221526212641700257010ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-document-save-symbolic.svg000066400000000000000000000011661526212641700257040ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-drive-removable-media-symbolic.svg000066400000000000000000000011251526212641700272650ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-edit-clear-all-symbolic.svg000066400000000000000000000023131526212641700257040ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-edit-clear-symbolic.svg000066400000000000000000000025701526212641700251430ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-edit-copy-symbolic.svg000066400000000000000000000013751526212641700250310ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-edit-delete-symbolic.svg000066400000000000000000000020021526212641700253050ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-edit-find-symbolic.svg000066400000000000000000000011161526212641700247700ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-edit-undo-symbolic.svg000066400000000000000000000012751526212641700250230ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-emblem-system-symbolic.svg000066400000000000000000000112001526212641700257030ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-face-smile-symbolic.svg000066400000000000000000000006531526212641700251370ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-filter-symbolic.svg000066400000000000000000000011731526212641700244150ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-find-location-symbolic.svg000066400000000000000000000024501526212641700256550ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-go-down-symbolic.svg000066400000000000000000000011171526212641700245000ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-go-next-symbolic.svg000066400000000000000000000011761526212641700245140ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-hashtag-symbolic.svg000066400000000000000000000005331526212641700245460ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-history-undo-symbolic.svg000066400000000000000000000026641526212641700256020ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-info-symbolic.svg000066400000000000000000000014441526212641700240640ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-key5-symbolic.svg000066400000000000000000000010231526212641700237770ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-list-add-symbolic.svg000066400000000000000000000003441526212641700246300ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-logs-symbolic.svg000066400000000000000000000024511526212641700240740ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-mail-send-symbolic.svg000066400000000000000000000017401526212641700250010ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-map-symbolic.svg000066400000000000000000000003651526212641700237070ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-network-offline-symbolic.svg000066400000000000000000000033561526212641700262460ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-network-transmit-receive-symbolic.svg000066400000000000000000000021201526212641700300710ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-network-wireless-symbolic.svg000066400000000000000000000031211526212641700264470ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-network-workgroup-symbolic.svg000066400000000000000000000046351526212641700266640ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-new-companion-symbolic.svg000066400000000000000000000041751526212641700257070ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-object-select-symbolic.svg000066400000000000000000000013471526212641700256560ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-open-menu-symbolic.svg000066400000000000000000000004721526212641700250340ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-padlock2-symbolic.svg000066400000000000000000000007031526212641700246250ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-phone-old-symbolic.svg000066400000000000000000000011651526212641700250160ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-plus-symbolic.svg000066400000000000000000000007311526212641700241120ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-podcast-symbolic.svg000066400000000000000000000037761526212641700246000ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-preferences-other-symbolic.svg000066400000000000000000000057631526212641700265610ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-preferences-system-details-symbolic.svg000066400000000000000000000003761526212641700304020ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-preferences-system-symbolic.svg000066400000000000000000000037451526212641700267620ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-qr-code-scanner-symbolic.svg000066400000000000000000000017441526212641700261150ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-radiowaves-dots-symbolic.svg000066400000000000000000000023601526212641700262420ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-region-symbolic.svg000066400000000000000000000015301526212641700244100ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-repeater-symbolic.svg000066400000000000000000000051151526212641700247370ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-resources-symbolic.svg000066400000000000000000000006701526212641700251430ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-share-alt-symbolic.svg000066400000000000000000000020101526212641700247770ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-share-symbolic.svg000066400000000000000000000020101526212641700242210ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-sidebar-show-right-symbolic.svg000066400000000000000000000012001526212641700266210ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-sidebar-show-symbolic.svg000066400000000000000000000011761526212641700255220ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-snapshots-alt-symbolic.svg000066400000000000000000000017551526212641700257360ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-star-filled-symbolic.svg000066400000000000000000000013331526212641700253340ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-star-half-symbolic.svg000066400000000000000000000022021526212641700250030ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-star-symbolic.svg000066400000000000000000000026611526212641700241040ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-starred-symbolic.svg000066400000000000000000000013171526212641700245740ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-strength-bars-1-symbolic.svg000066400000000000000000000013471526212641700260540ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-strength-bars-2-symbolic.svg000066400000000000000000000014251526212641700260520ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-strength-bars-3-symbolic.svg000066400000000000000000000014251526212641700260530ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-strength-bars-4-symbolic.svg000066400000000000000000000014261526212641700260550ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-strength-bars-5-symbolic.svg000066400000000000000000000013761526212641700260620ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-strength-bars-none-symbolic.svg000066400000000000000000000033141526212641700266470ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-lock-screen-symbolic.svg000066400000000000000000000007111526212641700266540ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-log-out-symbolic.svg000066400000000000000000000023341526212641700260400ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-monitor-symbolic.svg000066400000000000000000000024461526212641700261450ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-reboot-symbolic.svg000066400000000000000000000016621526212641700257470ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-run-symbolic.svg000066400000000000000000000126421526212641700252610ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-search-symbolic.svg000066400000000000000000000011411526212641700257120ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-system-users-symbolic.svg000066400000000000000000000020571526212641700256150ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-user-available-symbolic.svg000066400000000000000000000005111526212641700260170ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-user-home-symbolic.svg000066400000000000000000000016421526212641700250350ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-user-trash-symbolic.svg000066400000000000000000000017611526212641700252300ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-utilities-terminal-symbolic.svg000066400000000000000000000027541526212641700267620ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-view-more-symbolic.svg000066400000000000000000000007631526212641700250460ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-view-refresh-symbolic.svg000066400000000000000000000021361526212641700255360ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-view-sort-descending-symbolic.svg000066400000000000000000000020051526212641700271630ustar00rootroot00000000000000 meshy/data/icons/scalable/actions/meshy-weather-clear-symbolic.svg000066400000000000000000000045201526212641700256520ustar00rootroot00000000000000 meshy/data/icons/scalable/apps/000077500000000000000000000000001526212641700167465ustar00rootroot00000000000000meshy/data/icons/scalable/apps/page.codeberg.sesivany.Meshy-symbolic.svg000066400000000000000000000013431526212641700267200ustar00rootroot00000000000000 meshy/data/icons/scalable/apps/page.codeberg.sesivany.Meshy.svg000066400000000000000000000051021526212641700250760ustar00rootroot00000000000000 meshy/data/linux/000077500000000000000000000000001526212641700142615ustar00rootroot00000000000000meshy/data/linux/60-meshy-serial.rules000066400000000000000000000012671526212641700201700ustar00rootroot00000000000000# 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/000077500000000000000000000000001526212641700142245ustar00rootroot00000000000000meshy/data/macos/Info.plist.in000066400000000000000000000025601526212641700166040ustar00rootroot00000000000000 CFBundleIdentifier page.codeberg.sesivany.Meshy CFBundleName Meshy CFBundleDisplayName Meshy CFBundleVersion @VERSION@ CFBundleShortVersionString @VERSION@ CFBundleExecutable meshy CFBundleIconFile Meshy.icns CFBundlePackageType APPL NSHighResolutionCapable LSMinimumSystemVersion 15.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 LSUIElement meshy/data/meson.build000066400000000000000000000046161526212641700152730ustar00rootroot00000000000000# 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.in000066400000000000000000000005501526212641700225330ustar00rootroot00000000000000# 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.gresource.xml.in000066400000000000000000000461321526212641700236650ustar00rootroot00000000000000 icons/scalable/actions/meshy-action-unavailable-symbolic.svg icons/scalable/actions/meshy-avatar-default-symbolic.svg icons/scalable/actions/meshy-battery-level-100-symbolic.svg icons/scalable/actions/meshy-battery-level-80-symbolic.svg icons/scalable/actions/meshy-battery-level-70-symbolic.svg icons/scalable/actions/meshy-battery-level-60-symbolic.svg icons/scalable/actions/meshy-battery-level-50-symbolic.svg icons/scalable/actions/meshy-battery-level-30-symbolic.svg icons/scalable/actions/meshy-battery-level-20-symbolic.svg icons/scalable/actions/meshy-battery-level-low-symbolic.svg icons/scalable/actions/meshy-bluetooth-active-symbolic.svg icons/scalable/actions/meshy-bluetooth-disabled-symbolic.svg icons/scalable/actions/meshy-bluetooth-symbolic.svg icons/scalable/actions/meshy-broadcast-flood-symbolic.svg icons/scalable/actions/meshy-camera-switch-symbolic.svg icons/scalable/actions/meshy-broadcast-symbolic.svg icons/scalable/actions/meshy-charge-symbolic.svg icons/scalable/actions/meshy-chat-bubbles-text-symbolic.svg icons/scalable/actions/meshy-check-plain-symbolic.svg icons/scalable/actions/meshy-circle-crossed-symbolic.svg icons/scalable/actions/meshy-computer-symbolic.svg icons/scalable/actions/meshy-document-open-symbolic.svg icons/scalable/actions/meshy-document-save-symbolic.svg icons/scalable/actions/meshy-drive-removable-media-symbolic.svg icons/scalable/actions/meshy-edit-clear-all-symbolic.svg icons/scalable/actions/meshy-edit-clear-symbolic.svg icons/scalable/actions/meshy-edit-copy-symbolic.svg icons/scalable/actions/meshy-edit-delete-symbolic.svg icons/scalable/actions/meshy-edit-undo-symbolic.svg icons/scalable/actions/meshy-edit-find-symbolic.svg icons/scalable/actions/meshy-emblem-system-symbolic.svg icons/scalable/actions/meshy-face-smile-symbolic.svg icons/scalable/actions/meshy-filter-symbolic.svg icons/scalable/actions/meshy-find-location-symbolic.svg icons/scalable/actions/meshy-go-down-symbolic.svg icons/scalable/actions/meshy-history-undo-symbolic.svg icons/scalable/actions/meshy-hashtag-symbolic.svg icons/scalable/actions/meshy-info-symbolic.svg icons/scalable/actions/meshy-key5-symbolic.svg icons/scalable/actions/meshy-go-next-symbolic.svg icons/scalable/actions/meshy-list-add-symbolic.svg icons/scalable/actions/meshy-logs-symbolic.svg icons/scalable/actions/meshy-mail-send-symbolic.svg icons/scalable/actions/meshy-map-symbolic.svg icons/scalable/actions/meshy-network-offline-symbolic.svg icons/scalable/actions/meshy-network-transmit-receive-symbolic.svg icons/scalable/actions/meshy-network-wireless-symbolic.svg icons/scalable/actions/meshy-network-workgroup-symbolic.svg icons/scalable/actions/meshy-new-companion-symbolic.svg icons/scalable/actions/meshy-object-select-symbolic.svg icons/scalable/actions/meshy-open-menu-symbolic.svg icons/scalable/actions/meshy-padlock2-symbolic.svg icons/scalable/actions/meshy-phone-old-symbolic.svg icons/scalable/actions/meshy-plus-symbolic.svg icons/scalable/actions/meshy-podcast-symbolic.svg icons/scalable/actions/meshy-preferences-other-symbolic.svg icons/scalable/actions/meshy-preferences-system-details-symbolic.svg icons/scalable/actions/meshy-preferences-system-symbolic.svg icons/scalable/actions/meshy-qr-code-scanner-symbolic.svg icons/scalable/actions/meshy-region-symbolic.svg icons/scalable/actions/meshy-repeater-symbolic.svg icons/scalable/actions/meshy-resources-symbolic.svg icons/scalable/actions/meshy-radiowaves-dots-symbolic.svg icons/scalable/actions/meshy-share-alt-symbolic.svg icons/scalable/actions/meshy-share-symbolic.svg icons/scalable/actions/meshy-sidebar-show-right-symbolic.svg icons/scalable/actions/meshy-sidebar-show-symbolic.svg icons/scalable/actions/meshy-snapshots-alt-symbolic.svg icons/scalable/actions/meshy-star-filled-symbolic.svg icons/scalable/actions/meshy-star-half-symbolic.svg icons/scalable/actions/meshy-star-symbolic.svg icons/scalable/actions/meshy-starred-symbolic.svg icons/scalable/actions/meshy-strength-bars-1-symbolic.svg icons/scalable/actions/meshy-strength-bars-2-symbolic.svg icons/scalable/actions/meshy-strength-bars-3-symbolic.svg icons/scalable/actions/meshy-strength-bars-4-symbolic.svg icons/scalable/actions/meshy-strength-bars-5-symbolic.svg icons/scalable/actions/meshy-strength-bars-none-symbolic.svg icons/scalable/actions/meshy-system-lock-screen-symbolic.svg icons/scalable/actions/meshy-system-log-out-symbolic.svg icons/scalable/actions/meshy-system-reboot-symbolic.svg icons/scalable/actions/meshy-system-monitor-symbolic.svg icons/scalable/actions/meshy-system-run-symbolic.svg icons/scalable/actions/meshy-system-search-symbolic.svg icons/scalable/actions/meshy-system-users-symbolic.svg icons/scalable/actions/meshy-user-available-symbolic.svg icons/scalable/actions/meshy-user-home-symbolic.svg icons/scalable/actions/meshy-user-trash-symbolic.svg icons/scalable/actions/meshy-utilities-terminal-symbolic.svg icons/scalable/actions/meshy-view-more-symbolic.svg icons/scalable/actions/meshy-view-refresh-symbolic.svg icons/scalable/actions/meshy-view-sort-descending-symbolic.svg icons/scalable/actions/meshy-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/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/cli-dialog.ui ui/cli-console.ui ui/manual-backup-dialog.ui ui/auto-backup-dialog.ui ui/import-backup-dialog.ui ui/restore-snapshot-dialog.ui ui/new-companion-dialog.ui ui/passkey-dialog-content.ui ui/tcp-change-address-content.ui ui/tcp-add-content.ui ui/add-contact-content.ui ui/confirm-contact-dialog.ui ui/confirm-channel-dialog.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/add-regions-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 ui/online-nodes-enable-dialog.ui ui/online-nodes-disable-dialog.ui ui/custom-name-dialog.ui meshy/data/page.codeberg.sesivany.Meshy.gschema.xml000066400000000000000000000127311526212641700226670ustar00rootroot00000000000000 800 Window width 600 Window height false Window maximized false Window fullscreen '' Last connected device address '' Last connected device name '' Public key of the last connected device '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 'all' Contact list type filter Options: all, favorites, users, repeaters, rooms, sensors 'messages' Channel list sort order Options: messages, az 'all' Channel list type filter Options: all, public, hashtag, private '' 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 false Online node database Download node info from map.meshcore.io into discovered contacts true Auto-update online nodes Automatically refresh online node database daily 0 Last online nodes update Unix timestamp of the last successful online node database update ['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.in000066400000000000000000000370651526212641700234760ustar00rootroot00000000000000 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, USB or TCP to send and receive encrypted messages, manage contacts, configure channels, and monitor the mesh network.

Features:

  • Bluetooth LE, USB serial, TCP/Wifi 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:

  • Device CLI — run command-line commands on the connected companion from Device Actions
  • Remote CLI — send CLI commands to contacts and grant CLI access to others (requires MeshCore 1.18)
  • Initial setup dialog for newly flashed companions
  • Reworked region settings with a dedicated add dialog and a redesigned region discovery dialog
  • Sharing channels with a region scope, on both the sending and receiving side
  • Network section in repeater management — path hash size and default region, plus host password settings
  • CLI command autocomplete when managing repeaters, room servers, and sensors
  • Redesigned contact details dialog with share and favorite buttons on top
  • Last Seen filter on the map
  • Persistent contact and channel filters
  • Support for inverted (white on black) QR codes
  • Region scope shown in channel chats when incoming messages carry it
  • Total distance travelled by messages received in a channel
  • Dedicated plus button for sharing options; emoji picker moved to the message composer
  • Signal strength in the list of heard repeats
  • New radio presets — Netherlands (Limburg), USA - Southern California, and USA/Canada split into two
  • Ctrl+Shift+F toggles starting Meshy in fullscreen mode

Improvements:

  • The device's default flood scope is read on connect instead of being overwritten, and the default scope is included in the Apply batch
  • Renaming a contact is stored as a custom name instead of rewriting the node name
  • Backup compatibility with the official client, with more settings included in backups
  • Contact auto-add configuration fixed — manual_add_contacts is set so the firmware accepts per-type settings
  • Labels and values reordered in Device Info and contact details to match GNOME HIG
  • Hop count in channel chats taken from the shortest path, matching the official app
  • Character counter warns when a message exceeds 139 characters and may not carry full metadata
  • Notifications are withdrawn once no longer relevant instead of accumulating
  • Sticky chats and channels
  • Telemetry query moved to a top bar icon; telemetry section renamed to Permissions
  • Anonymous region/owner requests are sent directly when the contact has no path, restoring the path and last advert time afterwards
  • Node name shown in the list of apps running in the background
  • Channel slot count now taken from the device instead of a hardcoded value
  • Internal refactoring — views and utils split into modules, shared path rendering and path-byte decoding, storage cleanup

Fixes:

  • Adding a contact failed when the device contact list was full but overwrite-oldest was enabled
  • Orphaned message dividers left behind after deleting messages below them
  • Untranslated values in the smart routing path list
  • Regression in path drawing that drew a direct line between the first and last nodes
  • OK/ERR queue desync when an anonymous request returned an error
  • Queued messages fetched before channels were loaded
  • Stale GLib source ID warning in the trace picker map
  • Numeric keyboard digits now work for shortcuts

New features:

  • Import nodes from map.meshcore.io as discovered contacts, with optional periodic automatic updates — imported contacts are marked and can be removed at any time
  • Query regions and owner info from discovered nodes in Discover Nearby Nodes
  • Share your own contact and channels directly in chats
  • Mentions of users rendered as highlighted pills in messages and the composer
  • Multiline messages in chats
  • Camera switching and a reworked QR scanning dialog with MJPEG camera support
  • Automatic backup restoration when the companion is reflashed with a new public key
  • Number of missed channel messages reported in the notification sent after reconnect
  • Secretless hashtag channel URLs
  • Unscoped (flood) override when sending a message in a region-scoped channel
  • Major overhaul of sensor node support — issues fixed, node management added
  • Device Info redesigned into three tabs for a better overview

Improvements:

  • Automatic reconnect right after the system wakes from suspend
  • More reliable unread tracking — unread counts are no longer lost on reconnect
  • Reduced memory usage — loaded chat messages are released when switching away from a chat, channel message paths are loaded on demand
  • Confirmation dialogs when adding contacts and channels; toasts with Undo when removing them
  • More dialogs converted to GNOME HIG conventions
  • Regional presets updated, path hash size is now part of the preset
  • Smart routing history cleanup — near-zero-weight paths removed, purged paths not re-added
  • Path history removed together with the removed contact
  • Improved map scaling and richer discovered-node popups with an Add button
  • Bundled icons prefixed with meshy- so desktop themes cannot shadow them
  • Double-tap gesture to open the context menu on touchscreens

Fixes:

  • Unread counter cleared even though the user never saw the chat in collapsed mode
  • Corrupted contact names caused by feature bytes in advert payloads
  • Contact coordinates overwritten with zeros on refresh, making the contact disappear from the map
  • Chat views not loaded after leaving a chat — first contact/channel now auto-loaded
  • Channel message loss caused by a stuck message-sync batch
  • Contact chats with an unclickable text field
  • Message composer focus issues
  • Multi-byte UTF-8 characters truncated mid-character when sending
  • Two bugs in repeater and sensor telemetry requests
  • Anonymous region request replies with incorrectly encoded return paths
  • Echo filter breaking TerminalCLI commands
  • Channel list not refreshed after adding a new channel
  • Repeats miscounted when messages were purged from memory
  • Dead key input issues

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.css000066400000000000000000000076461526212641700150110ustar00rootroot00000000000000/* 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; } .mention-pill, .mention-pill-self { border-radius: 9999px; padding: 1px 6px; } /* Align pill baseline with surrounding text in the composer entry. The pill's vertical padding lifts its text above the line baseline; a negative bottom margin shifts the whole widget up just enough to compensate (value = vertical padding total). */ entry .mention-pill, entry .mention-pill-self { margin-bottom: -0.35em; } .mention-pill { background-color: alpha(@theme_fg_color, 0.12); } .mention-pill-self { background-color: alpha(@accent_bg_color, 0.2); color: @accent_color; font-weight: bold; } .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; } banner label.heading { font-feature-settings: "tnum"; } entry.chat-entry { padding-right: 0; } entry.chat-entry textview { background: none; min-height: 0; } entry.chat-entry textview > text { background: none; min-height: 0; } entry.chat-entry textview border { min-height: 0; min-width: 0; } /* QR scanner dialog — camera viewfinder overlay */ .qr-scanner-status { background: rgba(0, 0, 0, 0.45); padding: 8px 16px; border-radius: 8px; } meshy/data/ui/000077500000000000000000000000001526212641700135375ustar00rootroot00000000000000meshy/data/ui/add-contact-content.ui000066400000000000000000000063121526212641700177310ustar00rootroot00000000000000 Add Contact 360 false false _Cancel true _Add true vertical 12 12 12 12 12 Enter the contact details. true 0 none Contact Type Name Public Key (64 hex characters) meshy/data/ui/add-regions-dialog.ui000066400000000000000000000051351526212641700175330ustar00rootroot00000000000000 Add Regions 360 false false _Cancel true _Add true vertical 12 12 12 12 12 Add one or more regions, separated by commas. true 0 Regions meshy/data/ui/auto-backup-dialog.ui000066400000000000000000000273601526212641700175560ustar00rootroot00000000000000 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 meshy-key5-symbolic 5 false center Settings false true center meshy-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 meshy-avatar-default-symbolic Contact List check_contacts true center Discovered Contacts check_discovered_contacts true center Channels check_channels meshy-system-users-symbolic 5 true center Messages and Routing false true center meshy-chat-bubbles-text-symbolic Messages check_messages true center Smart Route History check_path_history true center meshy/data/ui/channel-chat-widget.ui000066400000000000000000000326471526212641700177200ustar00rootroot00000000000000 meshy/data/ui/channel-detail-dialog.ui000066400000000000000000000030241526212641700202020ustar00rootroot00000000000000 420 480 never true 420 vertical 12 12 12 12 12 meshy/data/ui/channel-message-item.ui000066400000000000000000000425271526212641700200760ustar00rootroot00000000000000 600 vertical horizontal 8 12 none false initials 32 true start emoji start vertical 2 12 2 2 0 false none false false text 12 12 8 8 contact vertical 6 12 12 8 8 0 true 6 meshy-avatar-default-symbolic 0 0 Add Contact start 4 channel vertical 6 12 12 8 8 0 true 6 meshy-system-users-symbolic 0 0 Join Channel start 4 location vertical 6 12 12 8 8 0 true 0 true hidden Open in Maps App 4 4 false false false 8 12 12 8 8 false true center true center meshy/data/ui/channel-participants-dialog.ui000066400000000000000000000064531526212641700214520ustar00rootroot00000000000000 Participants 420 480 meshy-system-search-symbolic Search meshy-filter-symbolic Filter meshy-view-sort-descending-symbolic Sort by vertical Search... 8 8 6 false never true false 4 4 meshy/data/ui/channels-view.ui000066400000000000000000000060541526212641700166460ustar00rootroot00000000000000 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 Import from Clipboard channel.import-clipboard Scan QR Code channel.scan-qr meshy/data/ui/chat-message-item.ui000066400000000000000000000410131526212641700173720ustar00rootroot00000000000000 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 12 12 8 8 contact vertical 6 12 12 8 8 0 true 6 meshy-avatar-default-symbolic 0 0 Add Contact start 4 channel vertical 6 12 12 8 8 0 true 6 meshy-system-users-symbolic 0 0 Join Channel 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.ui000066400000000000000000000313411526212641700157670ustar00rootroot00000000000000 meshy/data/ui/cli-console.ui000066400000000000000000000036211526212641700163070ustar00rootroot00000000000000 meshy/data/ui/cli-dialog.ui000066400000000000000000000067101526212641700161060ustar00rootroot00000000000000 meshy/data/ui/confirm-channel-dialog.ui000066400000000000000000000055321526212641700204030ustar00rootroot00000000000000 Add Channel 360 false false _Cancel true _Add true vertical 12 12 12 12 12 none Name Type Region false meshy/data/ui/confirm-contact-dialog.ui000066400000000000000000000065601526212641700204300ustar00rootroot00000000000000 Add Contact 360 false false _Cancel true _Add true false vertical 12 12 12 12 12 none Name Contact Type Public Key meshy-edit-copy-symbolic center Copy Public Key meshy/data/ui/connection-view.ui000066400000000000000000000070241526212641700172100ustar00rootroot00000000000000 meshy/data/ui/contact-detail-dialog.ui000066400000000000000000000321711526212641700202320ustar00rootroot00000000000000 meshy/data/ui/contacts-view.ui000066400000000000000000000037771526212641700167020ustar00rootroot00000000000000 meshy/data/ui/create-private-channel-content.ui000066400000000000000000000054721526212641700220770ustar00rootroot00000000000000 Private Channel 360 false false _Cancel true C_reate true vertical 12 12 12 12 12 A random PSK will be generated. Share it with others so they can join. true 0 none Channel Name meshy/data/ui/custom-name-dialog.ui000066400000000000000000000045671526212641700175770ustar00rootroot00000000000000 Rename 360 false false _Cancel true _Save true 12 12 12 12 Original Name -- true 0 Custom Name meshy/data/ui/custom-tiles-dialog.ui000066400000000000000000000055441526212641700177730ustar00rootroot00000000000000 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.ui000066400000000000000000000141671526212641700170040ustar00rootroot00000000000000 Trace Path Trace a route and show signal quality per hop trace_btn meshy-share-alt-symbolic center Discover Nearby Nodes Scan the network for nearby nodes discover_btn meshy-edit-find-symbolic center Rx Log View received radio packets rx_log_btn meshy-logs-symbolic center Device CLI Run CLI commands on this device cli_btn meshy-utilities-terminal-symbolic center Reboot Device reboot_btn meshy-system-reboot-symbolic center meshy/data/ui/device-view.ui000066400000000000000000000532361526212641700163160ustar00rootroot00000000000000 meshy/data/ui/discover-dialog.ui000066400000000000000000000067721526212641700171650ustar00rootroot00000000000000 Discover Contacts 420 520 meshy-system-search-symbolic Search meshy-filter-symbolic Filter by type meshy-view-sort-descending-symbolic Sort by vertical Search... 8 8 6 false true true No contacts 4 4 meshy/data/ui/discover-nearby-dialog.ui000066400000000000000000000056761526212641700204450ustar00rootroot00000000000000 Discover Nearby Nodes 420 480 vertical 8 8 8 8 8 center true true vertical 8 8 8 8 none meshy-edit-find-symbolic Listening... Waiting for nearby nodes to respond meshy/data/ui/discover-regions-dialog.ui000066400000000000000000000103601526212641700206150ustar00rootroot00000000000000 Discover Regions 420 480 false false _Cancel true Add Selected false vertical 8 8 8 8 8 center true true vertical 8 8 8 8 none meshy-edit-find-symbolic Scanning... Listening for nearby repeaters Try Again center false meshy/data/ui/import-backup-dialog.ui000066400000000000000000000303261526212641700201140ustar00rootroot00000000000000 Import Backup 360 false false _Cancel true _Restore true true true vertical 12 8 8 8 8 start none Device Identity check_device_identity meshy-key5-symbolic true center Settings false true center meshy-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 meshy-avatar-default-symbolic Contact List check_contacts true center Discovered Contacts check_discovered_contacts true center Channels check_channels meshy-system-users-symbolic true center Messages and Routing false true center meshy-chat-bubbles-text-symbolic Messages check_messages true center Smart Route History check_path_history true center meshy/data/ui/join-hashtag-channel-content.ui000066400000000000000000000055001526212641700215300ustar00rootroot00000000000000 Hashtag Channel 360 false false _Cancel true _Join true vertical 12 12 12 12 12 Enter a hashtag name. Anyone using the same hashtag can communicate. true 0 none Hashtag (e.g. #general) meshy/data/ui/join-private-channel-content.ui000066400000000000000000000060371526212641700215710ustar00rootroot00000000000000 Private Channel 360 false false _Cancel true _Join true vertical 12 12 12 12 12 Enter the channel name and private key shared with you. true 0 none Channel Name Private Key (32 hex characters) meshy/data/ui/location-dialog.ui000066400000000000000000000025601526212641700171460ustar00rootroot00000000000000 Device Location 400 450 vertical 8 12 12 12 meshy/data/ui/manual-backup-dialog.ui000066400000000000000000000275561526212641700200720ustar00rootroot00000000000000 Manual Backup 360 false false _Cancel true _Export true true true vertical 12 8 8 8 8 none Device Identity check_device_identity meshy-key5-symbolic 5 false center Settings false true center meshy-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 meshy-avatar-default-symbolic Contact List check_contacts true center Discovered Contacts check_discovered_contacts true center Channels check_channels meshy-system-users-symbolic 5 true center Messages and Routing false true center meshy-chat-bubbles-text-symbolic Messages check_messages true center Smart Route History check_path_history true center meshy/data/ui/map-dialog.ui000066400000000000000000000010471526212641700161120ustar00rootroot00000000000000 meshy/data/ui/map-picker-dialog.ui000066400000000000000000000031501526212641700173620ustar00rootroot00000000000000 false false _Cancel true true center center meshy/data/ui/map-sidebar-filters.ui000066400000000000000000000066351526212641700177420ustar00rootroot00000000000000 vertical 8 12 12 8 Users true meshy-avatar-default-symbolic Repeaters true meshy-repeater-symbolic Rooms true meshy-user-home-symbolic Sensors true meshy-weather-clear-symbolic Last Seen Discovered Nodes meshy-edit-find-symbolic 4 2 meshy/data/ui/map-view.ui000066400000000000000000000057561526212641700156400ustar00rootroot00000000000000 meshy/data/ui/message-paths-dialog.ui000066400000000000000000000025711526212641700201010ustar00rootroot00000000000000 Message Paths 420 480 never true vertical 8 12 12 12 12 meshy/data/ui/new-companion-dialog.ui000066400000000000000000000176421526212641700201170ustar00rootroot00000000000000 New Companion 420 false false _Cancel true _Forward true slide-left-right choice vertical 12 12 12 12 12 meshy-new-companion-symbolic 64 center 6 6 This companion looks newly flashed and hasn’t been set up yet. Choose how you’d like to continue. true 0.5 center none Set Up as New Companion Choose a node name and radio preset check_new true center Restore from a Backup File Import a Meshy or MeshCore backup file check_file center Restore from Automatic Backup Restore one of the daily snapshots stored on this computer check_auto center setup vertical 12 12 12 12 12 Choose a name for your companion and the radio preset used by the network in your area. true 0 Node Name Radio Preset meshy/data/ui/online-nodes-disable-dialog.ui000066400000000000000000000022511526212641700213260ustar00rootroot00000000000000 Disable Online Node Database? Online node updates will be stopped. vertical 8 Delete all contacts from online database true Cancel Disable meshy/data/ui/online-nodes-enable-dialog.ui000066400000000000000000000024721526212641700211560ustar00rootroot00000000000000 Online Node Database Node information will be downloaded from map.meshcore.io (~15 MB). Only nodes matching your device's radio preset and updated on the map in the last 3 months will be imported into discovered contacts. vertical 8 Automatically update daily true Cancel Download meshy/data/ui/pair-dialog.ui000066400000000000000000000062301526212641700162670ustar00rootroot00000000000000 meshy-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.ui000066400000000000000000000055241526212641700204700ustar00rootroot00000000000000 Enter Pairing PIN 360 false false _Cancel true _Pair true vertical 12 12 12 12 12 Enter the PIN shown on your MeshCore device. true 0 none PIN digits meshy/data/ui/qr-code-dialog.ui000066400000000000000000000037021526212641700166670ustar00rootroot00000000000000 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-scanner-dialog.ui000066400000000000000000000052151526212641700174070ustar00rootroot00000000000000 Scan QR Code 400 400 true flat false meshy-camera-switch-symbolic Switch Camera false true true cover Point the camera at a QR code... true center center end 40 16 16 16 meshy/data/ui/region-add-content.ui000066400000000000000000000057651526212641700175740ustar00rootroot00000000000000 Add Region 360 false false _Cancel true _Add true vertical 12 12 12 12 12 Enter a name and optionally choose a parent region. true 0 none Region Name Parent meshy/data/ui/repeater-telemetry-dialog.ui000066400000000000000000000023141526212641700211520ustar00rootroot00000000000000 420 true vertical 12 12 12 12 12 meshy/data/ui/repeater-view.ui000066400000000000000000001325371526212641700166700ustar00rootroot00000000000000 meshy/data/ui/restore-snapshot-dialog.ui000066400000000000000000000060721526212641700206600ustar00rootroot00000000000000 Restore from Automatic Backup 480 false false _Cancel true _Restore true vertical 12 12 12 12 true true 200 vertical 12 8 8 8 8 Show All Devices center meshy/data/ui/room-login-dialog.ui000066400000000000000000000055431526212641700174240ustar00rootroot00000000000000 360 280 vertical 12 16 16 12 12 none Password Save password 8 center 4 Login center meshy/data/ui/rx-log-dialog.ui000066400000000000000000000042331526212641700165450ustar00rootroot00000000000000 Rx Log 480 560 meshy-edit-clear-all-symbolic Clear log true vertical 8 8 8 8 none meshy-logs-symbolic No Packets Received radio packets will appear here meshy/data/ui/scan-dialog-content.ui000066400000000000000000000040311526212641700177250ustar00rootroot00000000000000 Scan for Devices 360 400 vertical 12 12 12 12 12 Scanning for MeshCore devices... true 0 true single meshy/data/ui/section-nav.ui000066400000000000000000000014371526212641700163310ustar00rootroot00000000000000 vertical true single meshy/data/ui/sensor-view.ui000066400000000000000000000073131526212641700163630ustar00rootroot00000000000000 meshy/data/ui/settings-sidebar.ui000066400000000000000000000114721526212641700173520ustar00rootroot00000000000000 12 12 8 Backup meshy-document-save-symbolic Manual Choose what to back up and save to a file manual_backup_btn meshy-document-save-symbolic center Automatic Daily backups, kept for 7 days auto_backup_btn meshy-snapshots-alt-symbolic center Restore meshy-document-open-symbolic From File Restore from a manually saved backup restore_file_btn meshy-document-open-symbolic center From Auto Backup Restore from an automatic snapshot restore_auto_btn meshy-history-undo-symbolic center true 12 12 12 Factory Reset Erase all data on the companion factory_reset_btn meshy-edit-clear-all-symbolic center meshy/data/ui/settings-view.ui000066400000000000000000000644501526212641700167170ustar00rootroot00000000000000 meshy/data/ui/smart-paths-dialog.ui000066400000000000000000000027441526212641700176050ustar00rootroot00000000000000 420 360 true 600 vertical 12 16 16 12 12 meshy/data/ui/stats-chart-dialog.ui000066400000000000000000000042701526212641700175730ustar00rootroot00000000000000 -- 420 600 vertical 12 16 16 12 12 -- dBm 160 true dBm over last 2 minutes meshy/data/ui/tcp-add-content.ui000066400000000000000000000063151526212641700170670ustar00rootroot00000000000000 Add TCP Companion 360 false false _Cancel true C_onnect true vertical 12 12 12 12 12 Enter the IP address and port of your MeshCore device. true 0 none Hostname / IP Address url Port digits 5000 meshy/data/ui/tcp-change-address-content.ui000066400000000000000000000062041526212641700212040ustar00rootroot00000000000000 Change Address 360 false false _Cancel true _Save true vertical 12 12 12 12 12 Enter the new address for this companion. true 0 none Hostname / IP Address url Port digits meshy/data/ui/telemetry-dialog.ui000066400000000000000000000023141526212641700173450ustar00rootroot00000000000000 420 true vertical 12 12 12 12 12 meshy/data/ui/theme-chooser-dialog.ui000066400000000000000000000037131526212641700201010ustar00rootroot00000000000000 meshy/data/ui/trace-path-dialog.ui000066400000000000000000000200171526212641700173630ustar00rootroot00000000000000 Trace Path 460 520 false false _Cancel true _Run Trace true never true vertical 8 8 8 8 8 Path Path Pick on Map Choose route on the map true meshy-map-symbolic meshy-go-next-symbolic 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 meshy-map-symbolic center false Show on Map false false vertical 0 4 8 meshy/data/ui/trace-picker-bottom-bar.ui000066400000000000000000000027641526212641700205240ustar00rootroot00000000000000 8 8 8 6 6 0 true true word-char false meshy-edit-undo-symbolic Undo meshy-edit-clear-all-symbolic Clear meshy/data/ui/window.ui000066400000000000000000000462521526212641700154160ustar00rootroot00000000000000
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/000077500000000000000000000000001526212641700131415ustar00rootroot00000000000000meshy/docs/.gitignore000066400000000000000000000000101526212641700151200ustar00rootroot00000000000000public/ meshy/docs/config.toml000066400000000000000000000007741526212641700153130ustar00rootroot00000000000000base_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.09" version_url = "https://codeberg.org/sesivany/meshy/src/tag/26.09" meshy/docs/content/000077500000000000000000000000001526212641700146135ustar00rootroot00000000000000meshy/docs/content/_index.md000066400000000000000000000001401526212641700163760ustar00rootroot00000000000000+++ title = "Meshy Documentation" sort_by = "weight" redirect_to = "guide/getting-started/" +++ meshy/docs/content/guide/000077500000000000000000000000001526212641700157105ustar00rootroot00000000000000meshy/docs/content/guide/_index.md000066400000000000000000000005001526212641700174730ustar00rootroot00000000000000+++ 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.md000066400000000000000000000035571526212641700200110ustar00rootroot00000000000000+++ 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 seconds next to the ✓ (delivered) status, e.g. `✓ 1.2s`; a late acknowledgement (received after the message was already marked failed) is marked with `(late)`. - 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.md000066400000000000000000000072751526212641700211730ustar00rootroot00000000000000+++ 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. When the device transitions to a new identity (e.g., after a firmware reflash that generates a new public key), Meshy preserves older snapshots from the previous identity if there are not enough new ones to fill the 7-day retention window. 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. If the companion device has been reflashed with a new identity (new public key), the list will only show backups for the new identity. Click **Show All Devices** to display backups from all previously connected devices, allowing you to restore data from the old identity onto the new one. ## 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.md000066400000000000000000000110041526212641700200210ustar00rootroot00000000000000+++ 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 from the lowercased channel name. Channels can also be added by scanning a QR code or importing a `meshcore://channel/add?...` URL from the contacts view. Hashtag channel URLs do not require a `secret` parameter -- the PSK is derived from the channel name, so a simple URL like `meshcore://channel/add?name=%23hiking` is sufficient. A URL may also carry an optional `region_scope` parameter (MeshCore App v1.47+); when present, Meshy adds that region to your configured regions and assigns it to the channel. ## Channel Chat Channel messages show the sender's name. Your own messages appear on the right; others' on the left. The message input field supports multiline messages -- press Shift+Enter to insert a new line, Enter to send. ### Mentions Type `@` at the beginning of a word in the message composer to mention another participant. A popover lists the senders recently seen in the channel (excluding yourself); pick one to insert a mention. Mentions are sent as `@[name]` and rendered as highlighted pills in the message list, with your own name highlighted differently when you are mentioned. When a channel's notification level is **Mentions Only**, only messages that mention you trigger a notification. ## 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 or double-tap (on touchscreens) 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)). When a channel is scoped to a region (either via the channel setting or the global default scope), the send button in the channel chat becomes a split button with a dropdown offering **Send to All Regions** -- a one-time override that sends the message without region scoping. ### Actions - **Clear Messages** -- deletes all messages in the channel. A toast notification with an **Undo** button appears briefly. - **Remove Channel** -- clears the channel slot on the device. A toast notification with an **Undo** button appears briefly. meshy/docs/content/guide/chat.md000066400000000000000000000123271526212641700171560ustar00rootroot00000000000000+++ 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. The input field supports multiline messages -- press Shift+Enter to insert a new line. An emoji picker is available via the smiley button. The send button is a split button with a dropdown menu offering additional send options: - **Send Automatically** -- uses the configured routing strategy (default). - **Send Direct** -- sends the message using direct (zero-hop) routing. - **Send Flood** -- sends the message using flood routing regardless of the configured strategy. 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. Mentions in the form `@[name]` (used by channel chats and some other clients) are rendered as highlighted pills in the message list; your own name is highlighted differently when you are mentioned. ## 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, long-press, or double-tap (on touchscreens) an outgoing message for: - **Send Again** -- re-send the message with a fresh retry cycle. - **Delete** -- remove the message from the chat and storage. A toast notification with an **Undo** button appears briefly. ## Sharing Right-click (or long-press) the message input field to open a menu with sharing options. These work in both direct chats and channel chats: - **Share My Contact** -- sends your own contact information as a message, so the recipient can add you. - **Share Contact** -- opens a picker to select one of your other contacts and sends their contact information as a message. - **Share Channel** -- opens a picker to select a channel and sends its `meshcore://channel/add?...` URL as a message, so the recipient can join it. If the channel is scoped to a region, the URL includes it as `region_scope`. - **Share Location** -- sends your device's current GPS coordinates (`latitude,longitude`). Requires the companion to have a GPS location. - **Share Location from Map** -- opens an interactive map to pick a point; sends its coordinates as a message. When a shared contact or channel arrives as a message, Meshy shows an **Add** / **Join** button in the bubble so you can add the contact or join the channel directly. meshy/docs/content/guide/contacts.md000066400000000000000000000214771526212641700200630ustar00rootroot00000000000000+++ 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 | 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 the full [management interface](@/guide/repeater-management.md) -- the same interface used for repeaters and room servers -- including status, CLI commands, and settings. | The contact type affects what you see when you select the contact in the sidebar: Chat and Room contacts open a messaging view, Repeater and Sensor contacts open the management interface. ## 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. If the system has multiple cameras, a button in the dialog lets you switch between them (the back camera is preferred by default). MJPEG cameras are supported. Requires camera permissions in Flatpak settings. When a contact is added from a clipboard link or a scanned QR code, a confirmation dialog first shows the parsed name, type, and public key; the name and type can be edited before confirming. If the device's contact table is full, a **Contact List Full** dialog appears instead -- remove unused contacts to make room for new ones. ## 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, long-press or double-tap (on touchscreens) a contact in the contact list, or click the name of the contact above the chat. ### Top Actions A row of icon buttons at the top of the dialog: - **Share** -- share your contact zero-hop, copy a `meshcore://` link, or show a QR code. - **Favorite** -- pin the contact to the top of your list. - **Request Telemetry** -- query the contact's telemetry (battery, environment sensors, location). The result opens in a dialog; if the contact does not support or does not answer telemetry requests, a toast notifies you. - **Remote CLI** -- open a command console for the contact (chat contacts only; shown when the connected companion runs MeshCore firmware 1.18 or newer). See [Remote CLI](#remote-cli) below. ### 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. ### Permissions For **Chat** contacts, the **Permissions** section controls what this contact is allowed to do on *your* companion. Changes are written to the contact record on the connected device. - **Allow Telemetry Requests** -- let this contact query your device's telemetry (battery, voltage, temperature). - **Include Location** -- include GPS coordinates in telemetry responses. - **Include Environment Sensors** -- include humidity, pressure, and air quality data. - **Allow Remote CLI** -- let this contact run CLI commands on your companion (MeshCore firmware 1.18 or newer). Off by default; only enable it for contacts you trust. ### Remote CLI For **Chat** contacts, when the connected companion runs MeshCore firmware 1.18 or newer, the **Remote CLI** top-bar icon opens a console that sends CLI commands to that contact over the mesh and shows the replies. The target companion must also run firmware 1.18 or newer. - **Command input** -- type a command and press Enter or click Send. - **Autocomplete** -- an inline popover suggests commands and parameters. `Tab` accepts a suggestion, `Enter` picks the highlighted one, `↑`/`↓` move through the list, and `Esc` dismisses it. When the popover is closed, `↑`/`↓` recall previous commands. - **Quick commands** -- `Reboot`, `Shut down`, `Get name`, `Board`, `Radio settings`, `Version`. - **Output area** -- shows each command and its response. Only one command is in flight at a time (20-second timeout). For a command to run, the **target** companion's owner must have enabled **Allow Remote CLI** for your contact (see [Permissions](#permissions) above). If it is not enabled, the command is delivered as a normal message on the target and no reply is returned. Repeaters, room servers, and sensors are managed through their own [management interface](@/guide/repeater-management.md) instead. ### 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. A toast notification with an **Undo** button appears briefly, allowing you to reverse the action. meshy/docs/content/guide/device.md000066400000000000000000000162551526212641700175020ustar00rootroot00000000000000+++ 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. The view is organized into three tabs: **Device**, **Radio**, and **Statistics**. ## Device Tab The Device tab shows basic information about your companion: - **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** -- percentage and voltage (mV) with a visual progress bar. - **Storage** -- percentage and used/total flash storage (KB) with a 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. ## Radio Tab Displays the current radio parameters (read-only here; to change them, go to **Settings**): - **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). ## Statistics Tab Detailed radio and packet statistics. Click **Refresh Statistics** at the bottom of the tab 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. Repeaters and room servers show a **Query Details** button that offers: - **Query Regions** -- requests the list of flood regions the node serves (repeaters and room servers). - **Query Name & Owner** -- requests the node's configured name and owner info (repeaters only). If the node was previously unknown and displayed as a hex prefix, a returned name replaces it. Responses are appended as extra lines under the node entry. - **Rx Log** -- view raw received packet log with hash deduplication. Shows packet hashes, SNR values, and timestamps. Useful for debugging and monitoring network activity. - **Device CLI** -- opens a command-line console for the connected companion (MeshCore firmware 1.18 or newer). Type a command and press Enter or click Send; quick buttons offer common commands (`reboot`, `shutdown`, `get name`, `board`, `get radio`, `ver`). Commands are completed and parameter suggestions appear as you type; press `Tab` to accept a suggestion and `↑`/`↓` to browse history. See [Device CLI](#device-cli) below. - **Reboot Device** -- restarts your companion device (requires confirmation). ## Device CLI The **Device CLI** action opens a console that runs CLI commands directly on the *connected* companion. It uses the current connection (USB, BLE, or TCP), so it works over whichever transport you are using -- no separate USB setup is required. - **Command input** -- type any command and press Enter or click the send button. - **Autocomplete** -- an inline popover suggests commands and parameters. Use `Tab` to accept, `Enter` to pick the highlighted suggestion, `↑`/`↓` to move through the list, and `Esc` to dismiss it. When the popover is closed, `↑`/`↓` recall previously entered commands. - **Quick commands** -- `Reboot`, `Shut down`, `Get name`, `Board`, `Radio settings`, `Version`. - **Output area** -- shows each command and its response in a monospace view. Only one command can be in flight at a time; the input is disabled until the reply arrives (20-second timeout). The available commands depend on the companion's board and firmware build. Commands that are not implemented return `Unknown command` -- for example `get fan` only exists on the LilyGo T-Beam 1W, `radio.fem.*` only on Heltec/Station/Wio boards, and `wifi.*` only on builds compiled with the WiFi interface. See the [MeshCore CLI reference](https://docs.meshcore.io/cli_commands/) for the full list. meshy/docs/content/guide/encryption.md000066400000000000000000000031451526212641700204270ustar00rootroot00000000000000+++ 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.md000066400000000000000000000050601526212641700213400ustar00rootroot00000000000000+++ 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 is also suspend-aware: when your computer goes to sleep, it cleanly disconnects from the device and reconnects automatically after wake-up. Any messages received by the device while disconnected are synced on reconnect, and a notification shows the count of missed direct and channel messages. meshy/docs/content/guide/keyboard-shortcuts.md000066400000000000000000000014661526212641700220750ustar00rootroot00000000000000+++ title = "Keyboard Shortcuts" weight = 13 +++ ### General | Shortcut | Action | |----------|--------| | Ctrl+Q | Quit the application | | Ctrl+D | Disconnect from device | | Ctrl+? | Show keyboard shortcuts | | Ctrl+Shift+F | Toggle fullscreen | | 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 | | Shift+Enter | Insert new line | | Ctrl+N | New message | | Ctrl+F | Search | ### Map | Shortcut | Action | |----------|--------| | Ctrl+M | Custom tile sources | meshy/docs/content/guide/map.md000066400000000000000000000037701526212641700170160ustar00rootroot00000000000000+++ 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 saved contact marker to open the contact's detail dialog. For discovered nodes, clicking a marker opens a detail popover showing: - **Type** -- the node type (Chat, Repeater, Room, Sensor). - **Public Key** -- truncated key with a copy button to copy the full key. - **Location** -- GPS coordinates. - **Last Seen** -- when the node was last heard. - **Add to Contacts** -- button to add the discovered node directly to your contact list. ## 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 or the [online node database](@/guide/settings.md#online-node-database) are shown. The **Last Seen** dropdown limits the map to nodes heard within **Past hour**, **Past day**, or **Past month** (or **Any time**); nodes that have never been heard are hidden as soon as a time window is selected. 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.md000066400000000000000000000121161526212641700221540ustar00rootroot00000000000000+++ title = "Repeater, Room and Sensor Management" weight = 9 +++ Repeaters, room servers, and sensors can be managed remotely through a shared management interface. All three device types use the same set of tabs, but some features are only available for specific types. | Tab / Feature | Repeater | Room Server | Sensor | Requires Admin | |---------------|:--------:|:-----------:|:------:|:--------------:| | Status | ✔ | ✔ | ✔ | — | | CLI | ✔ | ✔ | ✔ | ✔ | | Neighbors | ✔ | — | — | ✔ | | Access Control (ACL) | ✔ | ✔ | — | ✔ | | Regions | ✔ | ✔ | — | ✔ | | Settings | ✔ | ✔ | ✔ | ✔ | | Guest Access | ✔ | ✔ | — | ✔ (to configure) | ## Logging In 1. For **repeaters** and **sensors**, 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 and sensor firmware do not support neighbor queries, so this tab is hidden for those contact types. - 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.md000066400000000000000000000126231526212641700177250ustar00rootroot00000000000000+++ 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 or below, it is removed from the list, and a path that has been removed this way is not re-added just because it still exists in the firmware's routing table. 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.md000066400000000000000000000151701526212641700200760ustar00rootroot00000000000000+++ 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 26 regional configurations (Australia, EU/UK, USA, Canada, Czech Republic, etc.) plus Custom. Selecting a preset fills in the frequency, bandwidth, spreading factor, and coding rate automatically. Some presets (e.g. Hungary, Slovakia, Canada, New Zealand) also prescribe the **Default Path Hash Size** in [Routing and Messaging](#routing-and-messaging). - **Radio 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". Changing the **Default Path Hash Size** keeps the current preset selected unless the preset prescribes a conflicting hash size. - **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 this device's location service (requires location permissions). - **Pick on Map** -- opens an interactive map with a crosshair; pan and zoom to your location, then confirm. ## Contacts ### Online Node Database - **Online Node Database** -- toggle to import node information from [map.meshcore.io](https://map.meshcore.io). When enabled, Meshy downloads the node database (~15 MB) and adds matching nodes to your discovered contacts. Only nodes matching your device's radio preset and updated on the map in the last 3 months are imported. - **Automatically update daily** -- checkbox in the enable dialog; when checked, the database is refreshed once a day in the background. - When disabling, you can optionally delete all imported online node contacts. - On metered connections (detected via XDG portals), automatic updates are skipped to avoid unwanted data usage. Imported nodes appear as discovered contacts on the [Map](@/guide/map.md) and in the Discover Contacts dialog. They are marked as online imports and can be removed at any time. ### 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. - **Discover Regions from Repeaters** -- the search icon in the region entry scans for nearby repeaters and queries them for the regions they serve, so you can add them without typing. Repeaters are queried sequentially, and the results are offered as regions to add. - **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.md000066400000000000000000000101441526212641700214610ustar00rootroot00000000000000+++ 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/000077500000000000000000000000001526212641700144305ustar00rootroot00000000000000meshy/docs/static/logo.svg000066400000000000000000000051021526212641700161070ustar00rootroot00000000000000 meshy/docs/themes/000077500000000000000000000000001526212641700144265ustar00rootroot00000000000000meshy/docs/themes/easydocs/000077500000000000000000000000001526212641700162405ustar00rootroot00000000000000meshy/docs/themes/easydocs/.gitignore000066400000000000000000000000321526212641700202230ustar00rootroot00000000000000/public README.md LICENSE meshy/docs/themes/easydocs/config.toml000066400000000000000000000020401526212641700203760ustar00rootroot00000000000000base_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/000077500000000000000000000000001526212641700172115ustar00rootroot00000000000000meshy/docs/themes/easydocs/sass/_search.scss000066400000000000000000000015661526212641700215220ustar00rootroot00000000000000.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.scss000066400000000000000000000010611526212641700222130ustar00rootroot00000000000000$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.scss000066400000000000000000000125101526212641700237200ustar00rootroot00000000000000/* 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.scss000066400000000000000000000142241526212641700210350ustar00rootroot00000000000000@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/000077500000000000000000000000001526212641700175275ustar00rootroot00000000000000meshy/docs/themes/easydocs/static/js.js000066400000000000000000000217071526212641700205100ustar00rootroot00000000000000// 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.svg000066400000000000000000000051021526212641700212060ustar00rootroot00000000000000 meshy/docs/themes/easydocs/templates/000077500000000000000000000000001526212641700202365ustar00rootroot00000000000000meshy/docs/themes/easydocs/templates/404.html000066400000000000000000000004701526212641700214340ustar00rootroot00000000000000{% 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.html000066400000000000000000000000661526212641700233330ustar00rootroot00000000000000  meshy/docs/themes/easydocs/templates/index.html000066400000000000000000000174401526212641700222410ustar00rootroot00000000000000 {% if config.extra.favicon -%} {% set _favicon = config.extra.favicon -%} {% if (_favicon is starting_with(pat="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.html000066400000000000000000000003371526212641700220430ustar00rootroot00000000000000{% 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.html000066400000000000000000000013021526212641700241470ustar00rootroot00000000000000

    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.html000066400000000000000000000020251526212641700225670ustar00rootroot00000000000000{% 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.toml000066400000000000000000000015451526212641700202440ustar00rootroot00000000000000# Vendored from https://github.com/codeandmedia/zola_easydocs_theme # Upstream base: 542f699 (Zola 0.23 support). Meshy customizations on top: # - templates/index.html: version banner (config.extra.version / version_url) # - templates/index.html: starting_with(pat=...) for the nav active state # (upstream still uses the positional form, which zola 0.23 rejects) # - templates/page.html: page

    title # - sass/_variables.scss: Meshy accent colours and fonts # - sass/main.scss: logo width and release-icon tweaks # - static/logo.svg: Meshy logo name = "EasyDocs" description = "An easy way to create docs for your project" license = "MIT" homepage = "https://github.com/codeandmedia/zola_easydocs_theme" min_version = "0.23.0" demo = "https://zola-easydocs-theme.pages.dev" [author] name = "Roman Soldatenkov" homepage = "https://codeandmedia.com" meshy/meson.build000066400000000000000000000042431526212641700143560ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later project('meshy', version: '26.10', 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', 'gi': 'PyGObject'} _required_modules = ['Crypto', 'serial', 'segno', 'msgpack', 'gi'] # 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.txt000066400000000000000000000006301526212641700156450ustar00rootroot00000000000000# 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/000077500000000000000000000000001526212641700126275ustar00rootroot00000000000000meshy/po/LINGUAS000066400000000000000000000001021526212641700136450ustar00rootroot00000000000000be cs da de es et fi fr hu it lt lv nl pl pt ro ru ru_UA sk sv uk meshy/po/POTFILES000066400000000000000000000045261526212641700140060ustar00rootroot00000000000000data/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/restore-snapshot-dialog.ui data/ui/discover-dialog.ui data/ui/theme-chooser-dialog.ui data/ui/contact-detail-dialog.ui data/ui/cli-dialog.ui data/ui/cli-console.ui data/ui/custom-name-dialog.ui data/ui/new-companion-dialog.ui data/ui/trace-path-dialog.ui data/ui/add-contact-content.ui data/ui/add-regions-dialog.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-picker-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/confirm-channel-dialog.ui data/ui/confirm-contact-dialog.ui data/ui/qr-scanner-dialog.ui data/ui/region-add-content.ui data/ui/scan-dialog-content.ui data/ui/room-login-dialog.ui data/ui/tcp-add-content.ui data/ui/tcp-change-address-content.ui data/ui/trace-picker-bottom-bar.ui data/ui/online-nodes-enable-dialog.ui data/ui/online-nodes-disable-dialog.ui src/application.py src/companion_setup.py src/connection_controller.py src/frame_handler.py src/message_controller.py src/models.py src/platforms/linux/ble_bluez.py src/qr_scanner.py src/utils.py src/window.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/cli_dialog.py src/views/cli_console.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 src/map_nodes.py meshy/po/be.po000066400000000000000000002474621526212641700135740ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: be\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 ? 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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/cs.po000066400000000000000000003572721526212641700136140ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-09-28 20:27+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.9.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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" "Meshy je klient pro zařízení sítě mesh MeshCore LoRa. Připojte se k svému " "companionu přes Bluetooth, USB a TCP a posílejte a přijímejte šifrované " "zprávy, spravujte kontakty, nastavujte kanály a sledujte síť mesh." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "Funkce:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "Připojení přes Bluetooth LE , USB sériový port s TCP/WiFi" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "Šifrování zpráv mezi koncovými body" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "Veřejné, soukromé a hashtagové kanály" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "Správa kontaktů se sledováním polohy" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "Interaktivní zobrazení mapy s trasováním cest" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "Správa zařízení a repeaterů" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 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:29 msgid "Jiri Eischmann" msgstr "Jiří Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "Chaty kanálů" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "Správa repeateru" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "Nastavení - tmavý styl" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "Přepnout režim celé obrazovky" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "Navigace" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "Zařízení" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "Kontakty" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "Kanály" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "Mapa" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "Nastavení" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "Zprávy" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "Hledat kontakty" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "Přepnutí na psaní zprávy" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "Předchozí kontakt/kanál" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "Další kontakt/kanál" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "Předchozí nepřečtená" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "Nepřipojeno" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Stav" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "Odpojeno" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "Název uzlu" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "Deska" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:102 msgid "Update" msgstr "Aktualizovat" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "Otevřít flasher firmwaru" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "Veřejný klíč" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "Zkopírovat do schránky" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "Telemetrie" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "Vyžádat telemetrii" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "Poloha" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "Synchronizace…" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "Zobrazit na mapě" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "Baterie" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "Úložiště" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "Rádio" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "Frekvence" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "Šířka pásma" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "Faktor rozprostření" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "Kódovací poměr" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "Vysílací výkon" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Výchozí velikost hashe trasy" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "Statistiky" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "Úroveň šumu" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "Zobrazit živý graf" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "Poslední RSSI" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "Poslední SNR" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "Doba vysílání" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Pakety" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "Přímé" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Doba běhu" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Fronta zpráv" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "Obnovit" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "Obnovit statistiky" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "Všechny zprávy" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "Pouze zmínky" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio Configuration" msgstr "Konfigurace rádia" #: 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:723 msgid "Frequency (MHz)" msgstr "Frekvence (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "Šířka pásma (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "Vysílací výkon (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "Klasické" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "1 bajt (max 64 skoků)" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "2 bajty (max 32 skoků)" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "Zeměpisná šířka" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 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 device's location" msgstr "Nastavit podle polohy tohoto zařízení" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 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:313 msgid "Contact discovery and automatic adding" msgstr "Objevování kontaktů a jejich automatické přidávání" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Automatické přidávání kontaktů" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "Automaticky přidávat uzly zjištěné z advertů" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "Nastavení automatického přidávání" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "Typy zařízení, politika přepisu, limit hopů" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "Chatové uzly" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "Repeatery" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "Room servery" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "Senzory" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "Přepsat nejstarší při plné kapacitě" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Limit hopů" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0–63, ponechte prázdné pro žádný limit" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "Online databáze uzlů" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "Stáhnout informace o uzlech z map.meshcore.io" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "Soukromí telemetrie" #: data/ui/settings-view.ui:378 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:381 msgid "Battery & Sensors" msgstr "Baterie a senzory" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Baterie, napětí, teplota, proud" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "Souřadnice GPS" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Prostředí" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Vlhkost, tlak, kvalita vzduchu" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "Regiony" #: data/ui/settings-view.ui:404 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:428 msgid "Add region…" msgstr "Přidat region…" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "Objevit regiony z repeaterů" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "Přidat region" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Výchozí oblast" #: data/ui/settings-view.ui:452 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:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "Přidat TCP companiona" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Hledat…" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "Žádné kontakty" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Přidat kontakt" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "Zatím žádné kontakty" #: data/ui/chat-view.ui:23 msgid "Add contacts to start chatting" msgstr "Přidejte si kontakty 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "Sdílet" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" "Takto dlouhé zprávy nemusí zaznamenávat opakování a u některých příjemců " "obsahovat informace o trase." #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "Napište zprávu…" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "Vložit emoji" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "Vytvořit soukromý kanál" #: data/ui/channels-view.ui:14 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 msgid "Join a Hashtag Channel" msgstr "Připojit se ke kanálu s hashtagem" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "Importovat ze schránky" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "Naskenovat QR kód" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "Žádné kanály" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Přidat kanál" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "Zatím žádné kanály" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "Přidejte si kanály a začněte chatovat" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "Vyžádat telemetrii" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "Sousedé" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "Načíst další" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "Zobrazit na mapě" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "Řízení přístupu" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "Přidat" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "Načítání regionů…" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "Zkusit znovu" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "Výchozí omezující region" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "Použít" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "Základní" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "Načíst" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "Uložit" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "Název" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "Opakování paketů" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "Přístup pro hosta" #: data/ui/repeater-view.ui:603 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:612 msgid "Passwords" msgstr "Hesla" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "Heslo správce" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "Heslo hosta" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "Advert" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "Místní interval (min)" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "Interval floodu (hod)" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "Síť" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "Velikost hashe trasy" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "Velikost trasového ID, které tento uzel hlásí" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "1 bajt" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "2 bajty" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "3 bajty" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "Výchozí region (omezení)" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "Flood pakety budou omezeny na tento region, pokud se nepřepíše" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "Nebezpečná zóna" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "Restartovat zařízení" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "Vybrat zařízení" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "CLI zařízení" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" msgstr "Spouštět CLI příkazy na tomto zařízení" #: 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "_Zrušit" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "_Exportovat" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "Identifikace zařízení" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Nastavení zařízení" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "Místní nastavení" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "Seznam kontaktů" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "Objevené kontakty" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "Zprávy a směrování" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "Zprávy" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" msgstr "Historie chytrého směrování" #: 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "_Obnovit" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "Obnovit z automatického snapshotu" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "Zobrazit všechna zařízení" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "Objevit kontakty" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Vyberte téma" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "Přidat mezi oblíbené" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "Vzdálené CLI" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "Typ" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "Naposledy viděn" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "Směrování" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "Odchozí trasa" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "Resetovat trasu" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "Vymazat zavedenou trasu a přepnout na flood" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "Dostupné cesty" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "Automaticky resetovat trasu" #: data/ui/contact-detail-dialog.ui:213 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:219 msgid "Force Flood" msgstr "Vynutit flood" #: data/ui/contact-detail-dialog.ui:220 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:230 msgid "Permissions" msgstr "Oprávnění" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "Odebrat kontakt" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "Restartovat" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "Vypnout" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "Zeptat se na jméno" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "Nastavení rádia" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "Verze" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "Příkaz CLI…" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "Přejmenovat" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "_Uložit" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "Původní jméno" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "Vlastní pojmenování" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "Nový companion" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "_Vpřed" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" "Tento companion vypadá jako nově naflashovaný a ještě nenastavený. Vyberte, " "jak pokračovat." #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "Nastavit nový companion" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "Pojmenujte uzel a vyberte preset rádia" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "Obnovit ze souboru se zálohou" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "Importovat soubor se zálohou Meshy nebo oficiálního klienta MeshCore" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "Obnovit jeden z denních snapshotů uložených na tomto počítači" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" "Pojmenujte svůj companion a vyberte preset rádia používaný sítí ve vaší " "oblasti." #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "Preset rádia" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "_Spustit trasování" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "Trasa" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "Zvolte trasu na mapě" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "Zadejte hashe trasy a stiskněte Spustit trasování" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "_Přidat" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "Zadejte údaje kontaktu." #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "Typ kontaktu" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "Veřejný klíč (64 hex znaků)" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "Připojit se ke kanálu" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "Otevřít v mapové aplikaci" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "Účastníci" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "Filtr" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "Soukromý kanál" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "_Vytvořit" #: data/ui/create-private-channel-content.ui:43 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." #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "Přidat vybrané" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "Kanál s hashtagem" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "_Připojit se" #: data/ui/join-hashtag-channel-content.ui:43 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." #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (např. #obecný)" #: data/ui/join-private-channel-content.ui:43 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." #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "Uživatelé" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Roomy" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "Objevené uzly" #: data/ui/map-view.ui:30 msgid "User" msgstr "Uživatel" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "Repeater" #: data/ui/map-view.ui:62 msgid "Room" msgstr "Room" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "Senzor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "Hledání zařízení MeshCore…" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "Zadejte PIN pro párování" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "_Spárovat" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "Zadejte PIN zobrazený na vašem zařízení MeshCore." #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "Zkopírovat veřejný klíč" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "Přepnout kameru" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "Namiřte kameru na QR kód…" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Přidat region" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "Zadejte název a volitelně vyberte rodičovský region." #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "Název regionu" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "Rodič" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "Hledat zařízení" #: data/ui/room-login-dialog.ui:33 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 msgid "Login" msgstr "Přihlásit" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "_Připojit" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "Zadejte IP adresu a port vašeho zařízení MeshCore." #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "Název hostitele / IP adresa" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "Port" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "Změnit adresu" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "Vložit novou adresu pro tento companion." #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "Vrátit" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "Vymazat" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "Online databáze kontaktů" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" "Informace o uzlech budou stažené z map.meshcore.io (~15 MB). Pouze uzly, " "které mají stejný preset rádia jako vaše zařízení a byly aktualizovány v " "posledních 3 měsících budou importovány do objevených kontaktů." #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "Automaticky denně aktualizovat" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "Zrušit" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "Stáhnout" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "Zakázat online databázi uzlů?" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "Online aktualizace uzlů budou zastaveny." #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "Smazat všechny kontakty z online databáze" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "Zakázat" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "Neplatný odkaz meshcore://" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "Neplatný tajný klíč kanálu v odkazu" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "Neplatná délka tajného klíče kanálu" #: src/application.py:122 msgid "Invalid public key in link" msgstr "Neplatný veřejný klíč v odkazu" #: src/application.py:123 msgid "Invalid public key length" msgstr "Neplatná délka veřejného klíče" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "Nerozpoznaný odkaz meshcore://" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "{} je již ve vašem seznamu" #: src/application.py:146 msgid "Contact" msgstr "Kontakt" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "Kontakt {} přidán" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "Zprávy se přijímají na pozadí" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" "gallegonovato — španělština\n" "lebeno — francouzština, nizozemština\n" "bjawebos — němčina\n" "Manuel Traut — němčina\n" "bobidle — German\n" "lejun — francouzština\n" "LievenBlancke — nizozemština\n" "Jiří Eischmann — čeština\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "GTK klient pro zařízení sítě mesh MeshCore LoRa" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "Symbolické ikony Adwaita" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "Vlastní" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "Nastavit companion" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "_Zpátky" #: src/companion_setup.py:194 msgid "_Finish" msgstr "_Dokončit" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "Nový companion nastaven" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "Připojování…" #: src/connection_controller.py:288 msgid "Stop" msgstr "Zastavit" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Opětovné připojení ({current}/{total})…" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "Synchronizace…" #: src/connection_controller.py:347 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:593 msgid "Syncing channels" msgstr "Synchronizace kanálů" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} zpráva" msgstr[1] "{} zprávy" msgstr[2] "{} zpráv" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} zpráva v kanálech" msgstr[1] "{} zprávy v kanálech" msgstr[2] "{} zpráv v kanálech" #: src/connection_controller.py:681 msgid " and " msgstr " a " #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "Přijali jste {} během odpojení." #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "Synchronizace kontaktů" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "Chyba zařízení: {}" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "{} (pozdě)" #: src/frame_handler.py:752 msgid "late" msgstr "pozdě" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "Hodiny zařízení byly {}min pozadu — synchronizovány" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Hodiny zařízení jsou {}min napřed oproti systému" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "Kanál {}" #: src/frame_handler.py:1406 msgid "Someone" msgstr "Někdo" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "Já" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "flood" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "přímá" #: src/message_controller.py:234 msgid "path" msgstr "trasa" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "{attempt}/{total} – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "čekání {} (rádio zaneprázdněno)" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d skok" msgstr[1] "%d skoky" msgstr[2] "%d skoků" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "Špatný PIN — zkuste to znovu" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "Párování zrušeno" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "Párování selhalo — nelze se připojit" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" msgstr "Párování selhalo" #: 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:378 msgid "Missing GStreamer plugin." msgstr "Chybějící zásuvný modul GStreameru." #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Chyba kanálu kamery: {}" #: src/qr_scanner.py:469 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:480 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:486 msgid "QR code found!" msgstr "QR kód nalezen!" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "Chyba kamery: {}" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "Chyba kamery" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "OK" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "{} m" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "{} km" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "{} stop" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "{} mil" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "{d}d" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "{h}h" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "{m}m" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "{secs}s" #: src/utils.py:253 #, 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" #: src/utils.py:256 #, 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" #: src/utils.py:259 #, 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" #: src/utils.py:261 #, 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" #: src/window.py:536 msgid "Fullscreen off" msgstr "Režim celé obrazovky vypnut" #: src/window.py:540 msgid "Fullscreen on" msgstr "Režim celé obrazovky zapnut" #: src/window.py:555 msgid "Raster" msgstr "Rasterové" #: src/window.py:555 msgid "Vector" msgstr "Vektorové" #: src/window.py:563 #, 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:567 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:573 msgid "Style JSON URL" msgstr "URL k JSON stylu" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "Vlastní mapové dlaždice nastaveny" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "Nelze načíst vlastní mapové dlaždice" #: src/window.py:637 msgid "Sort" msgstr "Řadit" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "Akce" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "Informace o zařízení" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "Chat" #: src/window.py:733 msgid "Channel" msgstr "Kanál" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "Záloha a obnova" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:1031 msgid "Management" msgstr "Správa" #: src/window.py:1031 msgid "Guest" msgstr "Host" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1166 msgid "Excellent" msgstr "Vynikající" #: src/window.py:1167 msgid "Good" msgstr "Dobrý" #: src/window.py:1168 msgid "Fair" msgstr "Slušný" #: src/window.py:1169 msgid "Poor" msgstr "Špatný" #: src/window.py:1170 msgid "Very poor" msgstr "Velmi špatný" #: src/window.py:1432 msgid "flood routed" msgstr "směrováno floodem" #: src/window.py:1432 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "Advert odeslán ({})" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "Sdílení {}" #: src/window.py:1439 msgid "Could not share contact" msgstr "Kontakt nešlo sdílet" #: src/window.py:1764 msgid "Contact List Full" msgstr "Seznam kontaktů je plný" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" "Seznam kontaktů v zařízení je plný. Odstraňte nepoužívané kontakty, aby bylo " "možné přidat nové." #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "{} odstraněn" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "Kanál „{}“ je již přidán" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "Nejsou dostupné žádné volné sloty kanálů" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Kanál „{}“ přidán" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "Zprávy v „{}“ smazány" #: src/views/__init__.py:66 msgid "Today" msgstr "Dnes" #: src/views/__init__.py:68 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:73 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:76 msgid "%A, %B %-d, %Y" msgstr "%A, %-d. %B %Y" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "Neznámý" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "{total} kontaktů" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} kontaktů" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "Vše" #: src/views/__init__.py:656 msgid "Distance" msgstr "Vzdálenost" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "Přihlásit se k {}" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "Přihlášení selhalo — nesprávné heslo" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "Přihlášení vypršelo" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "Opakování přes flood…" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "Přihlašování…" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "Room server" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "Veřejný klíč zkopírován" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "Nepojmenovaný" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Hashtag" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "Soukromý" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "Bluetooth je vypnutý" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "Zapněte Bluetooth v nastavení systému pro připojení" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "Žádná spárovaná zařízení" #: src/views/connection_view.py:110 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:153 msgid "No USB devices detected" msgstr "Nebyla zjištěna žádná USB zařízení" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "Připojte companiona MeshCore přes USB" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "Odebrat companiona" #: src/views/connection_view.py:184 #, 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:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "Smazat uložená data" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "Připojení USB selhalo" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "Přístup k USB odepřen" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "Nainstalovat pravidlo" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "Zavřít" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "Kopírovat příkaz" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "Příkaz zkopírován do schránky" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "Instalace selhala" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Nepodařilo se nainstalovat pravidlo udev:\n" "{}" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "Žádní uložení companioni" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "Pomocí tlačítka níže přidejte companiona" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "Odebrat TCP companiona {}?" #: src/views/connection_view.py:590 msgid "Pair" msgstr "Spárovat" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "Nikdy" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "Zobrazit trasu na mapě" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "Zobrazit trasu zprávy na mapě" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "Povolit požadavky na telemetrii" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "Povolit tomuto kontaktu dotazovat baterii, napětí, teplotu" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "Zahrnout polohu" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "Zahrnout souřadnice GPS v odpovědích telemetrie" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "Zahrnout senzory prostředí" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "Zahrnout data o vlhkosti, tlaku, kvalitě vzduchu" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "Povolit vzdálené CLI" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "Povolit tomuto kontaktu pouštět CLI příkazy na tomto companionu" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "Správa roomu" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "Správa repeateru" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "Správa sensoru" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "Přihlásit se a získat přístup ke CLI, nastavením a regionům" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "Změřit dobu odezvy k tomuto uzlu" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "Objevit trasy" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "Najít trasy k tomuto uzlu pomocí floodu" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "Získat regiony z tohoto repeateru" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "Dotazování..." #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "Bez odpovědi" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "Odebrat z oblíbených" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "Zero hop advert" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "Kopírovat odkaz" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "Odkaz zkopírován" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "QR kód kontaktu" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "%d tras dostupných" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "Zatím žádné trasy" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "Chytré cesty - %s" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "Dostupné trasy" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "Skóre: %.2f" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "Váha: %.1f" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "Zatím nezaznamenány žádné trasy" #: src/views/contact_detail_dialog.py:634 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:641 msgid "Clear History" msgstr "Vymazat historii" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "Žádná data polohy nejsou k dispozici" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "Chytré cesty k %s" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "Moje zařízení" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "Přidat ručně" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "Oblíbené" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "Nedávno slyšené" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Nejnovější zprávy" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "Nejdříve oblíbené" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "{total} kontakt" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "%x" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "{total} objeveno" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} objeveno" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "Čas přijetí" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "Tento kontakt je již ve vašem seznamu." #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "Import selhal" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "Ve schránce nebyl nalezen odkaz meshcore://." #: src/views/contacts_view.py:417 #, 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:682 msgid "Searching..." msgstr "Hledání…" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "Trasa nalezena" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "Načítání…" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "Žádná uložená trasa" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "Nepodporováno" #: src/views/contacts_view.py:784 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:807 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: vypršel časový limit" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Vyžadování…" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "Bez odpovědi telemetrie (vypršel časový limit)" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "Bez odpovědi (vypršel časový limit)" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: 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:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "Telemetrie přijata" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "Trasa k {}" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "Neznámý repeater" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "Slyšená opakování" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "Odeslat znovu" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "Smazat" #: src/views/channels_view.py:186 msgid "Reply" msgstr "Odpovědět" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "Kopírovat text" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "Zobrazit trasy zprávy" #: src/views/channels_view.py:241 msgid "Send" msgstr "Odeslat" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "Odeslat bez omezení" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "Sdílet svůj kontakt" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Sdílet kontakt" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "Sdílet kanál" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Sdílet polohu" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "Sdílet polohu z mapy" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "Sdílíte kontakt:" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} s vámi sdílí kontakt:" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "Již přidán" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "Sdílel(a) jste kanál:" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "{name} sdílí kanál:" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "Již jste se připojil(a)" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "Sdílíte polohu:" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} sdílí polohu:" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "Nové zprávy" #: src/views/channels_view.py:619 #, 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:657 #, 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:1146 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:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "Zpráva od {sender} přijatá {count} trasou:" msgstr[1] "Zpráva od {sender} přijatá {count} trasami:" msgstr[2] "Zpráva od {sender} přijatá {count} trasami:" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "neznámé" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "SNR: {} dB" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "Trasa {}: {}" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "Skok {} · prefix {}" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "Přímá zpráva" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "Žádné repeatery nebyly použity" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "Žádné podrobnosti o repeateru" #: src/views/channels_view.py:1239 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:1308 msgid "Message Path" msgstr "Trasa zprávy" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "Zatím nebyla slyšena žádná opakování." #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "Veřejný" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "{visible}/{total} kanál" msgstr[1] "{visible}/{total} kanály" msgstr[2] "{visible}/{total} kanálů" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "{total} kanál" msgstr[1] "{total} kanály" msgstr[2] "{total} kanálů" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "Informace o kanálu" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "Soukromý klíč" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "Soukromý klíč zkopírován do schránky" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, 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:2119 msgid "Message Retention" msgstr "Uchovávání zpráv" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 den" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 týden" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 měsíc" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "3 měsíce" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "Napořád" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "Úroveň oznámení" #: src/views/channels_view.py:2147 msgid "Default" msgstr "Výchozí" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "Region" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "Omezovat zprávy šíření floodem na konkrétní region" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "Nejsou definovány žádné regiony" #: src/views/channels_view.py:2207 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:2226 msgid "Clear Messages" msgstr "Vymazat zprávy" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "Odebrat kanál" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "Zablokovaní" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} blokovaný" msgstr[1] "{} blokovaní" msgstr[2] "{} blokovaných" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "Odblokovat" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "Blokovat" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "Odblokovaný {}" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "Blokovat {}?" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "Blokovat a smazat zprávy" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "Zablokovaný {}" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "Žádné volné sloty" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "Všech {count} slotů kanálů se používá." #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "Již připojeno" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "Již jste na veřejném kanálu." #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "Ve schránce nenalezen žádný odkaz s kanálem." #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" "V QR kódu nenalezen žádný kanál:\n" "{}" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "CLI companiona — %s" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" "Napište příkaz a stikněte Enter. Cílový companion vám musí povolit vzdálené " "CLI." #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "CLI zařízení — %s" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "Napište příkaz a stiskněte Enter." #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "(prázdná odpověď)" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "Čekání na odpověď…" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "Veřejný klíč zařízení není dostupný" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "Obnovit tovární nastavení?" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "Resetovat" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "Restartovat zařízení?" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "Zařízení se odpojí a restartuje." #: src/views/device_view.py:319 msgid "Not set" msgstr "Nenastaveno" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "Poloha (ručně nastavená)" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "Poloha (GPS)" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "Poloha (GPS zapnutý, bez signálu)" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "Poloha (GPS vypnutý)" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "Vyžaduje se…" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "{days}d" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "{} zpráv" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "{count} chyby" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "Připojeno" #: src/views/device_view.py:498 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:520 msgid "e.g. aa,bb,cc" msgstr "např. aa,bb,cc" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "např. aabb,ccdd" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "např. aabbcc,ddeeff" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "Žádné repeatery se známou polohou" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trasování dokončeno: {} skoků, {}" #: src/views/device_view.py:681 msgid "Result" msgstr "Výsledek" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "Trasování vypršelo" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "Neplatný hex v trase" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "Trasování…" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Hledání… zbývá {}s" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "Přidat do kontaktů" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "Přidáno {}" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "Dotázat se na regiony" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "Dotázat se na název a majitele" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "Dotázat se na podrobnosti" #: src/views/device_view.py:1032 msgid "No region response" msgstr "Žádná odpověď na regiony" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "Žádná odpověď na majitele" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "Žádné informace o majiteli nenastaveny" #: src/views/device_view.py:1076 msgid "Owner" msgstr "Majitel" #: src/views/device_view.py:1105 msgid "Node" msgstr "Uzel" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "Hotovo. Nalezeno {} uzlů." #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "Hotovo. Nalezen {} uzel." #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} za poslední 2 minuty" #: src/views/device_view.py:1356 msgid "Size" msgstr "Velikost" #: src/views/device_view.py:1356 msgid "bytes" msgstr "bajtů" #: src/views/device_view.py:1361 msgid "hops" msgstr "hopů" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "Hashe trasy" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "bajt na hop" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "Dostupná aktualizace: {version}" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "Odmítnout vše" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "Povolit podle kontaktů" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "Povolit vše" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "Online kontakty smazány" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "Nastavení uloženo na zařízení" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "Neznámá chyba" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "Chyba nastavení: {}" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Vysílací výkon (dBm, max {})" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "Poloha nedostupná" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "Poloha vyplněna, klikněte na Použít pro uložení" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "Zvolit polohu" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "Nastavit polohu" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "Nejprve se připojte k zařízení" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "Objevování repeaterů v dosahu..." #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "Nalezeny {} regiony" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "Nalezen {} region" #: src/views/settings_view.py:1144 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:1148 msgid "No regions found" msgstr "Žádné regiony nenalezeny" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "Žádné repeatery nejsou v dosahu" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "od {}" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(již přidáno)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "Dotazování {} repeaterů…" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "Dotazování {} repeateru…" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "Přidány {} regiony" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "Přidán {} region" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "Nepřipojeno" #: src/views/settings_view.py:1431 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:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "Soubory JSON" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "Záloha úspěšně exportována" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "Export selhal: {}" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "Zařízení nepodporuje export klíče" #: src/views/settings_view.py:1542 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:1576 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:1591 msgid "No Auto Backups" msgstr "Žádné automatické zálohy" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "Žádné automatické zálohy nalezeny." #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "{} (současné)" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "Zobrazit pouze toto zařízení" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Nepodařilo se přečíst soubor zálohy: {}" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "Zdroj: {}" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "{} kontaktů" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "{} kanálů" #: src/views/settings_view.py:1789 msgid "messages" msgstr "zpráv" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "identifikace zařízení" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "Importováno: {}" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "Nic nového k importu" #: src/views/repeater_view.py:156 msgid "room" msgstr "místnost" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "senzor" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "repeater" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "Synchronizace času poslána do {}" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "Resetovat hodiny a restartovat?" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "Restartování…" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "Reset času a restart poslány do {}" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "Požadavek vypršel" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} z {} sousedů" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "Mapa sousedů" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "{} záznam" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "{} záznamů" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "Odebrat z ACL?" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "Odebrat {} ze seznamu řízení přístupu?" #: src/views/repeater_view.py:711 msgid "Role" msgstr "Role" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "Správce" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "Čtení-zápis" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "Pouze čtení" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "Přidat do řízení přístupu" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "Globální (wildcard)" #: src/views/repeater_view.py:842 msgid "Home" msgstr "Domácí" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "Flood povolen" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "Flood odmítán" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "Odmítat flood" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "Povolit flood" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "Nastavit jako domácí" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "Nejdříve odstranit podřízené regiony" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "Neuložené změny" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "Máte neuložené změny v regionech." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "Zahodit" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "Bez omezení" #: src/views/repeater_view.py:1213 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:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "Nastavení odesláno na {}" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "Restartovat {}?" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "Čas nesouhlasí" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "Sdílená poloha" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "Sdílená" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "Knihovna QR kódů není dostupná" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "Poloha není k dispozici" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "Váš companion nemá k dispozici polohu GPS." #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "Poslat automaticky" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "Poslat přímo" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "Poslat floodem" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "Odesílá se" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "Místnost pouze pro čtení" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} z {} kontaktů na mapě" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "{} z {} lokalizovaných na mapě" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "{} objevených" #: src/views/map_view.py:427 msgid "Any time" msgstr "Kdykoliv" #: src/views/map_view.py:427 msgid "Past month" msgstr "Poslední měsíc" #: src/views/map_view.py:427 msgid "Past day" msgstr "Poslední den" #: src/views/map_view.py:427 msgid "Past hour" msgstr "Poslední hodinu" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "a {} dalších" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "Pozice je ilustrativní - skutečné umístění neznámé" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "{} dB" #: src/views/map_view.py:876 msgid "SNR" msgstr "SNR" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "Vyberte trasovací cestu" #: src/views/map_view.py:1263 msgid "_Done" msgstr "_Hotovo" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "Trasovací cestu vytvoříte klepáním na repeatery" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "{count} uzlů importováno z online databáze" #, fuzzy #~ msgid "Companion Management" #~ msgstr "Správa roomu" #, fuzzy #~ msgid "Enter a CLI command" #~ msgstr "Příkaz CLI…" #, fuzzy #~ msgid "WiFi IP" #~ msgstr "Wi-Fi / TCP" #~ msgid "Query this contact's telemetry data" #~ msgstr "Dotázat se na data telemetrie tohoto kontaktu" #, python-format #~ msgid "%d hops" #~ msgstr "%d skoků" #~ msgid "Contact Info" #~ msgstr "Informace o kontaktu" #~ msgid "Favorite" #~ msgstr "Oblíbený" #~ msgid "Pin to top of contacts list" #~ msgstr "Připnout na začátek seznamu kontaktů" #, python-brace-format #~ msgid "{total} channels" #~ msgstr "{total} kanálů" #~ msgid "Preset Configuration" #~ msgstr "Konfigurace presetu" #~ msgid "Select a Contact" #~ msgstr "Vyberte kontakt" #~ msgid "Select a Channel" #~ msgstr "Vyberte kanál" #~ msgid "Choose a channel from the list" #~ msgstr "Vyberte kanál ze seznamu" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: {type}" #~ msgstr "" #~ "Název: {name}\n" #~ "Typ: {type}" #, python-brace-format #~ msgid "" #~ "Type: {type}\n" #~ "Key: {key}…" #~ msgstr "" #~ "Typ: {type}\n" #~ "Klíč: {key}…" #~ msgid "Import Contact" #~ msgstr "Importovat kontakt" #, python-brace-format #~ msgid "Contact {}" #~ msgstr "Kontakt {}" #~ msgid "imported" #~ msgstr "naimportován" #, python-brace-format #~ msgid "Add {} Contact" #~ msgstr "Přidat kontakt typu {}" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Public Key: {key}…" #~ msgstr "" #~ "Název: {name}\n" #~ "Veřejný klíč: {key}…" #~ msgid "Scan" #~ msgstr "Hledat" #~ msgid "Invalid channel QR code." #~ msgstr "Neplatný QR kód kanálu." #~ msgid "Invalid public key in QR code." #~ msgstr "Neplatný veřejný klíč v QR kódu." #, python-brace-format #~ msgid "Public key must be 32 bytes, got {}." #~ msgstr "Veřejný klíč musí mít 32 bajtů, obdrženo {}." #~ msgid "QR Code Scanned" #~ msgstr "QR kód naskenován" #, python-brace-format #~ msgid "Public key: {}...{}" #~ msgstr "Veřejný klíč: {}…{}" #~ msgid "Battery & Storage" #~ msgstr "Baterie a úložiště" #~ msgid "Remove Contact?" #~ msgstr "Odebrat kontakt?" #, python-brace-format #~ msgid "Remove {} and all messages?" #~ msgstr "Odebrat {} a všechny zprávy?" #~ msgid "Clear Messages?" #~ msgstr "Vymazat zprávy?" #, python-brace-format #~ msgid "Delete all messages in \"{}\"?" #~ msgstr "Smazat všechny zprávy v „{}“?" #~ msgid "Remove Channel?" #~ msgstr "Odebrat kanál?" #, python-brace-format #~ msgid "Remove \"{}\" and all its messages?" #~ msgstr "Odebrat „{}“ a všechny jeho zprávy?" #~ msgid "Pick Snapshot" #~ msgstr "Vyberte snapshot" #~ 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í" #~ msgid "Login successful" #~ msgstr "Přihlášení úspěšné" #, python-brace-format #~ msgid "Logged in to {}" #~ msgstr "Přihlásit se k {}" #~ msgid "Login failed" #~ msgstr "Přihlášení selhalo" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: Private" #~ msgstr "" #~ "Název: {name}\n" #~ "Typ: Soukromý" #~ msgid "Channel secret must be 16 bytes, got {}." #~ msgstr "Tajný klíč kanálu musí mít 16 bajtů, obdrženo {}." #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Přidat oblast (např. cz-jmk)" #~ 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.po000066400000000000000000002473301526212641700135640ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/de.po000066400000000000000000002720501526212641700135650ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-09-16 22:26+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.8.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 #, fuzzy msgid "" "Meshy is a client for MeshCore 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 the 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:16 msgid "Features:" msgstr "Funktionen:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 #, fuzzy msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "Bluetooth LE- und USB-Serial-Verbindung" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "Ende-zu-Ende-verschlüsselte Nachrichten" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "Öffentliche, private und Hashtag-Kanäle" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "Geräte- und Repeaterverwaltung" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "Repeaterverwaltung" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "Einstellungen - Dunkel" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 msgid "Map view showing contact locations" msgstr "" #: data/gtk/help-overlay.ui:10 msgid "General" msgstr "Allgemein" #: data/gtk/help-overlay.ui:13 data/ui/window.ui:11 msgid "Disconnect" msgstr "Trennen" #: data/gtk/help-overlay.ui:19 data/ui/window.ui:31 data/ui/window.ui:52 msgid "Quit" msgstr "Beenden" #: data/gtk/help-overlay.ui:25 data/ui/window.ui:23 data/ui/window.ui:44 msgid "Help" msgstr "Hilfe" #: data/gtk/help-overlay.ui:31 data/ui/window.ui:19 data/ui/window.ui:40 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" #: data/gtk/help-overlay.ui:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "Navigation" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "Gerät" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "Kontakte" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "Kanäle" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "Karte" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "Einstellungen" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "Kontakte durchsuchen" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "Über Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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 "Verbinde…" #: data/ui/window.ui:307 msgid "Show navigation" msgstr "" #: data/ui/window.ui:328 src/connection_controller.py:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "Nicht verbunden" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Status" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "Verbindung getrennt" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:102 msgid "Update" msgstr "Aktualisieren" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "Öffentlicher Schlüssel" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "In die Zwischenablage kopieren" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "Telemetrie" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "Telemetriedaten anfordern" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "Standort" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "Synchronisierung…" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "Auf Karte anzeigen" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "Akku" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "Speicher" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "Frequenz" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "Bandbreite" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "Spreading-Faktor" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "Codierungsrate" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "Sendeleistung" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "Statistik" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "Grundrauschen" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 #, fuzzy msgid "Show live graph" msgstr "Auf Karte anzeigen" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Pakete" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "Direkt" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Laufzeit" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "Statistik aktualisieren" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 msgid "Application" msgstr "Programm" #: 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 "Stil" #: data/ui/settings-view.ui:21 msgid "Choose the appearance of the application" msgstr "" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Systemvorgabe" #: data/ui/settings-view.ui:26 msgid "Light" msgstr "Hell" #: data/ui/settings-view.ui:27 msgid "Dark" msgstr "Dunkel" #: data/ui/settings-view.ui:28 data/ui/settings-view.ui:36 msgid "Palette Theme" msgstr "Designpalette" #: data/ui/settings-view.ui:37 msgid "MeshCore Dark" msgstr "MeshCore Dunkel" #: data/ui/settings-view.ui:49 msgid "Channel Notifications" msgstr "Kanal-Benachrichtigungen" #: data/ui/settings-view.ui:50 msgid "Default notification level for all channels" msgstr "Standard-Benachrichtigungsstufe für alle Kanäle" #: data/ui/settings-view.ui:54 src/views/channels_view.py:2147 msgid "All Messages" msgstr "Alle Nachrichten" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "Nur Erwähnungen" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 msgid "None" msgstr "Keine" #: data/ui/settings-view.ui:64 msgid "Run in Background" msgstr "Im Hintergrund ausführen" #: data/ui/settings-view.ui:65 msgid "Keep receiving messages after closing the window" msgstr "" #: 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 "Beim Login automatisch starten" #: data/ui/settings-view.ui:81 msgid "LoRa radio parameters and regional presets" msgstr "LoRa Funk-Parameter und regionale Voreinstellungen" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Regionale Voreinstellungen" #: data/ui/settings-view.ui:89 msgid "Radio Configuration" msgstr "Funk Konfiguration" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "Frequenz, Brandbreite, Spreading-Faktor, Codierungsrate" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:723 msgid "Frequency (MHz)" msgstr "Frequenz (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "Bandbreite (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 msgid "Repeat Mode" msgstr "Repeat-Modus" #: 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:757 msgid "TX Power (dBm)" msgstr "Sendeleistung (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 msgid "Advert" msgstr "Advert" #: 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 "Gerätename" #: data/ui/settings-view.ui:164 msgid "Include Location in Advert" msgstr "Position zu Advert hinzufügen" #: data/ui/settings-view.ui:165 msgid "Broadcast your position to other nodes" msgstr "Standort an andere Knoten senden" #: data/ui/settings-view.ui:174 src/views/settings_view.py:139 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 "Standard-Routing" #: 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:160 msgid "Classic" msgstr "Klassisch" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 msgid "Smart" msgstr "Clever" #: 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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 "Position manuell festlegen" #: 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:678 msgid "Latitude" msgstr "Breitengrad" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "Längengrad" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "Auf Karte auswählen" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "Eine Position auf der Karte auswählen" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Kontakte automatisch hinzufügen" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "Repeater" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "Room-Server" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "Sensoren" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Hop Limit" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "Online Node Datenbank" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "Knoten-Informationen von map.meshcore.io herunterladen" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "Telemetrie Privatsphäre" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "Batterie & Sensoren" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Batterie, Spannung, Temperatur, Stromstärke" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "GPS Koordinaten" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Umgebung" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Luftfeuchtigkeit, Druck, Luftqualität" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "Regionen" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "Region hinzufügen…" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "Entdecke Regionen von Repeatern" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "Region hinzufügen" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Standard Scope" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 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:466 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 "WLAN / TCP" #: data/ui/connection-view.ui:84 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "Companion per TCP hinzufügen" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Suchen..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "Keine Kontakte" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Kontakt hinzufügen" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "Noch keine Kontakte" #: data/ui/chat-view.ui:23 msgid "Add contacts to start chatting" msgstr "" #: data/ui/chat-view.ui:85 data/ui/channel-chat-widget.ui:85 msgid "↑ New Messages" msgstr "↑ Neue Nachrichten" #: data/ui/chat-view.ui:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "Teilen" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "Nachricht verfassen..." #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "Privaten Kanal erstellen" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "Privaten Kanal beitreten" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Öffentlichen Kanal beitreten" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "Hashtag-Kanal beitreten" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "Von der Zwischenablage importieren" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "QR Code scannen" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "Keine Kanäle" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Kanal hinzufügen" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "Noch keine Kanäle" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 msgid "Message channel..." msgstr "" #: data/ui/repeater-view.ui:37 msgid "System" msgstr "System" #: data/ui/repeater-view.ui:40 msgid "Clock at Login" msgstr "" #: data/ui/repeater-view.ui:54 msgid "Sync Now" msgstr "Jetzt synchronisieren" #: data/ui/repeater-view.ui:64 src/views/repeater_view.py:329 msgid "Reset & Reboot" msgstr "Zurücksetzen & Neustart" #: 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 "Gesendet" #: data/ui/repeater-view.ui:162 msgid "Received" msgstr "Empfangen" #: data/ui/repeater-view.ui:168 msgid "Receive Errors" msgstr "" #: data/ui/repeater-view.ui:187 msgid "Duplicates" msgstr "" #: data/ui/repeater-view.ui:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "Nachbarn" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "Auf Karte ansehen" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "Zugriffskontrolle" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "Hinzufügen" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "Lade Regionen..." #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "Wiederholen" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "Standard-Region" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "Anwenden" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "Speichern" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "Name" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "Gastzugang" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "Passwörter" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "Verwaltungspasswort" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "Gastpasswort" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "Advert" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "Lokal Interval (min)" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "Flood Interval (Std)" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 #, fuzzy msgid "Default Region (Scope)" msgstr "Standard-Region" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "Gerät neustarten" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 msgid "Log Out" msgstr "Ausloggen" #: 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 "Advert senden" #: 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 src/views/contact_detail_dialog.py:382 msgid "Show QR Code" msgstr "QR Code anzeigen" #: 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:824 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 "Empfangsprotokoll" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "" #: data/ui/device-actions.ui:121 #, fuzzy msgid "Device CLI" msgstr "Gerät" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 msgid "Factory Reset" msgstr "Auf Werkseinstellungen zurücksetzen" #: 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Geräteeinstellungen" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "Kontaktliste" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "Entdeckte Kontakte" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "Nachrichten" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "Kontakte entdecken" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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 "Sortierung" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Add to favorites" msgstr "Favoriten" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 #, fuzzy msgid "Remote CLI" msgstr "Entfernen" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "Kanaltyp" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "Neustart" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 #, fuzzy msgid "Radio settings" msgstr "Regionale Voreinstellungen" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "CLI Befehl..." #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "_Speichern" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 #, fuzzy msgid "New Companion" msgstr "Keine gespeicherten Companions" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 #, fuzzy msgid "Radio Preset" msgstr "Regionale Voreinstellungen" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "Pfad" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "Eine Route auf der Karte auswählen" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "Kontakttyp" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "Kanal beitreten" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "Privater Kanal" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "Hashtag-Kanal" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "_Beitreten" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 msgid "Private Key (32 hex characters)" msgstr "" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "Geräteposition" #: data/ui/map-sidebar-filters.ui:17 src/views/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "Sensor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 msgid "PIN" msgstr "" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "Dein Kontakt QR Code" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "" #: data/ui/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "Öffentlichen Schlüssel kopieren" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Region hinzufügen" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "Passwort" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Passwort speichern" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "Einloggen" #: data/ui/tcp-add-content.ui:24 #, fuzzy msgid "C_onnect" msgstr "Verbinden" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "Hostname / IP Adresse" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "Ändere Adresse" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "Lösche alle Kontakte von der Onlinedatenbank" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "Deaktivieren" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "Ungültiger meshcore:// Link" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "Ungültige Länge des öffentlichen Schlüssels" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "{} ist bereits in deiner Liste" #: src/application.py:146 msgid "Contact" msgstr "Kontakt" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "Kontakt {} hinzugefügt" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "Empfange Nachrichten im Hintergrund" #: src/application.py:388 #, fuzzy msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" "gallegonovato — Spanisch\n" "lebeno — Französisch, Dänisch\n" "bjawebos — Deutsch\n" "Manuel Traut — Deutsch\n" "lejun — Französisch\n" "LievenBlancke — Dänisch\n" "sesivany — Tschechisch" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 #, fuzzy msgid "Set Up Companion" msgstr "Companion per TCP hinzufügen" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 #, fuzzy msgid "New companion set up" msgstr "Keine gespeicherten Companions" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "Verbinde..." #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Wiederverbinden ({current}/{total})..." #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" "Gerät hat nicht geantwortet. Es unterstützt möglicherweise den gewählten " "Verbindungstyp nicht." #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} Nachricht" msgstr[1] "{} Nachrichten" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} Kanalnachricht" msgstr[1] "{} Kanalnachrichten" #: src/connection_controller.py:681 msgid " and " msgstr " und " #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "Kontakte synchronisieren" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "Gerätefehler: {}" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "Kanal {}" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d Hop" msgstr[1] "%d Hops" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "Kamerafehler: {}" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "OK" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, fuzzy, python-brace-format msgid "{} mi" msgstr "vor {} Minute" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "{d}d" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "{h}h" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "{m}m" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "{secs}s" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "vor {} Sekunde" msgstr[1] "vor {} Sekunden" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "vor {} Minute" msgstr[1] "vor {} Minuten" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "vor {} Stunde" msgstr[1] "vor {} Stunden" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "vor {} Tag" msgstr[1] "vor {} Tagen" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" "Nutze {z}, {x}, {y} als Platzhalter.\n" "Beispiel: https://tile.openstreetmap.org/{z}/{x}/{y}.png" #: src/window.py:567 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" "URL zu einer MapLibre/Mapbox style JSON Datei.\n" "Beispiel: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" #: src/window.py:573 msgid "Style JSON URL" msgstr "Style JSON URL" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "Aktionen" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "Geräteinformation" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "Chat" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:1031 msgid "Management" msgstr "Verwaltung" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "Advert gesendet ({})" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 #, fuzzy msgid "Could not share contact" msgstr "Kontakt teilen" #: src/window.py:1764 #, fuzzy msgid "Contact List Full" msgstr "Kontaktliste" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "{} entfernt" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "Kanal \"{}\" ist bereits vorhanden" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Kanal \"{}\" hinzugefügt" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "Nachrichten in \"{}\" gelöscht" #: src/views/__init__.py:66 msgid "Today" msgstr "Heute" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "Gestern" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "%A, %-d. %B %Y" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "Unbekannt" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "{total} Kontakte" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} Kontakte" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "Alle" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "Login zu {}" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "Anmeldung fehlgeschlagen — falsches Passwort" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Hashtag" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "Privat" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 msgid "Remove" msgstr "Entfernen" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "Bluetooth ist nicht verfügbar" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:96 msgid "Bluetooth is disabled" msgstr "Bluetooth ist ausgeschaltet" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" "Aktiviere Bluetooth in den Systemeinstellungen um eine Verbindung " "herzustellen" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "Keine USB-Geräte gefunden" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "Verbinde einen MeshCore-Companion per USB" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "Keine gespeicherten Companions" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "Raumverwaltung" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "Repeaterverwaltung" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "Sensorverwaltung" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 #, fuzzy msgid "Zero Hop Advert" msgstr "Advert senden" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 #, fuzzy msgid "Contact QR Code" msgstr "Dein Kontakt QR Code" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "%d Pfade verfügbar" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "Verfügbare Pfade" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "Ergebnis: %.2f" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "Gewicht: %.1f" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "Keine Positionsdaten verfügbar" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "Routen zu %s" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "Mein Gerät" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "Favoriten" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Letzte Nachrichten" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "{total} Kontakt" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "%x" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "{total} entdeckt" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} entdeckt" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "Kein meshcore:// Link in der Zwischenablage gefunden." #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "Pfad gefunden" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "Laden..." #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "Nicht unterstützt" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: Timeout" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 #, fuzzy msgid "No telemetry response (timed out)" msgstr "Keine Antwort (Timeout)" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "Keine Antwort (Timeout)" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Keine Telemetrie Daten" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "Telemetrie empfangen" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "Pfad zu {}" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "Empfangene Repeats" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "Senden wiederholen" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "Löschen" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "Text kopieren" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "Senden" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "Senden ohne Region" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "Meinen Kontakt teilen" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Kontakt teilen" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "Einen Kanal teilen" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Position teilen" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "Position von der Karte teilen" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "Bereits beigetreten" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "Neue Nachrichten" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d Wiederholung" msgstr[1] "· %d Wiederholungen" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 #, fuzzy msgid "unknown" msgstr "Unbekannt" #: src/views/channels_view.py:1193 #, fuzzy, python-brace-format msgid "SNR: {} dB" msgstr "{} dB" #: src/views/channels_view.py:1194 #, fuzzy, python-brace-format msgid "Path {}: {}" msgstr "Pfad zu {}" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "Noch keine Repeats empfangen." #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "{visible}/{total} Kanal" msgstr[1] "{visible}/{total} Kanäle" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "{total} Kanal" msgstr[1] "{total} Kanäle" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "Kanalinformationen" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "Privater Schlüssel" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 Tag" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 Woche" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 Monat" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "3 Monate" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "Standard" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "Region" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "Keine Regionen definiert" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "Nachrichten löschen" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "Blockiert" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} blockiert" msgstr[1] "{} blockiert" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "Blockieren" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "{} blockieren?" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "Blockiere und lösche Nachrichten" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "{} blockiert" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "Kein Kanal Link in der Zwischenablage gefunden." #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 #, fuzzy msgid "Waiting for response..." msgstr "Lade Regionen..." #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "Position (manuell festgelegt)" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "Position (GPS)" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "Position (GPS an, kein Signal)" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "Position (GPS aus)" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "{days}d" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "{} Nachrichten" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "{count} Fehler" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "Verbunden" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "z.B. aa,bb,cc" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "z.B. aabb,ccdd" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "z.B. aabbcc,ddeeff" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "Ergebnis" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "{} hinzugefügt" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "Regionen abfragen" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "Name und Besitzinformationen abfragen" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "Keine Regionen gefunden" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "Keine Besitzinformationen vorhanden" #: src/views/device_view.py:1076 msgid "Owner" msgstr "Besitzinformation" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "Fertig. {} Nodes gefunden." #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "Fertig. {} Node gefunden." #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} in den letzten 2 Minuten" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "Hops" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "Update verfügbar: {version}" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "Verbiete alle" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "Erlaube alle" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "Unbekannter Fehler" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "Konfigurationsfehler: {}" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Sendeleistung (dBm, max {})" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "Position nicht verfügbar" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "Position auswählen" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "Position setzen" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "{} Regionen gefunden" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "{} Region gefunden" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(bereits hinzugefügt)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "{} Regionen hinzugefügt" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "Quelle: {}" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "{} Kontakte" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "{} Kanäle" #: src/views/settings_view.py:1789 msgid "messages" msgstr "Nachrichten" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "Importiert: {}" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "Raum" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "Sensor" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "Uhrzeit zurücksetzen und neustarten?" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "Neustarten..." #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} von {} Nachbarn" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "Verwaltung" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "Flood erlaubt" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "Flood abgelehnt" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "Flood ablehnen" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "Flood erlauben" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "Entferne zuerst untergeordnete Regionen" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "Nicht gespeicherte Änderungen" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "Es gibt nicht gespeicherte Region Änderungen." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "Verwerfen" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 #, fuzzy msgid "Unscoped" msgstr "Senden ohne Region" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "Neustart {}?" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "Position geteilt" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "Geteilt" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "Position nicht verfügbar" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "Dein Gerät hat keine GPS Position." #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} von {} Kontakten auf der Karte" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "{} entdeckt" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "und {} mehr" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "{} dB" #: src/views/map_view.py:876 msgid "SNR" msgstr "SNR" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" #, fuzzy #~ msgid "Companion Management" #~ msgstr "Raumverwaltung" #, fuzzy #~ msgid "Enter a CLI command" #~ msgstr "CLI Befehl..." #, fuzzy #~ msgid "WiFi IP" #~ msgstr "WLAN / TCP" #, python-format #~ msgid "%d hops" #~ msgstr "%d Hops" #, python-brace-format #~ msgid "{total} channels" #~ msgstr "{total} Kanäle" #~ msgid "Select a Contact" #~ msgstr "Einen Kontakt auswählen" #~ msgid "Select a Channel" #~ msgstr "Einen Kanal auswählen" #~ msgid "Import Contact" #~ msgstr "Kontakt importieren" #, python-brace-format #~ msgid "Contact {}" #~ msgstr "Kontakt {}" #~ msgid "imported" #~ msgstr "importiert" #, python-brace-format #~ msgid "Add {} Contact" #~ msgstr "Kontakt {} hinzufügen" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Public Key: {key}…" #~ msgstr "" #~ "Name: {name}\n" #~ "Öffentlicher Schlüssel: {key}…" #~ msgid "Invalid channel QR code." #~ msgstr "Ungültiger Kanal QR Code." #~ msgid "Invalid public key in QR code." #~ msgstr "Ungültiger öffentlicher Schlüssel im QR Code." #, python-brace-format #~ msgid "Public key must be 32 bytes, got {}." #~ msgstr "Der öffentliche Schlüssel muss 32 Bytes umfassen, {} erhalten." #~ msgid "QR Code Scanned" #~ msgstr "QR Code eingescannt" #, python-brace-format #~ msgid "Public key: {}...{}" #~ msgstr "Öffentlicher Schlüssel: {}...{}" #, python-brace-format #~ msgid "Remove {} and all messages?" #~ msgstr "Entferne {} und alle Nachrichten?" #, python-brace-format #~ msgid "Delete all messages in \"{}\"?" #~ msgstr "Lösche alle Nachrichten in \"{}\"?" #, fuzzy #~ msgid "New features:" #~ msgstr "Funktionen:" meshy/po/es.po000066400000000000000000003626351526212641700136150ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-09-28 20:27+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.9.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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" "Meshy es un cliente para dispositivos de red en malla LoRa MeshCore. " "Conéctese a su dispositivo companion mediante Bluetooth, USB o TCP para " "enviar y recibir mensajes cifrados, gestionar contactos, configurar canales " "y monitorear la red en malla." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "Características:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "Conectividad Bluetooth LE, USB serie, TCP/WiFi" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "Mensajería cifrada de extremo a extremo" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "Canales públicos, privados y de hashtags" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 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:22 msgid "Interactive map view with route tracing" msgstr "Vista de mapa interactiva con trazado de rutas" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "Gestión de dispositivos y repetidores" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 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:29 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "Canales de chat" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "Gestión de repetidores" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "Ajustes - Tema oscuro" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "Activar/desactivar pantalla completa" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "Exploración" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "Dispositivo" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "Contactos" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "Canales" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "Mapa" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "Ajustes" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "Mensajería" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "Buscar contactos" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "Entrada de mensajes destacados" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "Contacto/Canal anterior" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "Contacto/canal siguiente" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "Anteriores sin leer" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "No conectado/-a" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Estado" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "Desconectado/-a" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "Nombre del nodo" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "Teclado" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:102 msgid "Update" msgstr "Actualización" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "Abrir el programador de firmware" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "Clave pública" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "Copiar al portapapeles" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "Telemetría" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "Solicitar datos de telemetría" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "Ubicación" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "Sincronizando…" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "Ver en el mapa" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "Batería" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "Almacenamiento" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "Radio" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "Frecuencia" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "Ancho de banda" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "Factor de ensanchamiento" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "Tasa de codificación" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "Potencia de transmisión" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Tamaño de hash de ruta predeterminado" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "Estadísticas" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "Ruido de fondo" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "Mostrar gráfico en tiempo real" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "RSSI más reciente" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "SNR más reciente" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "Tiempo de transmisión" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Paquetes" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "Flood (inundación)" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "Directo" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Tiempo de actividad" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Cola de mensajes" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "Actualizar" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "Actualizar estadísticas" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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 el aspecto de la aplicación" #: data/ui/settings-view.ui:25 msgid "Follow System" msgstr "Usar ajustes del 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:2147 msgid "All Messages" msgstr "Todos los mensajes" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "Solo menciones" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio Configuration" msgstr "Ajustar frecuencias" #: 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:723 msgid "Frequency (MHz)" msgstr "Frecuencia (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "Ancho de banda (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "Potencia de transmisión (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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 red en 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:139 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 del enrutamiento y la 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:160 msgid "Classic" msgstr "Clásico" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "1 byte (máximo 64 saltos)" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "2 bytes (máximo 32 saltos)" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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 de mensaje privado" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "Número de ACKs enviados al recibir un mensaje directo" #: 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 del acompañante" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "" "Activa el GPS en el dispositivo complementario; tu posición se actualizará " "automáticamente" #: data/ui/settings-view.ui:250 msgid "Set Location Manually" msgstr "Establecer la ubicación manualmente" #: data/ui/settings-view.ui:251 msgid "Choose another method to set the location, it will be fixed" msgstr "" "Elige otro método para establecer la ubicación; así se solucionará el " "problema" #: data/ui/settings-view.ui:254 data/ui/repeater-view.ui:678 msgid "Latitude" msgstr "Latitud" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 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 device's location" msgstr "Usar la ubicación de este dispositivo" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 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:313 msgid "Contact discovery and automatic adding" msgstr "Detección de contactos y guardado automático" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Añadir contactos automáticamente" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "Añadir automáticamente los nodos descubiertos a través de anuncios" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "Añadir ajustes automáticamente" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "Tipos de dispositivos, política de sobrescritura, límite de saltos" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "Nodos de chat" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "Repetidores" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "Servidores de salas" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "Sensores" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "Sobrescribir los archivos más antiguos cuando se llenen" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Límite de saltos" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0–63, dejar vacío para no tener límite" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "Nodos en línea" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "Descargar información de los nodos desde map.meshcore.io" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "Privacidad de la telemetría" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" "Controla qué información pueden consultar otros usuarios en tu dispositivo" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "Batería y sensores" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Batería, voltaje, temperatura, corriente" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "Coordenadas GPS" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Ambiente" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Humedad, presión, calidad del aire" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "Regiones" #: data/ui/settings-view.ui:404 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:428 msgid "Add region…" msgstr "Añadir una región…" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "Obtener las regiones de estos repetidores" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "Añadir una región" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Ámbito predeterminado" #: data/ui/settings-view.ui:452 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:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 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 y, a continuación, " "desvincula y vuelve a vincular el dispositivo. El ajuste 0 restablece el PIN " "a su valor predeterminado." #: data/ui/settings-view.ui:466 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 "Vincular con un nuevo compañero" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "Puerto serie USB" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "WiFi / TCP" #: data/ui/connection-view.ui:84 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "Añadir un complemento TCP" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Buscar…" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "Sin contactos" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Añadir contacto" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "Aún no hay contactos" #: data/ui/chat-view.ui:23 msgid "Add contacts to start chatting" msgstr "Añade contactos para comenzar 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "Compartir" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" "Los mensajes con esta longitud pueden no registrar la información de " "retransmisión y trayectoria en algunos dispositivos receptores." #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "Escribe un mensaje..." #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "Insertar emoji" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "Crear un canal privado" #: data/ui/channels-view.ui:14 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 msgid "Join a Hashtag Channel" msgstr "Únete a un canal de hashtags" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "Importar desde el portapapeles" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "Escanear el código QR" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "No hay canales" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Añadir canal" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "Aún no hay canales" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "Añade canales para comenzar a chatear" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "Solicitar telemetría" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "Vecinos" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "Cargar más" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "Mostrar en el mapa" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "Control de acceso" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "Añadir" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "Cargando regiones ..." #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "Reintentar" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "Ámbito regional predeterminado" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "Aplicar" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "Básico" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "Traer" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "Guardar" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "Nombre" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "Repetición de paquetes" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "Acceso de invitados" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "Permitir el acceso de invitados de solo lectura sin contraseña" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "Contraseñas" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "Contraseña de administrador" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "Contraseña de invitado" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "Anuncio" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "Intervalo local (min)" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "Intervalo de inundación (horas)" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "Red" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "Tamaño del hash de la ruta" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "Tamaño de los identificadores de ruta que anuncia este nodo" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "1 byte" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "2 bytes" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "3 bytes" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "Región por defecto (Ámbito)" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" "Los paquetes de difusión se limitan a esta región, salvo que se especifique " "lo contrario" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "Zona de peligro" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "Reiniciar dispositivo" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "Selecciona un dispositivo" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "Interfaz de línea de comandos del dispositivo" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" msgstr "Ejecuta comandos CLI en este dispositivo" #: 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "_Cancelar" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "_Exportar" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "Identidad del dispositivo" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Ajustes del dispositivo" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "Configuración local" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "Lista de contactos" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "Contactos encontrados" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "Mensajes y enrutamiento" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "Mensajes" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" msgstr "Historial de rutas inteligentes" #: 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "_Restaurar" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "Restaurar desde una copia de seguridad automática" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "Ver todos los dispositivos" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "Descubre tus contactos" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Elegir el tema" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "Añadir a favoritos" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "Línea de comandos remota" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "Tipo" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "Última conexión" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "Enrutamiento" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "Ruta de salida" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "Restablecer ruta" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "Despeja la ruta marcada y cambia al modo de inundación" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "Rutas disponibles" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "Restablecer la ruta automáticamente" #: data/ui/contact-detail-dialog.ui:213 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:219 msgid "Force Flood" msgstr "Inundación forzada" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "Transmitir siempre por difusión, nunca usar una ruta establecida" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "Permisos" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "Eliminar contacto" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "Reiniciar" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "Apagar" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "Obtener el nombre" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "Ajustes de radio" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "Versión" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "Comando cli ..." #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "Renombrar" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "_Guardar" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "Nombre original" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "Nombre personalizado" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "Nuevo dispositivo asociado" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "_Reenviar" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" "Este dispositivo asociado parece recién flasheado y aún no está configurado. " "Elige cómo deseas continuar." #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "Configurar como nuevo dispositivo vinculado" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "Elige un nombre de nodo y un perfil de radio" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "Restaurar desde una copia de seguridad" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "Importar un archivo de copia de seguridad de Meshy o MeshCore" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" "Restaurar una de las copias de seguridad diarias guardadas en este equipo" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" "Elige un nombre para tu dispositivo asociado y el perfil de radio de la red " "de tu zona." #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "Preajuste de radio" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "_Rastrear" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "Ruta" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "Seleccione la ruta en el mapa" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "Introduce los hashes de las rutas y pulsa «Ejecutar rastreo»" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "_Añadir" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "Introduce los datos de contacto." #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "Tipo de contacto" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "Clave pública (64 caracteres hexadecimales)" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "Únete al canal" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "Abrir en la aplicación de mapas" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "Participantes" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "Filtrar" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "Canal privado" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "C_rear" #: data/ui/create-private-channel-content.ui:43 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." #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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 "Esperando la respuesta de 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "Añadir seleccionados" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "Canal con hashtag" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "_Unirse" #: data/ui/join-hashtag-channel-content.ui:43 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." #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (p. ej. #general )" #: data/ui/join-private-channel-content.ui:43 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." #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "Usuarios" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Salas" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "Nodos detectados" #: data/ui/map-view.ui:30 msgid "User" msgstr "Usuario" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "Repetidor" #: data/ui/map-view.ui:62 msgid "Room" msgstr "Sala" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "Sensor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 "Vincular nuevo dispositivo" #: data/ui/pair-dialog.ui:41 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "Buscando dispositivos MeshCore..." #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "Introduce el pin de emparejamiento" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "_Emparejar" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "Introduce el pin que aparece en tu dispositivo MeshCore." #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "Copiar la clave pública" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "Cambiar de cámara" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "Enfoca el código QR con la cámara..." #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Añadir una región" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "Introduce un nombre y, si lo deseas, selecciona una región principal." #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "Nombre de la región" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "Principal" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "Buscar dispositivos" #: data/ui/room-login-dialog.ui:33 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 msgid "Login" msgstr "Iniciar sesión" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "C_onectar" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "Introduce la dirección IP y el puerto de tu dispositivo MeshCore." #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "Nombre de host / Dirección IP" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "Puerto" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "Cambiar la dirección" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "Ingrese la nueva dirección para este acompañante." #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "Deshacer" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "Limpiar" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "Nodos en línea" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" "La información de los nodos se descargará desde map.meshcore.io (~15 MB). " "Solo se importarán a los contactos detectados aquellos nodos que coincidan " "con la preselección de radio de tu dispositivo y que se hayan actualizado en " "el mapa en los últimos 3 meses." #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "Actualizar automáticamente cada día" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "Cancelar" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "Descargar" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "¿Desactivar la base de datos de nodos en línea?" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "Se detendrán las actualizaciones de nodos en línea." #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "Eliminar todos los contactos de la base de datos en línea" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "Desactivar" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "Enlace meshcore:// no válido" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "Secreto de canal no válido en el enlace" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "Longitud de secreto de canal no válida" #: src/application.py:122 msgid "Invalid public key in link" msgstr "Clave pública no válida en el enlace" #: src/application.py:123 msgid "Invalid public key length" msgstr "Longitud de clave pública no válida" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "Enlace «meshcore://» no reconocido" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "{} ya aparece en tu lista" #: src/application.py:146 msgid "Contact" msgstr "Contacto" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "Contacto {} añadido" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "Recibir mensajes en segundo plano" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" "gallegonovato — Español\n" "lebeno — Francés, Neerlandés\n" "bjawebos — Alemán\n" "Manuel Traut — Alemán\n" "bobidle — Alemán\n" "lejun — Francés\n" "LievenBlancke — Neerlandés\n" "Jiri Eischmann — Checo\n" "Sanyanov — Ruso\n" "Priit Jõerüüt — Estonio" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "Cliente GTK para dispositivos de red mallada MeshCore LoRa" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "Iconos simbólicos de Adwaita" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "Personalizado" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "Configurar dispositivo asociado" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "_Volver" #: src/companion_setup.py:194 msgid "_Finish" msgstr "_Terminar" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "Configuración de nuevo dispositivo asociado" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "Conectando…" #: src/connection_controller.py:288 msgid "Stop" msgstr "Detener" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Reconectando ({current}/{total})…" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "Sincronizando…" #: src/connection_controller.py:347 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:593 msgid "Syncing channels" msgstr "Sincronizando canales" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} mensaje" msgstr[1] "{} mensajes" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} mensaje del canal" msgstr[1] "{} mensajes del canal" #: src/connection_controller.py:681 msgid " and " msgstr " y " #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "Recibiste {} mientras estaba desconectado." #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "Sincronizando contactos" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "Error del dispositivo: {}" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "{} (tarde)" #: src/frame_handler.py:752 msgid "late" msgstr "tarde" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "El reloj del dispositivo se retrasaba {} minutos — sincronizado" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "El reloj del dispositivo se adelanta {} minutos al del sistema" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "Canal {}" #: src/frame_handler.py:1406 msgid "Someone" msgstr "Alguien" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "A mí" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "flood (inundación)" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "privado" #: src/message_controller.py:234 msgid "path" msgstr "ruta" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "en espera {} (radio ocupada)" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d salto" msgstr[1] "%d saltos" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "Código PIN incorrecto. Por favor, inténtelo de nuevo" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "Emparejamiento cancelado" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "Error en el emparejamiento: no se ha podido establecer la conexión" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" msgstr "El emparejamiento ha fallado" #: 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:378 msgid "Missing GStreamer plugin." msgstr "Falta un complemento de GStreamer." #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Error en el proceso de la cámara: {}" #: src/qr_scanner.py:469 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:480 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:486 msgid "QR code found!" msgstr "¡Encontrado un código QR!" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "Error de la cámara: {}" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "Error de la cámara" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "De acuerdo" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "{} m" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "{} km" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "{} ft" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "{} mi" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "{d}día(s)" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "{h}hora(s)" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "{m}minuto(s)" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "{secs}segundo(s)" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "hace {} segundo" msgstr[1] "hace {} segundos" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "Hace {} minuto" msgstr[1] "Hace {} minutos" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "Hace {} hora" msgstr[1] "Hace {} horas" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "Hace {} día" msgstr[1] "Hace {} días" #: src/window.py:536 msgid "Fullscreen off" msgstr "Desactivar pantalla completa" #: src/window.py:540 msgid "Fullscreen on" msgstr "Activar pantalla completa" #: src/window.py:555 msgid "Raster" msgstr "Ráster (o imagen rasterizada)" #: src/window.py:555 msgid "Vector" msgstr "Vector" #: src/window.py:563 #, 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:567 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:573 msgid "Style JSON URL" msgstr "Enlace del estilo JSON" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "Mosaicos de mapas personalizados aplicados" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "No se han podido cargar los mosaicos del mapa personalizado" #: src/window.py:637 msgid "Sort" msgstr "Clasificar" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "Comportamiento" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "Información del dispositivo" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "Chat" #: src/window.py:733 msgid "Channel" msgstr "Canal" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "Copia de seguridad y restauración" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:1031 msgid "Management" msgstr "Dirección" #: src/window.py:1031 msgid "Guest" msgstr "Invitado" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1166 msgid "Excellent" msgstr "Excelente" #: src/window.py:1167 msgid "Good" msgstr "Bien" #: src/window.py:1168 msgid "Fair" msgstr "Bueno" #: src/window.py:1169 msgid "Poor" msgstr "Pobre" #: src/window.py:1170 msgid "Very poor" msgstr "Muy pobre" #: src/window.py:1432 msgid "flood routed" msgstr "enrutamiento" #: src/window.py:1432 msgid "zero-hop" msgstr "sin salto" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "Anuncio enviado ( {} )" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "Compartiendo {}" #: src/window.py:1439 msgid "Could not share contact" msgstr "No se pudo compartir el contacto" #: src/window.py:1764 msgid "Contact List Full" msgstr "Lista de contactos completa" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" "La lista de contactos de tu dispositivo está llena. Elimina los contactos " "que no uses para hacer espacio para nuevos." #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "{} eliminado" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "El canal «{}» ya está añadido" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "No hay canales libres disponibles" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Canal «{}» añadido" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "Mensajes en '{}' borrados" #: src/views/__init__.py:66 msgid "Today" msgstr "Hoy" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "Ayer" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "%A, %-d de %B de %Y" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "Desconocido" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "{total} contactos" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contactos" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "Todo" #: src/views/__init__.py:656 msgid "Distance" msgstr "Distancia" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "Iniciar sesión en {}" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "Error al iniciar sesión: contraseña incorrecta" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "Se ha agotado el tiempo de espera de la sesión" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "Volviendo a intentarlo mediante inundación..." #: src/views/__init__.py:860 msgid "Logging in..." msgstr "Iniciando sesión..." #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "Sala de servidores" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "Clave pública copiada" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "Sin nombre" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Hashtag" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "Privado" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "Bluetooth desactivado" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "Activa el Bluetooth en los ajustes del sistema para conectarte" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "No hay dispositivos emparejados" #: src/views/connection_view.py:110 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:153 msgid "No USB devices detected" msgstr "Sin dispositivos USB" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "Conéctese al dispositivo complementario MeshCore mediante USB" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "Eliminar compañero" #: src/views/connection_view.py:184 #, 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:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "Borrar los datos almacenados" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "Error en la conexión usb" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "Acceso al usb denegado" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "Instalar regla" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "Cerrar" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "Copiar el comando" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "Comando copiado al portapapeles" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "Error en la instalación" #: src/views/connection_view.py:349 #, 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:385 msgid "No saved companions" msgstr "No hay compañeros guardados" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "Utiliza el botón inferior para añadir uno" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "¿Eliminar el complemento TCP {}?" #: src/views/connection_view.py:590 msgid "Pair" msgstr "Par" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "Nunca" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "Mostrar ruta en el mapa" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "Visualiza la ruta del mensaje en el mapa" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "Permitir solicitudes de telemetría" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" "Permitir que este contacto consulte el estado de la batería, el voltaje y la " "temperatura" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "Incluir ubicación" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "Incluir las coordenadas GPS en la telemetría" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "Incluir sensores ambientales" #: src/views/contact_detail_dialog.py:146 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:156 msgid "Allow Remote CLI" msgstr "Permitir línea de comandos remota" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "Permitir a este contacto ejecutar comandos CLI en este dispositivo" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "Gestionar la sala" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "Gestión de repetidores" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "Gestión de sensores" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "Iniciar sesión y acceder a la CLI, ajustes y regiones" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "Hacer ping a este nodo" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "Descubre las rutas" #: src/views/contact_detail_dialog.py:210 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:230 msgid "Query regions from this repeater" msgstr "Obtener las regiones de este repetidor" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "Consultando…" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "Sin respuesta" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "Quitar de favoritos" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "Anuncio sin saltos" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "Copiar enlace" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "Enlace copiado" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "Código QR del contacto" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "%d rutas disponibles" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "Aún no hay rutas" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "Enrutamiento inteligente— %s" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "Rutas disponibles" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "Resultado: %.2f" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "Prioridad: %.1f" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "Sin rutas registradas" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "Las rutas se actualizan al intercambiar mensajes" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "Borrar historial" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "Ubicación no disponible" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "Rutas a %s" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "Mi dispositivo" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "Añadir manualmente" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "Favoritos" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "Escuchado recientemente" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Últimos mensajes" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "Lo más destacado primero" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "{total} contacto" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "%x" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "{total} encontrados" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} encontrados" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "Llegada" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "Este contacto ya figura en tu lista." #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "Error al importar" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "Sin enlace meshcore:// en el portapapeles." #: src/views/contacts_view.py:417 #, 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:682 msgid "Searching..." msgstr "Buscando…" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "Ruta encontrada" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "Cargando…" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "Sin rutas en caché" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "No compatible" #: src/views/contacts_view.py:784 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:807 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: tiempo de espera agotado" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Solicitando..." #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "Error de tiempo de espera: sin respuesta de telemetría" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "Sin respuesta (se ha agotado el tiempo de espera)" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: 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:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "Telemetría recibida" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetría — {}" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "Ruta a {}" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "Repetidor desconocido" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "Repeticiones escuchadas" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "Reenviar" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "Borrar" #: src/views/channels_view.py:186 msgid "Reply" msgstr "Responder" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "Copiar texto" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "Ver rutas de mensajes" #: src/views/channels_view.py:241 msgid "Send" msgstr "Enviar" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "Enviar mensaje sin restricción en el canal" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "Enviar mi contacto" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Compartir contacto" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "Compartir el canal" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Compartir ubicación" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "Compartir la ubicación desde el mapa" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "Has compartido un contacto:" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} te ha enviado un contacto:" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "Ya añadido" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "Compartiste un canal:" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "{name} compartió un canal:" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "Ya unido" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "Has compartido tu ubicación:" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} ha compartido una ubicación:" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "Mensajes nuevos" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d repetición" msgstr[1] "· %d repeticiones" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "· %d reenvío" msgstr[1] "· %d reenvíos" #: src/views/channels_view.py:1146 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:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "Mensaje de {sender} recibido a través de {count} ruta:" msgstr[1] "Mensaje de {sender} recibido a través de {count} rutas:" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "desconocido" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "Relación señal/ruido: {} dB" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "Ruta {}: {}" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "Saltos {} · prefijo {}" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "Mensaje privado" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "No se utilizaron repetidores" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "No hay datos sobre el repetidor" #: src/views/channels_view.py:1239 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:1308 msgid "Message Path" msgstr "Ruta del mensaje" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "Aún no se han escuchado repeticiones." #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "Público" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "{visible}/{total} canal" msgstr[1] "{visible}/{total} canales" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "{total} canal" msgstr[1] "{total} canales" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "Información del canal" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "Clave privada" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "Clave privada copiada en el portapapeles" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} participante" msgstr[1] "{} participantes" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "Conservación de mensajes" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 día" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 semana" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 mes" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "3 meses" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "Para siempre" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "Nivel de notificación" #: src/views/channels_view.py:2147 msgid "Default" msgstr "Por defecto" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "Región" #: src/views/channels_view.py:2178 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:2206 msgid "No regions defined" msgstr "Sinregiones definidas" #: src/views/channels_view.py:2207 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:2226 msgid "Clear Messages" msgstr "Borrar mensajes" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "Eliminar el canal" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "Bloqueado" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} bloqueo" msgstr[1] "{} bloqueos" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "Desbloquear" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "Bloquear" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "Desbloqueado {}" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "¿Bloquear {}?" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "Bloquear y borrar mensajes" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "{} bloqueado" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "No hay plazas disponibles" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "Todos los {count} canales están en uso." #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "Ya me uní" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "¡Ya estás en el canal público!." #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "Ningún enlace a un canal en el portapapeles." #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" "Ningún canal en el código QR:\n" "{}" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "Línea de comandos del compañero — %s" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" "Escribe un comando y pulsa Enter. El dispositivo de destino debe permitir " "este contacto para la CLI remota." #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "CLI del dispositivo — %s" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "Introduce un comando y presiona Intro." #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "(Sin respuesta)" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "Esperando respuesta..." #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "La clave pública del dispositivo no está disponible" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "¿Restaurar de fábrica?" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "Restablecer" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "¿Reiniciar el dispositivo?" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "El dispositivo se desconectará y se reiniciará." #: src/views/device_view.py:319 msgid "Not set" msgstr "Sin configurar" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "Ubicación (configurada manualmente)" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "Ubicación (GPS)" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "Ubicación (GPS activado, sin señal)" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "Ubicación (GPS desactivado)" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "Solicitando…" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "{days}día(s)" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "{hours}hora(s)" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "{mins}minuto(s)" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "{} mensajes" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "{count} error(es)" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "Conectado" #: src/views/device_view.py:498 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:520 msgid "e.g. aa,bb,cc" msgstr "p. ej. aa,bb,cc" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "p. ej., aabb,ccdd" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "p. ej., aabbcc,ddeeff" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "No hay repetidores cuya ubicación se conozca" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Rastreo completado: {} saltos, {}" #: src/views/device_view.py:681 msgid "Result" msgstr "Resultado" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "El seguimiento ha expirado" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "Valor hexadecimal no válido en la ruta" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "Cargando..." #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Escaneando... Quedan {}segundo(s)" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "Añadir a los contactos" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "Añadido {}" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "Consultar regiones" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "Nombre y titular de la consulta" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "Consultar detalles" #: src/views/device_view.py:1032 msgid "No region response" msgstr "Región no responde" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "Sin respuesta del propietario" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "Sin información del propietario" #: src/views/device_view.py:1076 msgid "Owner" msgstr "Propietario" #: src/views/device_view.py:1105 msgid "Node" msgstr "Nodo" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "Hecho. Se han encontrado {} nodos." #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "Hecho. Se ha encontrado el nodo {}." #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} durante los últimos 2 minutos" #: src/views/device_view.py:1356 msgid "Size" msgstr "Tamaño" #: src/views/device_view.py:1356 msgid "bytes" msgstr "bytes" #: src/views/device_view.py:1361 msgid "hops" msgstr "saltos" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "Rutas hash" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "byte por salto" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "Hay una actualización disponible: {version}" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "Rechazar todo" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "Permitir por contacto" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "Permitir todo" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "Contactos del nodo en línea eliminados" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "Configuración aplicada en el dispositivo" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "Error desconocido" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "Error de configuración: {}" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Potencia de transmisión (dBm, máx. {})" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "Ubicación no disponible" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "El campo ya está rellenado; haz clic en «Aplicar» para guardar" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "Seleccionar ubicación" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "Establecer ubicación" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "Conéctate primero a un dispositivo" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "Obteniendo los repetidores dentro del alcance..." #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "{} regiones encontradas" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "Región {} encontrada" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" "La lista de contactos del dispositivo está llena. Elimina algunos contactos " "para descubrir regiones." #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "Sin regiones" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "Sin repetidores en el radio de alcance" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "de {}" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(ya añadido)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "Consultando {} repetidores..." #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "Procesando la consulta del repetidor {}..." #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "Regiones {} añadidas" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "Región {} añadida" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "Sin conexión" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "Conecta un dispositivo para exportar la copia." #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "Archivos json" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "¡Copia de seguridad exportada correctamente!" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "Error al exportar: {}" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "El dispositivo no admite la exportación de claves" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "Conecta un dispositivo para importar la copia." #: src/views/settings_view.py:1576 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:1591 msgid "No Auto Backups" msgstr "No hay copias de seguridad automáticas" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "Sin copias de seguridad automáticas." #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "{} (en curso)" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "Mostrar solo este dispositivo" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Error al leer la copia de seguridad: {}" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "Fuente: {}" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "{} contactos" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "{} canales" #: src/views/settings_view.py:1789 msgid "messages" msgstr "mensajes" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "identidad del dispositivo" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "Importado: {}" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "No hay nada nuevo que importar" #: src/views/repeater_view.py:156 msgid "room" msgstr "sala" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "sensor" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "repetidor" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "Sincronización horaria enviada a {}" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "¿Restablecer el reloj y reiniciar?" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "Reiniciando..." #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "Reinicio de reloj y sistema enviado a {}" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "La solicitud ha expirado" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} de {} vecinos" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "Mapa del vecindario" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "{} entrada" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "{} entradas" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "¿Eliminar de la lista ACL?" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "¿Eliminar {} de la lista de control de acceso?" #: src/views/repeater_view.py:711 msgid "Role" msgstr "Cargo o función" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "Administración" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "Lectura-Escritura" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "Solo lectura" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "Añadir al control de acceso" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "Global (comodín)" #: src/views/repeater_view.py:842 msgid "Home" msgstr "Inicio" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "Inundación permitida" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "Inundación denegada" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "Rechazar Flood" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "Permitir inundación" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "Establecer como página de inicio" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "Elimina primero las regiones secundarias" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "Cambios sin guardar" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "Tienes cambios en la región sin guardar." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "Descartar" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "Sin ámbito" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "Contraseña de administrador actualizada. Inicie sesión de nuevo" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "Configuración enviada a {}" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "¿Reiniciar {}?" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "La hora no es correcta" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "Ubicación compartida" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "Compartida" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "La biblioteca de códigos QR no está disponible" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "Ubicación no disponible" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "Tu acompañante no tiene señal de GPS." #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "Enviar automáticamente" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "Enviar directamente" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "Enviar Flood" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "Enviando" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "Sala de solo lectura" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} de {} contactos en el mapa" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "{} de {} situado en el mapa" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "{} encontrados" #: src/views/map_view.py:427 msgid "Any time" msgstr "En cualquier momento" #: src/views/map_view.py:427 msgid "Past month" msgstr "El mes pasado" #: src/views/map_view.py:427 msgid "Past day" msgstr "El día anterior" #: src/views/map_view.py:427 msgid "Past hour" msgstr "Última hora" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "y {} más" #: src/views/map_view.py:868 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:876 #, python-brace-format msgid "{} dB" msgstr "{} dB" #: src/views/map_view.py:876 msgid "SNR" msgstr "Relación señal/ruido" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "Seleccione Traceroute" #: src/views/map_view.py:1263 msgid "_Done" msgstr "_Hecho" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "Pulsa en los repetidores para trazar la ruta" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "{count} nodos importados de la base de datos" #, fuzzy #~ msgid "Companion Management" #~ msgstr "Gestionar la sala" #, fuzzy #~ msgid "Enter a CLI command" #~ msgstr "Comando cli ..." #, fuzzy #~ msgid "WiFi IP" #~ msgstr "WiFi / TCP" #~ msgid "Query this contact's telemetry data" #~ msgstr "Consultar la telemetría de este contacto" #, python-format #~ msgid "%d hops" #~ msgstr "%d saltos" #~ msgid "Contact Info" #~ msgstr "Información del contacto" #~ msgid "Favorite" #~ msgstr "Favorito" #~ msgid "Pin to top of contacts list" #~ msgstr "Fijar en la parte superior de la lista de contactos" #, python-brace-format #~ msgid "{total} channels" #~ msgstr "{total} canales" #~ msgid "Preset Configuration" #~ msgstr "Configuración preestablecida" #~ msgid "Select a Contact" #~ msgstr "Selecciona un contacto" #~ msgid "Select a Channel" #~ msgstr "Seleccione un canal" #~ msgid "Choose a channel from the list" #~ msgstr "Elige un canal de la lista." #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: {type}" #~ msgstr "" #~ "Nombre: {name}\n" #~ "Tipo: {type}" #, python-brace-format #~ msgid "" #~ "Type: {type}\n" #~ "Key: {key}…" #~ msgstr "" #~ "Tipo: {type}\n" #~ "Clave: {key}…" #~ msgid "Import Contact" #~ msgstr "Importar contacto" #, python-brace-format #~ msgid "Contact {}" #~ msgstr "Contacto {}" #~ msgid "imported" #~ msgstr "importado" #, python-brace-format #~ msgid "Add {} Contact" #~ msgstr "Añadir {} contacto" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Public Key: {key}…" #~ msgstr "" #~ "Nombre: {name}\n" #~ "Clave pública: {key}…" #~ msgid "Scan" #~ msgstr "Escanear" #~ msgid "Invalid channel QR code." #~ msgstr "QR del canal no válido" #~ msgid "Invalid public key in QR code." #~ msgstr "Clave pública no válida en el código QR." #, python-brace-format #~ msgid "Public key must be 32 bytes, got {}." #~ msgstr "La clave pública debe tener 32 bytes; se ha obtenido {}." #~ msgid "QR Code Scanned" #~ msgstr "Código QR escaneado" #, python-brace-format #~ msgid "Public key: {}...{}" #~ msgstr "Clave pública: {}...{}" #~ msgid "Battery & Storage" #~ msgstr "Batería y almacenamiento" #~ msgid "Remove Contact?" #~ msgstr "¿Eliminar contacto?" #, python-brace-format #~ msgid "Remove {} and all messages?" #~ msgstr "¿Eliminar {} y todos los mensajes?" #~ msgid "Clear Messages?" #~ msgstr "¿Borrar los mensajes?" #, python-brace-format #~ msgid "Delete all messages in \"{}\"?" #~ msgstr "¿Deseas eliminar todos los mensajes de «{}»?" #~ msgid "Remove Channel?" #~ msgstr "¿Eliminar canal?" #, python-brace-format #~ msgid "Remove \"{}\" and all its messages?" #~ msgstr "¿Quitar\" {}\" y todos sus mensajes?" #~ msgid "Pick Snapshot" #~ msgstr "Seleccionar instantánea" #~ msgid "Login and access status, CLI, neighbors, and settings" #~ msgstr "Estado de inicio de sesión y acceso, CLI, vecinos y configuración" #~ msgid "Login successful" #~ msgstr "Inicio de sesión correcto" #, python-brace-format #~ msgid "Logged in to {}" #~ msgstr "Sesión iniciada en {}" #~ msgid "Login failed" #~ msgstr "Error al iniciar sesión" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: Private" #~ msgstr "" #~ "Nombre: {name}\n" #~ "Tipo: Privado" #~ msgid "Channel secret must be 16 bytes, got {}." #~ msgstr "El canal privado debe tener 16 bytes; se ha obtenido {}." #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Añadir región (por ejemplo, Praga)" #~ msgid "Reboot Repeater" #~ msgstr "Reiniciar el repetidor" #~ 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.po000066400000000000000000002517161526212641700136130ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-08-11 20:26+0000\n" "Last-Translator: Priit Jõerüüt \n" "Language-Team: Estonian \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" "X-Generator: Weblate 2026.8.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 "" #: 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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "Seadme ja repiiteri haldus" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 msgid "Next Unread" msgstr "" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "Rakenduse teave: Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 msgid "Connect" msgstr "Ühenda" #: 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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 "Seadme nimi" #: 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0–63, piirangu puudumiseks jäta tühjaks" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "Lisa piirkond" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 "WiFi / TCP" #: data/ui/connection-view.ui:84 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "Lisa TCP-kaaslane" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Lisa kontakt" #: data/ui/chat-view.ui:22 #, fuzzy msgid "No Contacts Yet" msgstr "Lisa kontakt" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Lisa kanal" #: data/ui/channel-chat-widget.ui:22 #, fuzzy msgid "No Channels Yet" msgstr "Lisa kanal" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 #, fuzzy msgid "Device CLI" msgstr "Seadme nimi" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Seadme seadistused" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 #, fuzzy msgid "Radio settings" msgstr "Seadme seadistused" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 #, fuzzy msgid "New Companion" msgstr "Lisa TCP-kaaslane" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 #, fuzzy msgid "Join Channel" msgstr "Lisa kanal" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 #, fuzzy msgid "Private Channel" msgstr "Lisa kanal" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "Lisa valitud" #: data/ui/join-hashtag-channel-content.ui:8 #, fuzzy msgid "Hashtag Channel" msgstr "Lisa kanal" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "Teemaviide (nt. #üldine)" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Lisa piirkond" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 #, fuzzy msgid "C_onnect" msgstr "Ühenda" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 #, fuzzy msgid "Set Up Companion" msgstr "Lisa TCP-kaaslane" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, fuzzy, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} kanalit" msgstr[1] "{} kanalit" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Sedame kell on süsteemi kellast {} minutit ees" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "Leidus QR-kood!" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 #, fuzzy msgid "Could not share contact" msgstr "Jaga kontakti" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "Eile" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Teemaviide" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "Paardunud seadmeid pole" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 #, fuzzy msgid "Contact QR Code" msgstr "Lisa kontakt" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "Lisa käsitsi" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "Hiljuti kuuldud" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Hiljutised sõnumid" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 #, fuzzy msgid "Share My Contact" msgstr "Jaga kontakti" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Jaga kontakti" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 #, fuzzy msgid "Share Channel" msgstr "Lisa kanal" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Jaga asukohta" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 #, fuzzy msgid "Already joined" msgstr "(juba lisatud)" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, fuzzy, python-brace-format msgid "SNR: {} dB" msgstr "{} dB" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 päev" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 nädal" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 kuu" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} on blokeeritud" msgstr[1] "{} on blokeeritud" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "Sa juba oled avalikul kanalil." #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 #, fuzzy msgid "Query Regions" msgstr "Lisa piirkond" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(juba lisatud)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "{} kontakti" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "{} kanalit" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "Avaleht" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "Sul on salvestamata regioonimuudatusi." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "QR-koodide teek pole saadaval" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "{} dB" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" #, fuzzy #~ msgid "WiFi IP" #~ msgstr "WiFi / TCP" #~ msgid "QR Code Scanned" #~ msgstr "QR-kood on skaneeritud" meshy/po/fi.po000066400000000000000000002473301526212641700135760ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/fr.po000066400000000000000000003360711526212641700136100ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-08-26 06:26+0000\n" "Last-Translator: lejun \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.8.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 #, fuzzy msgid "" "Meshy is a client for MeshCore 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 the 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:16 msgid "Features:" msgstr "Caractéristiques:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 #, fuzzy msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "Connectivité Bluetooth LE et USB série" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "Messagerie chiffrée de bout en bout" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "Canaux publics, privés et hashtag" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "Gestion des contacts avec suivi de localisation" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 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:23 msgid "Device and repeater management" msgstr "Gestion des appareils et des répéteurs" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 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:29 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "Canaux de discussion" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "Gestion des répéteurs" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "Paramètres – Thème sombre" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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 "Aide" #: 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "Navigation" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "Appareil" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "Contacts" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "Canaux" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "Carte" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "Paramètres" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "Messagerie" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "Rechercher des Contacts" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "Concentrer Saisie du Message" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "Contact/Canal Précédent" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "Contact/Canal Suivant" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "Non Lu Précédent" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "Non Connecté" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Statut" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "Déconnecté" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "Nom du Nœud" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "Carte" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:102 msgid "Update" msgstr "Mise à Jour" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "Ouvrir le flasheur du firmware" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "Clé Publique" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "Copier vers le presse-papiers" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "Télémétrie" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "Demande de télémétrie" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "Position" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "Synchronisation…" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "Afficher sur la carte" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "Batterie" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "Stockage" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "Radio" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "Fréquence" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "Débit" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "Facteur d'Étalement" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "Taux d’encodage" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "Puissance TX" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Taille Hash Chemin Défaut" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "Statistiques" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "Plancher de Bruit" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 #, fuzzy msgid "Show live graph" msgstr "Afficher sur la carte" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "Dernier RSSI" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "Dernier SNR" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "Temps d'Antenne" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Paquets" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "Direct" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Temps de Disponibilité" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Liste de messages" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "Actualiser" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "Rafraîchir les statistiques" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "Tous les messages" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "Mentions uniquement" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio Configuration" msgstr "Configuration Radio" #: data/ui/settings-view.ui:90 msgid "Frequency, bandwidth, spreading factor, coding rate" msgstr "Fréquence, débit, facteur d'étalement, taux d’encodage" #: data/ui/settings-view.ui:93 data/ui/repeater-view.ui:723 msgid "Frequency (MHz)" msgstr "Fréquence (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "Débit (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "Puissance TX (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 msgid "Routing and Messaging" msgstr "Chemin et messages" #: data/ui/settings-view.ui:175 msgid "Path routing and message delivery settings" msgstr "Chemin et options de distribution des messages" #: data/ui/settings-view.ui:178 msgid "Default Routing" msgstr "Chemin par défaut" #: data/ui/settings-view.ui:179 msgid "Fixed path or automatic route selection" msgstr "Chemin fixe ou sélection automatique" #: data/ui/settings-view.ui:183 data/ui/contact-detail-dialog.ui:160 msgid "Classic" msgstr "Classique" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 msgid "Smart" msgstr "Intelligente" #: data/ui/settings-view.ui:191 msgid "Learn more about routing modes" msgstr "Obtenir plus d’informations sur les modes de distribution" #: data/ui/settings-view.ui:205 msgid "Larger hashes reduce collisions but allow fewer hops" msgstr "" "Un hachage plus important réduit les collisions mais permet moins de rebonds" #: data/ui/settings-view.ui:210 src/views/device_view.py:284 msgid "1-byte (max 64 hops)" msgstr "1 octet (max 64 sauts)" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "2 octets (max 32 sauts)" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 msgid "3-byte (max 21 hops)" msgstr "3 octets (max 21 sauts)" #: data/ui/settings-view.ui:220 msgid "Direct Message Confirmations" msgstr "Confirmations de message direct" #: data/ui/settings-view.ui:221 msgid "Number of ACKs sent when receiving a direct message" msgstr "Nombre d’ACK envoyés lors de la réception de messages directs" #: 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:678 msgid "Latitude" msgstr "Latitude" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "Longitude" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Utiliser ma position" #: data/ui/settings-view.ui:265 #, fuzzy msgid "Set from this device's location" msgstr "Utiliser la position de l'ordinateur" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 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:313 msgid "Contact discovery and automatic adding" msgstr "Découverte de contact et ajout automatique" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Ajout Automatique de Contacts" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "Ajouter automatiquement les nœuds découverts via les annonces" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "Paramètres Ajout Automatique" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "Types d'Appareil, politique de remplacement, limite de saut" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "Nœuds de Chat" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "Répéteurs" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "Serveurs de Salon" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "Capteurs" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "Écraser le plus ancien lorsque pleine" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Limite de sauts" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0-63, vide pour ne pas imposer de limite" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "Liste publique de nœuds" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "Télécharger les informations du nœud depuis map.meshcore.io" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "Confidentialité de la Télémétrie" #: data/ui/settings-view.ui:378 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:381 msgid "Battery & Sensors" msgstr "Batterie & Capteurs" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Batterie, tension, température, courant" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "Coordonnées GPS" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Environnement" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Humidité, pression, qualité de l'air" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "Régions" #: data/ui/settings-view.ui:404 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:428 msgid "Add region…" msgstr "Ajouter une région…" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "Découvrir les régions à partir des répéteurs" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "Ajouter une région" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Portée par Défaut" #: data/ui/settings-view.ui:452 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:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "Ajouter un Compagnon TCP" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Recherche…" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "Aucun contact" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Ajouter un Contact" #: data/ui/chat-view.ui:22 #, fuzzy msgid "No Contacts Yet" msgstr "Aucun contact" #: data/ui/chat-view.ui:23 #, fuzzy msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "Partager" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "Tapez un message..." #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "Créer un Canal Privé" #: data/ui/channels-view.ui:14 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 msgid "Join a Hashtag Channel" msgstr "Rejoindre un Canal Hashtag" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "Importer depuis le Presse-papiers" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "Scanner le code QR" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "Aucun Canal" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Ajouter un Canal" #: data/ui/channel-chat-widget.ui:22 #, fuzzy msgid "No Channels Yet" msgstr "Aucun Canal" #: data/ui/channel-chat-widget.ui:23 #, fuzzy msgid "Add channels to start chatting" msgstr "Choisissez un contact dans la liste pour commencer à discuter" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 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:266 msgid "Neighbors" msgstr "Voisins" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "Charger plus" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "Afficher sur la Carte" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "Contrôle d'Accès" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "Ajouter" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "Chargement des Régions..." #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "Réessayer" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "Région par défaut" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "Appliquer" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "Basique" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "Récupérer" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "Sauvegarder" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "Nom" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "Répétition de Paquet" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "Accès invité" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "Autoriser l’accès invité en lecture seule sans mot de passe" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "Mots de passe" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "Mot de passe administration" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "Mot de passe invité" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "Annonce" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "Intervalle local (min)" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "Intervalle Flood(hrs)" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 #, fuzzy msgid "Path Hash Size" msgstr "Hachages de Chemin" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 #, fuzzy msgid "1 byte" msgstr "octets" #: data/ui/repeater-view.ui:869 #, fuzzy msgid "2 bytes" msgstr "octets" #: data/ui/repeater-view.ui:870 #, fuzzy msgid "3 bytes" msgstr "octets" #: data/ui/repeater-view.ui:878 #, fuzzy msgid "Default Region (Scope)" msgstr "Région par défaut" #: data/ui/repeater-view.ui:879 #, fuzzy msgid "Flood packets are scoped to this region unless overridden" msgstr "" "Tous les paquets Flood auront pour portée cette région si elle est définie." #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "Zone de danger" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "Redémarrer l'Appareil" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "Choisir un appareil" #: data/ui/repeater-view.ui:944 msgid "Log Out" msgstr "Déconnexion" #: data/ui/sensor-view.ui:14 msgid "Select a Sensor" msgstr "Choisir un capteur" #: data/ui/sensor-view.ui:76 msgid "Refresh Telemetry" msgstr "Rafraîchir la télémétrie" #: data/ui/sensor-view.ui:85 msgid "Login for Admin Access" msgstr "Identifiant d’accès administration" #: 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 #, fuzzy msgid "Device CLI" msgstr "Appareil" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" msgstr "" #: 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 "dBm sur les deux dernières minutes" #: data/ui/settings-sidebar.ui:15 msgid "Backup" msgstr "Sauvegarde" #: data/ui/settings-sidebar.ui:23 msgid "Manual" msgstr "Manuel" #: 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 msgid "Automatic" msgstr "Automatique" #: data/ui/settings-sidebar.ui:37 msgid "Daily backups, kept for 7 days" msgstr "Sauvegardes quotidiennes, conservées sept jours" #: data/ui/settings-sidebar.ui:51 msgid "Restore" msgstr "Restauration" #: data/ui/settings-sidebar.ui:59 msgid "From File" msgstr "Depuis un fichier" #: data/ui/settings-sidebar.ui:60 msgid "Restore from a manually saved backup" msgstr "Restauration depuis un fichier de sauvegarde" #: data/ui/settings-sidebar.ui:72 msgid "From Auto Backup" msgstr "Depuis une sauvegarde automatique" #: data/ui/settings-sidebar.ui:73 msgid "Restore from an automatic snapshot" msgstr "Restauration depuis une sauvegarde automatique" #: data/ui/settings-sidebar.ui:99 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 msgid "Manual Backup" msgstr "Sauvegarde manuelle" #: data/ui/manual-backup-dialog.ui:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "_Annuler" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "_Exporter" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "Informations sur l'appareil" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Paramètres de l’appareil" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "Paramètres locaux" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "Liste de contacts" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "Contacts découverts" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "Messages et distribution" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "Messages" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" msgstr "Historique de distribution intelligente" #: data/ui/auto-backup-dialog.ui:8 msgid "Automatic Backup" msgstr "Sauvegarde Automatique" #: data/ui/auto-backup-dialog.ui:35 msgid "Enable Automatic Backups" msgstr "Activer les sauvegardes automatiques" #: data/ui/auto-backup-dialog.ui:36 msgid "Daily backups after sync, kept for 7 days" msgstr "" "Sauvegardes quotidiennes après synchronisation, conservées pendant sept jours" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "Importer une sauvegarde" #: data/ui/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "_Restaurer" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "Restaurer depuis un fichier de sauvegarde" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "Afficher tous les appareils" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "Découvrir Les Contacts" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 msgid "Search" msgstr "Recherche" #: data/ui/discover-dialog.ui:27 msgid "Filter by type" msgstr "Filtrer par type" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" msgstr "Trier par" #: data/ui/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Choisir un thème" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Add to favorites" msgstr "Ajouter aux Contacts" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 #, fuzzy msgid "Remote CLI" msgstr "Retirer" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "Type" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "Dernière connexion" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "Distribution" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "Chemin Sortant" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "Réinitialiser Chemin" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "Effacer le chemin établi et passer au flood" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "Chemins disponibles" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "Réinitialiser Automatiquement le Chemin" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" "Basculer vers une distribution par inondation après plusieurs tentatives " "échouées" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "Distribution par inondation exclusivement" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "Toujours diffuser, ne jamais utiliser un chemin établi" #: data/ui/contact-detail-dialog.ui:230 #, fuzzy msgid "Permissions" msgstr "Autorisation USB Refusée" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "Supprimer le Contact" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "Redémarrer" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 #, fuzzy msgid "Get name" msgstr "Sans nom" #: data/ui/cli-dialog.ui:62 #, fuzzy msgid "Radio settings" msgstr "Pré-réglages régionaux" #: data/ui/cli-dialog.ui:68 #, fuzzy msgid "Version" msgstr "Autorisation USB Refusée" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "Commande CLI..." #: data/ui/custom-name-dialog.ui:8 #, fuzzy msgid "Rename" msgstr "Sans nom" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "_Sauvegarder" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 #, fuzzy msgid "Original Name" msgstr "Nom de la région" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 #, fuzzy msgid "Custom Name" msgstr "Personnalisée" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 #, fuzzy msgid "New Companion" msgstr "Associer un Nouveau Compagnon" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 #, fuzzy msgid "Set Up as New Companion" msgstr "Associer un Nouveau Compagnon" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 #, fuzzy msgid "Restore from a Backup File" msgstr "Restaurer depuis un fichier de sauvegarde" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 #, fuzzy msgid "Radio Preset" msgstr "Pré-réglages régionaux" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "_Exécuter la trace" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "Chemin" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "Choisir un chemin sur la carte" #: data/ui/trace-path-dialog.ui:89 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:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "_Ajouter" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "Entrez les détails du contact." #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "Type de Contact" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "Clé Publique (64 caractères hexadécimaux)" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "Rejoindre un canal" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "Ouvrir dans l'Application Cartes" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "Membres" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "Filtre" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "Rejoindre un canal privé" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "C_réer" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" "Un PSK aléatoire sera généré. Partagez le avec les personnes à inviter." #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 msgid "Channel Name" msgstr "Nom du Canal" #: data/ui/custom-tiles-dialog.ui:8 msgid "Custom Map Tiles" msgstr "Tuiles de carte personnalisée" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:573 msgid "URL Template" msgstr "Modèle d’URL" #: data/ui/custom-tiles-dialog.ui:55 msgid "Reset to Default" msgstr "Réinitialiser" #: 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 msgid "Discover Regions" msgstr "Découvrir les régions" #: data/ui/discover-regions-dialog.ui:60 data/ui/pair-dialog.ui:9 #: src/connection_controller.py:287 msgid "Scanning..." msgstr "Scanner..." #: data/ui/discover-regions-dialog.ui:61 msgid "Listening for nearby repeaters" msgstr "Recherche de répéteurs proches" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "Ajouter Sélection" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "Rejoindre un canal hashtag" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "_Rejoindre" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Entrer un nom hashtag. Tout le monde peut communiquer avec le même hashtag." #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (ex : #general)" #: data/ui/join-private-channel-content.ui:43 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." #: data/ui/join-private-channel-content.ui:64 msgid "Private Key (32 hex characters)" msgstr "Clé privée (32 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "Utilisateurs" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Salons" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "Nœuds découverts" #: data/ui/map-view.ui:30 msgid "User" msgstr "Membre" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "Répéteur" #: data/ui/map-view.ui:62 msgid "Room" msgstr "Salon" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "Capteur" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "Recherche de périphériques MeshCore..." #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "Entrer le Code PIN d'Appairage" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "_Apparier" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "Entrez le code PIN affiché sur votre appareil MeshCore." #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "Copier la clé publique" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "Etiquette d'état" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Ajouter Région" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "Entrer un nom et ajouter une région principale optionnelle." #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "Nom de la région" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "Principale" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "Rechercher des Appareils" #: data/ui/room-login-dialog.ui:33 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 msgid "Login" msgstr "Se Connecter" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "C_onnecter" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "Entrez l'adresse IP et le port de votre appareil MeshCore." #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "Nom d'Hôte/Adresse IP" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "Port" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "Changer l'Adresse" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "Entrer la nouvelle adresse pour cet appareil." #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "Annuler" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "Effacer" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "Liste publique de nœuds" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" "Les informations des nœuds seront téléchargées depuis map.meshcore.io " "(~15 MB). Seuls les nœuds correspondant aux réglages de votre appareil et " "ayant une activité dans les trois derniers mois seront importés dans les " "contacts découverts." #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "Mise à jour quotidienne automatique" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "Annuler" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "Télécharger" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "Désactiver la liste publique de nœuds ?" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "La mise à jour de la liste publique sera arrêtée." #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "Supprimer tous les contacts importés de la liste publique" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "Désactiver" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "Lien meshcore:// non valide" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "Secret du canal invalide dans le lien" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "Secret du canal de longueur invalide" #: src/application.py:122 msgid "Invalid public key in link" msgstr "Clé publique du lien non valide" #: src/application.py:123 msgid "Invalid public key length" msgstr "Longueur de la clé publique non valide" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "Lien meshcore:// non reconnu" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "{} est déjà dans votre liste" #: src/application.py:146 msgid "Contact" msgstr "Contact" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "Contact {} ajouté" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "Recevoir des messages en arrière-plan" #: src/application.py:388 #, fuzzy msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" "gallegonovato — Espagnol\n" "lebeno — Français, Néerlandais\n" "bjawebos — Allemand\n" "Manuel Traut — Allemand\n" "lejun — Français\n" "LievenBlancke — Néerlandais\n" "sesivany — Tchèque" #: src/application.py:401 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:450 msgid "Adwaita Symbolic Icons" msgstr "Icônes Symboliques Adwaita" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "Personnalisée" #: src/companion_setup.py:192 #, fuzzy msgid "Set Up Companion" msgstr "Supprimer un Compagnon" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 #, fuzzy msgid "_Back" msgstr "Sauvegarde" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 #, fuzzy msgid "New companion set up" msgstr "Aucuns compagnons sauvegardés" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "Connecter..." #: src/connection_controller.py:288 msgid "Stop" msgstr "Arrêter" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Reconnexion ({current}/{total})..." #: src/connection_controller.py:312 msgid "Syncing..." msgstr "Synchronisation..." #: src/connection_controller.py:347 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:593 msgid "Syncing channels" msgstr "Synchronisation des canaux" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} messages" msgstr[1] "{} messages" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} canaux" msgstr[1] "{} canaux" #: src/connection_controller.py:681 msgid " and " msgstr " et " #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "Vous avez reçu {} pendant votre absence." #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "Synchronisation des contacts" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "Erreur de l'appareil: {}" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "{} (en retard)" #: src/frame_handler.py:752 msgid "late" msgstr "en retard" #: src/frame_handler.py:826 #, 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:834 #, 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" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "Canal {}" #: src/frame_handler.py:1406 msgid "Someone" msgstr "Une personne" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender} : {text}" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "Moi" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "Flood" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "direct" #: src/message_controller.py:234 msgid "path" msgstr "chemin" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "en attente {} (radio occupée)" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d saut" msgstr[1] "%d sauts" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "Mot de passe erroné – Veuillez réessayer" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "Appariement supprimé" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "Appariement échoué – Impossible d’établir la connexion" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" msgstr "Appariemment échoué" #: 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:378 msgid "Missing GStreamer plugin." msgstr "Extension GStreamer manquante." #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Erreur de pipeline de caméra: {}" #: src/qr_scanner.py:469 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:480 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:486 msgid "QR code found!" msgstr "QR code détecté!" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "Erreur de caméra: {}" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "Erreur de Caméra" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "OK" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, fuzzy, python-brace-format msgid "{} mi" msgstr "il y a {} min" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "{d} j" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "{h} h" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "{m} min" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "{secs} s" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "il y a {} s" msgstr[1] "il y a {} s" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "il y a {} min" msgstr[1] "il y a {} min" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "il y a {} heure" msgstr[1] "il y a {} heures" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "il y a {} jour" msgstr[1] "il y a {} jours" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "Raster" #: src/window.py:555 msgid "Vector" msgstr "Vecteur" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" "Utiliser {z}, {x}, {y} comme variables.\n" "Exemple : https://tile.openstreetmap.org/{z}/{x}/{y}.png" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "Tri" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "Actions" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "Informations sur l'Appareil" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "Tchat" #: src/window.py:733 msgid "Channel" msgstr "Canal" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "Sauvegarde & restauration" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "Gestion" #: src/window.py:1031 msgid "Guest" msgstr "Invitée" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1166 msgid "Excellent" msgstr "Excellent" #: src/window.py:1167 msgid "Good" msgstr "Bien" #: src/window.py:1168 msgid "Fair" msgstr "Passable" #: src/window.py:1169 msgid "Poor" msgstr "Mauvais" #: src/window.py:1170 msgid "Very poor" msgstr "Très Mauvais" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "saut zéro" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "Annonce envoyée ({})" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 #, fuzzy msgid "Could not share contact" msgstr "Vous avez partagé un contact:" #: src/window.py:1764 #, fuzzy msgid "Contact List Full" msgstr "Liste de contacts" #: src/window.py:1766 #, fuzzy msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" "La liste de contacts sur l’appareil est pleine. Supprimez en une partie pour " "découvrir des régions." #: src/window.py:1947 src/window.py:2100 #, fuzzy, python-brace-format msgid "{} removed" msgstr "Retirer" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, fuzzy, python-brace-format msgid "Channel \"{}\" is already added" msgstr "Canal \"{}\" ajouté" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "Aucun emplacement de canal vide disponible" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Canal \"{}\" ajouté" #: src/window.py:2131 #, fuzzy, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "Liste de messages" #: src/views/__init__.py:66 msgid "Today" msgstr "Aujourd’hui" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "Hier" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "%A %-d %B %Y" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "Inconnu" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "{total} contacts" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contacts" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "Tout" #: src/views/__init__.py:656 msgid "Distance" msgstr "Distance" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "Se connecter à {}" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "Échec de la connexion — mot de passe incorrect" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "La connexion a expiré" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "Nouvelle tentative via Flood..." #: src/views/__init__.py:860 msgid "Logging in..." msgstr "Connexion en cours..." #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "Serveur de Salle" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "Clé publique copiée" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "Sans nom" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Hashtag" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "Privé" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 msgid "Remove" msgstr "Retirer" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "Bluetooth non disponible" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "" #: src/views/connection_view.py:96 msgid "Bluetooth is disabled" msgstr "Le Bluetooth est désactivé" #: src/views/connection_view.py:97 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:109 msgid "No paired devices" msgstr "Aucun appareil couplé" #: src/views/connection_view.py:110 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:153 msgid "No USB devices detected" msgstr "Aucun appareil USB détecté" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "Connecter un compagnon MeshCore via USB" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "Supprimer un Compagnon" #: src/views/connection_view.py:184 #, 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:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "Supprimer les données stockées" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "Échec de la Connexion USB" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "Autorisation USB Refusée" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "Installer la Règle" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "Fermer" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "Copier la Commande" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "Commande copiée dans le presse-papiers" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "Échec de l'Installation" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Impossible d'installer la règle udev:\n" "{}" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "Aucuns compagnons sauvegardés" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "Utilisez le bouton ci-dessous pour en ajouter un" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "Supprimer le compagnon TCP {}?" #: src/views/connection_view.py:590 msgid "Pair" msgstr "Associer" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "Jamais" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "Afficher Chemin sur la Carte" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "Visualiser l'itinéraire du message sur la carte" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "Autoriser les Demandes de Télémétrie" #: src/views/contact_detail_dialog.py:136 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:140 msgid "Include Location" msgstr "Inclure Position" #: src/views/contact_detail_dialog.py:141 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:145 msgid "Include Environment Sensors" msgstr "Inclure les Capteurs d'Environnement" #: src/views/contact_detail_dialog.py:146 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:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "Gestion de Salle" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "Gestion de Répéteur" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "Gestion de capteur" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "Mesurer le temps aller-retour vers ce nœud" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "Découvrir Chemins" #: src/views/contact_detail_dialog.py:210 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:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "Recherche…" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "Aucune réponse" #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Remove from favorites" msgstr "Supprimer de l’ACL ?" #: src/views/contact_detail_dialog.py:380 #, fuzzy msgid "Zero Hop Advert" msgstr "Zéro Saut" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 #, fuzzy msgid "Link copied" msgstr "Clé publique copiée" #: src/views/contact_detail_dialog.py:412 #, fuzzy msgid "Contact QR Code" msgstr "Votre Code QR de Contact" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "%d chemins disponibles" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "Aucun chemin pour le moment" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "Itinéraires Intelligents — %s" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "Chemins Disponibles" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "Poids: %.1f" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "Aucun chemin enregistré pour le moment" #: src/views/contact_detail_dialog.py:634 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:641 msgid "Clear History" msgstr "Effacer le Journal" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "Localisation non disponible" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "Chemin vers %s" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "Mon Appareil" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "Ajouter Manuellement" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "Favoris" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "Entendu Récemment" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Derniers Messages" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "Favoris d'Abord" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "{total} contact" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "%x" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "{total} découvert" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} découvert" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "Heure d'Arrivée" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "Ce contact est déjà dans votre liste." #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "Échec de l'Importation" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "Aucun lien meshcore:// trouvé dans le presse-papiers." #: src/views/contacts_view.py:417 #, 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:682 msgid "Searching..." msgstr "Recherche..." #: src/views/contacts_view.py:735 msgid "Path found" msgstr "Chemin trouvé" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "Chargement..." #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "Pas de chemin en cache" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "Non pris en charge" #: src/views/contacts_view.py:784 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:807 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: délai expiré" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Demander..." #: src/views/contacts_view.py:848 #, fuzzy msgid "No telemetry response (timed out)" msgstr "Aucune réponse (délai expiré)" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "Aucune réponse (délai expiré)" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: 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:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "Télémétrie reçue" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "Télémétrie — {}" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "Chemin vers {}" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "Répéteur inconnu" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "Répétitions Entendues" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "Renvoyer" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "Supprimer" #: src/views/channels_view.py:186 msgid "Reply" msgstr "Répondre" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "Copier le Texte" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "Afficher Chemins des Messages" #: src/views/channels_view.py:241 msgid "Send" msgstr "Envoyer" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "Envoyer sans restriction de canal" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "Partager mon contact" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Partager le Contact" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "Partager le canal" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Partager la Localisation" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "Partager la Localisation Depuis la Carte" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "Vous avez partagé un contact:" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} a partagé un contact avec vous:" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "Déjà ajouté" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "Vous avez partagé un canal :" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "{name} a partagé un canal :" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "Déjà membre" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "Vous avez partagé une localisation:" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} a partagé une localisation:" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "Nouveaux Messages" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d répétition" msgstr[1] "· %d répétitions" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "· %d tentative" msgstr[1] "· %d tentatives" #: src/views/channels_view.py:1146 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:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 #, fuzzy msgid "unknown" msgstr "Inconnu" #: src/views/channels_view.py:1193 #, fuzzy, python-brace-format msgid "SNR: {} dB" msgstr "SNR: {:.1f} dB" #: src/views/channels_view.py:1194 #, fuzzy, python-brace-format msgid "Path {}: {}" msgstr "Chemin vers {}" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "Message direct" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "Aucun répéteur n'a été utilisé" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "Aucun détail de répéteur" #: src/views/channels_view.py:1239 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:1308 msgid "Message Path" msgstr "Chemin du Message" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "Aucune répétition entendue pour le moment." #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "Public" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, fuzzy, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "{visible}/{total} canaux" msgstr[1] "{visible}/{total} canaux" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, fuzzy, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "{total} canal" msgstr[1] "{total} canal" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "Informations du Canal" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "Clé privée" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "Clé privée copiée vers le presse-papiers" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} membre" msgstr[1] "{} membres" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "Durée de conservation des messages" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 Jour" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 Semaine" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 Mois" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "3 Mois" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "Illimitée" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "Niveau de notification" #: src/views/channels_view.py:2147 msgid "Default" msgstr "Par défaut" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "Région" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "Limiter la distribution par inondation à une certaine région" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "Pas de régions définies" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "Ajouter des régions dans les paramètres pour les joindre aux canaux" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "Nettoyer les messages" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "Supprimer le Canal" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "Bloqué" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} bloqué" msgstr[1] "{} bloqués" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "Débloquer" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "Bloquer" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "Débloqué {}" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "Bloquer {}?" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "Bloquer et supprimer les messages" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "{} Bloqué" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "Pas d’espace disponible" #: src/views/channels_view.py:2550 #, fuzzy, python-brace-format msgid "All {count} channel slots are in use." msgstr "Tous les 8 emplacements de canal sont utilisés." #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "Déjà membre" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "Vous êtes déjà sur le canal public." #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "Aucun lien de canal trouvé dans le presse-papiers." #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" "Aucun lien de canal trouvé dans le code QR :\n" "{}" #: src/views/cli_dialog.py:46 #, fuzzy, python-format msgid "Companion CLI — %s" msgstr "GPS de l'appareil" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, fuzzy, python-format msgid "Device CLI — %s" msgstr "GPS de l'appareil" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 #, fuzzy msgid "(empty response)" msgstr "Aucune réponse" #: src/views/cli_console.py:148 #, fuzzy msgid "Waiting for response..." msgstr "Chargement des Régions..." #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "Clé publique de l'appareil non disponible" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "Réinitialisation d'Usine?" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "Réinitialiser" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "Redémarrer l'Appareil?" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "L'appareil se déconnectera et redémarrera." #: src/views/device_view.py:319 msgid "Not set" msgstr "Non configuré" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "Position (définie manuellement)" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "Position (GPS)" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "Position (GPS activé, pas de fix)" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "Position (GPS désactivé)" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "Demande en cours…" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "{days}j" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "{} messages" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "{count} erreurs" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "Connecté" #: src/views/device_view.py:498 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:520 msgid "e.g. aa,bb,cc" msgstr "p. ex. aa,bb,cc" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "p. ex. aabb,ccdd" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "p. ex. aabbcc,ddeeff" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "Aucun répéteur avec position connue" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trace terminée : {} sauts, {}" #: src/views/device_view.py:681 msgid "Result" msgstr "Résultat" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "La trace a expiré" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "Hex invalide dans le chemin" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "Traçage en cours..." #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Analyse en cours... {} s restant" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "Ajouter aux Contacts" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "Ajouté {}" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "Explorer les régions" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "Explorer les noms et les propriétaires" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "Explorer les détails" #: src/views/device_view.py:1032 msgid "No region response" msgstr "Pas de région disponible" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "Pas de propriétaire disponible" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "Pas d’informations sur le propriétaire" #: src/views/device_view.py:1076 msgid "Owner" msgstr "Propriétaire" #: src/views/device_view.py:1105 msgid "Node" msgstr "Nœud" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "Terminé. {} nœuds trouvés." #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "Terminé. {} nœud trouvé." #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} sur les deux dernières minutes" #: src/views/device_view.py:1356 msgid "Size" msgstr "Taille" #: src/views/device_view.py:1356 msgid "bytes" msgstr "octets" #: src/views/device_view.py:1361 msgid "hops" msgstr "sauts" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "Hachages de Chemin" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "octet par saut" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "Mise à jour disponible: {version}" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "Tout refuser" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "Autoriser par Contact" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "Tout Autoriser" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "Nœuds de la liste publique supprimés" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "Paramètres appliqués sur l'appareil" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "Erreur inconnue" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "Erreur Paramètres: {}" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Puissance TX (dBm, max {})" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "Position indisponible" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "Position remplie, cliquer sur Appliquer pour sauvegarder" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "Choisir Position" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "Définir Position" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "Connexion à un apparereil nécessaire" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "Explorer les répéteurs à proximité…" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "{} régions détectées" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "{} région détectée" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" "La liste de contacts sur l’appareil est pleine. Supprimez en une partie pour " "découvrir des régions." #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "Pas de régions disponibles" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "Aucun répéteur à proximité" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "de {}" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(déjà ajouté)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "{} régions ajoutées" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "{} région ajoutée" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "Fichiers JSON" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "Sauvegarde exportée avec succès" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 #, fuzzy msgid "No automatic backups found." msgstr "Sauvegarde Automatique" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "{} contacts" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "{} canaux" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "salon" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "capteur" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "répéteur" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "Redémarrage…" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "Carte du voisinage" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "{} entrée" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "{} entrées" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "Supprimer de l’ACL ?" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "Rôle" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "Administration" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "Lecture-Écriture" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "Lecture seule" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "Ajouter à Contrôle d'Accès" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "Accueil" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "Autoriser Flood" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "Modifications non sauvegardées" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "Vous avez des modifications de région non sauvegardées." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 #, fuzzy msgid "Unscoped" msgstr "Envoyer sans restriction de canal" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "Mot de passe administrateur modifié — reconnexion requise" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "Paramètres envoyés à {}" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "Localisation Partagée" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "Partagée" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "Bibliothèque de code QR non disponible" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "Localisation non disponible" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "Votre compagnon n'a pas de localisation GPS." #: src/views/chat_view.py:167 #, fuzzy msgid "Send Automatically" msgstr "Démarrage automatique à la connexion" #: src/views/chat_view.py:168 #, fuzzy msgid "Send Direct" msgstr "Direct" #: src/views/chat_view.py:169 #, fuzzy msgid "Send Flood" msgstr "Flood" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} de {} contacts sur la carte" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 #, fuzzy msgid "Any time" msgstr "Temps d'Antenne" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" #, fuzzy #~ msgid "Companion Management" #~ msgstr "Gestion de Salle" #, fuzzy #~ msgid "Enter a CLI command" #~ msgstr "Commande CLI..." #, fuzzy #~ msgid "WiFi IP" #~ msgstr "WiFi / TCP" #~ msgid "Query this contact's telemetry data" #~ msgstr "Interroger les données télémétriques de ce contact" #, python-format #~ msgid "%d hops" #~ msgstr "%d sauts" #~ msgid "Contact Info" #~ msgstr "Informations de Contact" #~ msgid "Favorite" #~ msgstr "Préféré" #~ msgid "Pin to top of contacts list" #~ msgstr "Épingler en haut de la liste de contacts" #, python-brace-format #~ msgid "{total} channels" #~ msgstr "{total} canaux" #~ msgid "Preset Configuration" #~ msgstr "Configuration de Préréglage" #~ msgid "Select a Contact" #~ msgstr "Sélectionner un Contact" #~ msgid "Select a Channel" #~ msgstr "Sélectionner un Canal" #~ msgid "Choose a channel from the list" #~ msgstr "Choisissez un canal dans la liste" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: {type}" #~ msgstr "" #~ "Nom : {name}\n" #~ "Type : {type}" #, python-brace-format #~ msgid "" #~ "Type: {type}\n" #~ "Key: {key}…" #~ msgstr "" #~ "Type : {type}\n" #~ "Clé : {key}" #~ msgid "Import Contact" #~ msgstr "Importer un Contact" #, python-brace-format #~ msgid "Contact {}" #~ msgstr "Contact {}" #~ msgid "imported" #~ msgstr "importé" #, python-brace-format #~ msgid "Add {} Contact" #~ msgstr "Ajouter {} Contact" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Public Key: {key}…" #~ msgstr "" #~ "Nom: {name}\n" #~ "Clé Publique: {key}…" #~ msgid "Scan" #~ msgstr "Scanner" #~ msgid "Invalid public key in QR code." #~ msgstr "Clé publique invalide dans le code QR." #, python-brace-format #~ msgid "Public key must be 32 bytes, got {}." #~ msgstr "La clé publique doit faire 32 octets, j'ai obtenu {}." #~ msgid "QR Code Scanned" #~ msgstr "Code QR scanné" #, python-brace-format #~ msgid "Public key: {}...{}" #~ msgstr "Clé publique: {}...{}" #~ msgid "Battery & Storage" #~ msgstr "Batterie & Stockage" #~ msgid "Remove Contact?" #~ msgstr "Supprimer le Contact?" #, python-brace-format #~ msgid "Remove {} and all messages?" #~ msgstr "Supprimer {} et tous les messages?" #, fuzzy #~ msgid "Clear Messages?" #~ msgstr "Tous les messages" #~ msgid "Remove Channel?" #~ msgstr "Supprimer le Canal?" #, fuzzy #~ msgid "Pick Snapshot" #~ msgstr "Indiquer sur une carte" #~ msgid "Login and access status, CLI, neighbors, and settings" #~ msgstr "Se connecter et accéder à statut, CLI, voisins et paramètres" #~ msgid "Login successful" #~ msgstr "Connexion réussie" #, fuzzy, python-brace-format #~ msgid "Logged in to {}" #~ msgstr "Se connecter à {}" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: Private" #~ msgstr "" #~ "Nom : {name}\n" #~ "Type : Privé" #~ msgid "Channel secret must be 16 bytes, got {}." #~ msgstr "Secret du canal doit être de 16 octets, reçu {}." #, 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." #, 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.po000066400000000000000000002473301526212641700136140ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/it.po000066400000000000000000002473301526212641700136140ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/lt.po000066400000000000000000002500141526212641700136100ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/lv.po000066400000000000000000002477511526212641700136300ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/meshy.pot000066400000000000000000002473731526212641700145200ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/meson.build000066400000000000000000000002431526212641700147700ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later i18n.gettext('meshy', preset: 'glib') meshy/po/nl.po000066400000000000000000003337611526212641700136150ustar00rootroot00000000000000# 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-09-27 21:18+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 #, fuzzy msgid "" "Meshy is a client for MeshCore 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 the 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:16 msgid "Features:" msgstr "Kenmerken:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 #, fuzzy msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "Bluetooth LE en USB seriële verbinding" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "Eind-tot-eind versleutelde berichtgeving" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "Openbare, privé- en hashtagkanalen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "Contactbeheer met locatievolgen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "Interactieve kaartweergave met routeopvolging" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "Apparaat- en repeaterbeheer" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "QR-code scannen voor snel contact uitwisselen" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "Kanaalchats" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "Repeaterbeheer" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "Instellingen - Donkere Stijl" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "Navigatie" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "Apparaat" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "Contacten" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "Kanalen" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "Kaart" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "Instellingen" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "Berichten" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "Contacten Zoeken" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "Focus berichtinvoer" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "Vorig Contact/Kanaal" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "Volgend Contact/Kanaal" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "Vorig Ongelezen" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "Niet verbonden" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Status" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "Niet verbonden" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "Knooppuntnaam" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "Bord" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "Firmware" #: data/ui/device-view.ui:102 msgid "Update" msgstr "Update" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "Open firmware flasher" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "Openbare sleutel" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "Kopiëren naar klembord" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "Telemetrie" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "Telemetrie aanvragen" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "Locatie" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "Synchroniseren…" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "Op kaart tonen" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "Batterij" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "Opslag" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "Radio" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "Frequentie" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "Bandbreedte" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "Spreidingsfactor" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "Coderingssnelheid" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "TX Vermogen" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Standaard pad-hashgrootte" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "Statistieken" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "Ruisvloer" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 #, fuzzy msgid "Show live graph" msgstr "Op kaart tonen" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "Laatste RSSI" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "Laatste SNR" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "Zendduur" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Pakketten" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "Flood" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "Direct" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Uptime" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Berichtenwachtrij" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "Vernieuwen" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "Statistieken vernieuwen" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "Alle berichten" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "Alleen Vermeldingen" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio Configuration" msgstr "Radioconfiguratie" #: 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:723 msgid "Frequency (MHz)" msgstr "Frequentie (MHz)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "Bandbreedte (kHz)" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "TX Vermogen (dBm)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "Klassiek" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "1-byte (max 64 hops)" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "2-byte (max 32 hops)" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "Breedtegraad" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "Lengtegraad" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Gebruik Mijn Locatie" #: data/ui/settings-view.ui:265 #, fuzzy msgid "Set from this device's location" msgstr "Instellen vanaf de locatie van deze computer" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 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:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Contacten Automatisch Toevoegen" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "Voeg automatisch knooppunten toe die via aankondigingen worden ontdekt" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "Instellingen Automatisch Toevoegen" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "Aapparaattype, overschrijfbeleid, hoplimiet" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "Chat Knooppunten" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "Repeaters" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "Kamerservers" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "Sensoren" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "Oudste overschrijven wanneer vol" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Hoplimiet" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0–63, laat leeg voor geen limiet" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "Telemetrieprivacy" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "Bepaal wat anderen van jouw apparaat mogen opvragen" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "Batterij & Sensoren" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Batterij, spanning, temperatuur, stroom" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "GPS-coördinaten" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Omgeving" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Vochtigheid, druk, luchtkwaliteit" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "Regio's" #: data/ui/settings-view.ui:404 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:428 #, fuzzy msgid "Add region…" msgstr "Regio Toevoegen" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "Ontdek regio's van repeaters" #: data/ui/settings-view.ui:437 #, fuzzy msgid "Add region" msgstr "Regio Toevoegen" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Standaardbereik" #: data/ui/settings-view.ui:452 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:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "TCP Companion Toevoegen" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Zoeken..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "Geen contacten" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Contact Toevoegen" #: data/ui/chat-view.ui:22 #, fuzzy msgid "No Contacts Yet" msgstr "Geen contacten" #: data/ui/chat-view.ui:23 #, fuzzy msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "Delen" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "Typ een bericht..." #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "Privékanaal Maken" #: data/ui/channels-view.ui:14 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 msgid "Join a Hashtag Channel" msgstr "Deelnemen aan een Hashtagkanaal" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "Importeren vanaf Klembord" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "QR-Code Scannen" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "Geen Kanalen" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Kanaal Toevoegen" #: data/ui/channel-chat-widget.ui:22 #, fuzzy msgid "No Channels Yet" msgstr "Geen Kanalen" #: data/ui/channel-chat-widget.ui:23 #, fuzzy msgid "Add channels to start chatting" msgstr "Kies een contact uit de lijst om te beginnen met chatten" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "Telemetrie Aanvragen" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "Buren" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "Meer Laden" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "Weergeven op Kaart" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "Toegangscontrole" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "Toevoegen" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "Regio's worden geladen..." #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "Opnieuw proberen" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "Standaard Regiobereik" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "Toepassen" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "Basis" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "Ophalen" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "Opslaan" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "Naam" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "Pakketherhaling" #: data/ui/repeater-view.ui:602 #, fuzzy msgid "Guest Access" msgstr "Gastwachtwoord" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "Wachtwoord" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "Beheerderswachtwoord" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "Gastwachtwoord" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "Aankondiging" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "Lokale interval (min)" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "Flood Interval (uren)" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 #, fuzzy msgid "Path Hash Size" msgstr "Pad-hashes" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 #, fuzzy msgid "1 byte" msgstr "bytes" #: data/ui/repeater-view.ui:869 #, fuzzy msgid "2 bytes" msgstr "bytes" #: data/ui/repeater-view.ui:870 #, fuzzy msgid "3 bytes" msgstr "bytes" #: data/ui/repeater-view.ui:878 #, fuzzy msgid "Default Region (Scope)" msgstr "Standaard Regiobereik" #: data/ui/repeater-view.ui:879 #, fuzzy msgid "Flood packets are scoped to this region unless overridden" msgstr "Alle flood-pakketten worden aan deze regio gebonden als ingesteld" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "Gevarenzone" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "Apparaat Opnieuw Opstarten" #: data/ui/repeater-view.ui:915 #, fuzzy msgid "Select a device" msgstr "Selecteer een Repeater" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 #, fuzzy msgid "Device CLI" msgstr "Apparaat" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" msgstr "" #: 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 #, fuzzy msgid "_Cancel" msgstr "Annuleren" #: data/ui/manual-backup-dialog.ui:24 #, fuzzy msgid "_Export" msgstr "Exporteren" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "Apparaatidentiteit" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Apparaatinstellingen" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "Lokale Instellingen" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "Contactenlijst" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "Gevonden Contacten" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "Berichten en Routering" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "Berichten" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" msgstr "Slimme Routegeschiedenis" #: 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 #, fuzzy msgid "_Restore" msgstr "Terugzetten" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 #, fuzzy msgid "Restore from Automatic Backup" msgstr "Terugzetten vanaf een automatische momentopname" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 #, fuzzy msgid "Show All Devices" msgstr "Scannen Naar Apparaten" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "Ontdek contacten" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Kies Thema" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Add to favorites" msgstr "Toevoegen aan Contacten" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 #, fuzzy msgid "Remote CLI" msgstr "Verwijder" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "Type" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "Laatst Gezien" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "Routering" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "Uitgaand Pad" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "Pad Opnieuw Instellen" #: data/ui/contact-detail-dialog.ui:187 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:199 msgid "Available Routes" msgstr "Beschikbare Paden" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "Pad Automatisch Opnieuw Instellen" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "Schakel over naar Flood na mislukte bezorgpogingen" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "Forceer Flood" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "Zend altijd uit, gebruik nooit een vastgesteld pad" #: data/ui/contact-detail-dialog.ui:230 #, fuzzy msgid "Permissions" msgstr "USB-Toestemming Geweigerd" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "Contact verwijderen" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "Opnieuw Opstarten" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 #, fuzzy msgid "Get name" msgstr "Naamloos" #: data/ui/cli-dialog.ui:62 #, fuzzy msgid "Radio settings" msgstr "Regionale Preset" #: data/ui/cli-dialog.ui:68 #, fuzzy msgid "Version" msgstr "USB-Toestemming Geweigerd" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "CLI-opdracht..." #: data/ui/custom-name-dialog.ui:8 #, fuzzy msgid "Rename" msgstr "Naamloos" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 #, fuzzy msgid "_Save" msgstr "Opslaan" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 #, fuzzy msgid "Original Name" msgstr "Regionaam" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 #, fuzzy msgid "Custom Name" msgstr "Aangepast" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 #, fuzzy msgid "New Companion" msgstr "Nieuwe Companion Koppelen" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 #, fuzzy msgid "Set Up as New Companion" msgstr "Nieuwe Companion Koppelen" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 #, fuzzy msgid "Restore from a Backup File" msgstr "Terugzetten vanaf een automatische momentopname" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 #, fuzzy msgid "Radio Preset" msgstr "Regionale Preset" #: data/ui/trace-path-dialog.ui:25 #, fuzzy msgid "_Run Trace" msgstr "Trace Uitvoeren" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "Pad" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "Kies route op de kaart" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "Voer pad-hashes in en druk op Trace Uitvoeren" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "Voer de contactgegevens in." #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "Contacttype" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "Openbare sleutel (64 hexadecimale tekens)" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 #, fuzzy msgid "Join Channel" msgstr "Kanaal" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "Openen in Maps App" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "Deelnemers" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "Filter" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 #, fuzzy msgid "Private Channel" msgstr "Deelnemen aan een Privékanaal" #: data/ui/create-private-channel-content.ui:24 #, fuzzy msgid "C_reate" msgstr "Aanmaken" #: data/ui/create-private-channel-content.ui:43 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." #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "Geselecteerde Toevoegen" #: data/ui/join-hashtag-channel-content.ui:8 #, fuzzy msgid "Hashtag Channel" msgstr "Deelnemen aan een Hashtagkanaal" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 #, fuzzy msgid "_Join" msgstr "Lid Worden" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Voer een hashtagnaam in. Iedereen die dezelfde hashtag gebruikt, kan " "communiceren." #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "Hashtag (bv. #algemeen)" #: data/ui/join-private-channel-content.ui:43 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." #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "Gebruikers" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Kamers" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "Ontdekte Knooppunten" #: data/ui/map-view.ui:30 msgid "User" msgstr "Gebruiker" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "Repeater" #: data/ui/map-view.ui:62 msgid "Room" msgstr "Kamer" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "Sensor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "Scannen naar MeshCore apparaten..." #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "Koppelingspincode Invoeren" #: data/ui/passkey-dialog-content.ui:24 #, fuzzy msgid "_Pair" msgstr "Koppelen" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "Voer de pincode in die wordt weergegeven op uw MeshCore-apparaat." #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 #, fuzzy msgid "Copy Public Key" msgstr "Openbare sleutel" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "Statuslabel" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Regio Toevoegen" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "Voer een naam in en kies optioneel een bovenliggende regio." #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "Regionaam" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "Ouder" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "Scannen Naar Apparaten" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "Wachtwoord" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Wachtwoord opslaan" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "Login" #: data/ui/tcp-add-content.ui:24 #, fuzzy msgid "C_onnect" msgstr "Verbinden" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "Voer het IP-adres en de poort van uw MeshCore-apparaat in." #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "Hostnaam / IP-Adres" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "Poort" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "Adres Wijzigen" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "Geef het nieuwe adres voor deze companion." #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "Ongedaan Maken" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "Wissen" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 #, fuzzy msgid "Automatically update daily" msgstr "Pad Automatisch Opnieuw Instellen" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "Annuleren" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "Ongeldige meshcore:/- link" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "Ongeldig kanaalgeheim in link" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "Ongeldige kanaalgeheimlengte" #: src/application.py:122 msgid "Invalid public key in link" msgstr "Ongeldige openbare sleutel in link" #: src/application.py:123 msgid "Invalid public key length" msgstr "Ongeldige lengte van de openbare sleutel" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "Niet-herkende meshcore://-link" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "{} is al in jouw lijst" #: src/application.py:146 msgid "Contact" msgstr "Contact" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "Contact {} toegevoegd" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "Berichten ontvangen op de achtergrond" #: src/application.py:388 #, fuzzy msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" 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:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "Een GTK-client voor MeshCore LoRa mesh-netwerkapparaten" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "Adwaita Symbolische Iconen" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "Aangepast" #: src/companion_setup.py:192 #, fuzzy msgid "Set Up Companion" msgstr "Verwijder Companion" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 #, fuzzy msgid "_Back" msgstr "Back-up" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 #, fuzzy msgid "New companion set up" msgstr "Geen opgeslagen companions" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "Verbinden..." #: src/connection_controller.py:288 msgid "Stop" msgstr "Stop" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Opnieuw verbinding maken ({current}/{total})..." #: src/connection_controller.py:312 msgid "Syncing..." msgstr "Synchroniseren..." #: src/connection_controller.py:347 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:593 msgid "Syncing channels" msgstr "Kanalen synchroniseren" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} bericht" msgstr[1] "{} berichten" #: src/connection_controller.py:675 #, fuzzy, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} kanalen" msgstr[1] "{} kanalen" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, fuzzy, python-brace-format msgid "You received {} while disconnected." msgstr "Je hebt {} bericht ontvangen terwijl de verbinding was verbroken." #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "Contacten synchroniseren" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "Apparaatfout: {}" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "{} (later)" #: src/frame_handler.py:752 msgid "late" msgstr "later" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "Apparaatklok liep {}min achter — gesynchroniseerd" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Apparaatklok loopt {}min voor op het systeem" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "Kanaal {}" #: src/frame_handler.py:1406 msgid "Someone" msgstr "Iemand" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "Ik" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "flood" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "direct" #: src/message_controller.py:234 msgid "path" msgstr "pad" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "wachten {} (radio bezet)" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d hop" msgstr[1] "%d hops" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 #, fuzzy msgid "Pairing failed" msgstr "Inloggen mislukt" #: 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Fout in camerapijplijn: {}" #: src/qr_scanner.py:469 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:480 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:486 msgid "QR code found!" msgstr "QR-code gevonden!" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "Camerafout: {}" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "Camerafout" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "OK" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, fuzzy, python-brace-format msgid "{} mi" msgstr "{} minuut geleden" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "{d}d" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "{h}h" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "{m}m" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "{secs}s" #: src/utils.py:253 #, fuzzy, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "{}s geleden" msgstr[1] "{}s geleden" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "{} minuut geleden" msgstr[1] "{} minuten geleden" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} uur geleden" msgstr[1] "{} uren geleden" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} dag geleden" msgstr[1] "{} dagen geleden" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "Raster" #: src/window.py:555 msgid "Vector" msgstr "Vector" #: src/window.py:563 #, 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:567 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:573 msgid "Style JSON URL" msgstr "JSON URL Stijl" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "Aangepaste kaarttegels toegepast" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "Aangepaste kaarttegels laden mislukt" #: src/window.py:637 msgid "Sort" msgstr "Sorteren" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "Acties" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "Apparaatinformatie" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "Chat" #: src/window.py:733 msgid "Channel" msgstr "Kanaal" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "Back-up & Terugzetten" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:1031 msgid "Management" msgstr "Beheer" #: src/window.py:1031 msgid "Guest" msgstr "Gast" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1166 msgid "Excellent" msgstr "Uitstekend" #: src/window.py:1167 msgid "Good" msgstr "Goed" #: src/window.py:1168 msgid "Fair" msgstr "Redelijk" #: src/window.py:1169 msgid "Poor" msgstr "Zwak" #: src/window.py:1170 msgid "Very poor" msgstr "Zeer zwak" #: src/window.py:1432 msgid "flood routed" msgstr "flood gerouteerd" #: src/window.py:1432 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "Aankondiging verzonden ({})" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 #, fuzzy msgid "Could not share contact" msgstr "Je deelde een contact:" #: src/window.py:1764 #, fuzzy msgid "Contact List Full" msgstr "Contactenlijst" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, fuzzy, python-brace-format msgid "{} removed" msgstr "Verwijder" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, fuzzy, python-brace-format msgid "Channel \"{}\" is already added" msgstr "Kanaal \"{}\" toegevoegd" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "Geen lege kanaalslots beschikbaar" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Kanaal \"{}\" toegevoegd" #: src/window.py:2131 #, fuzzy, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "Berichten gewist" #: src/views/__init__.py:66 msgid "Today" msgstr "Vandaag" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "Gisteren" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "%A %-d %B %Y" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "Onbekend" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "{total} contacten" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contacten" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "Alle" #: src/views/__init__.py:656 msgid "Distance" msgstr "Afstand" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "Inloggen op {}" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "Inloggen mislukt - verkeerd wachtwoord" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "Er is een time-out voor inloggen opgetreden" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "Opnieuw proberen via flood..." #: src/views/__init__.py:860 msgid "Logging in..." msgstr "Inloggen..." #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "Kamerserver" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "Openbare sleutel gekopieerd" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "Naamloos" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Hashtag" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "Privé" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "Bluetooth is uitgeschakeld" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "Schakel Bluetooth in de systeeminstellingen in om verbinding te maken" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "Geen gekoppelde apparaten" #: src/views/connection_view.py:110 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:153 msgid "No USB devices detected" msgstr "Geen USB-apparaten gedetecteerd" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "Verbind een MeshCore companion via USB" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "Verwijder Companion" #: src/views/connection_view.py:184 #, 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:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "Verwijder opgeslagen gegevens" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "USB-Verbinding Mislukt" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "USB-Toestemming Geweigerd" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "Installeer Regel" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "Sluiten" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "Kopieer Opdracht" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "Opdracht gekopieerd naar klembord" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "Installatie Mislukt" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Kon udev-regel niet installeren: \n" "{}" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "Geen opgeslagen companions" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "Gebruik de onderstaande knop om er een toe te voegen" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "TCP-companion {} verwijderen?" #: src/views/connection_view.py:590 msgid "Pair" msgstr "Koppelen" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "Nooit" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "Toon Pad op Kaart" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "Visualiseer de berichtenroute op de kaart" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "Telemetrieverzoeken Toestaan" #: src/views/contact_detail_dialog.py:136 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:140 msgid "Include Location" msgstr "Inclusief Locatie" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "Neem GPS-coördinaten op in telemetriereacties" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "Inclusief Omgevingssensoren" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "Voeg gegevens over vochtigheid, druk en luchtkwaliteit toe" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "Kamerbeheer" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "Repeaterbeheer" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "Ping" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "Meet de retourtijd naar dit knooppunt" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "Ontdek Paden" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "Vind routes naar deze node via flood" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "Vraag regio's van deze repeater op" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "Opvragen..." #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "Geen reactie" #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Remove from favorites" msgstr "Uit ACL Verwijderen?" #: src/views/contact_detail_dialog.py:380 #, fuzzy msgid "Zero Hop Advert" msgstr "Zero Hop" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 #, fuzzy msgid "Link copied" msgstr "Openbare sleutel gekopieerd" #: src/views/contact_detail_dialog.py:412 #, fuzzy msgid "Contact QR Code" msgstr "Uw Contact-QR-Code" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "%d paden beschikbaar" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "Nog geen paden" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "Slimme Routes — %s" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "Beschikbare Paden" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "Score: %.2f" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "Gewicht: %.1f" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "Nog geen paden geregistreerd." #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "Paden worden ontdekt terwijl berichten worden uitgewisseld" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "Geschiedenis Wissen" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "Geen locatiegegevens beschikbaar" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "Routes naar %s" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "Mijn Apparaat" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "Handmatig Toevoegen" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "Favorieten" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "Onlangs Gehoord" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Laatste Berichten" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "Favorieten Eerst" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "{total} contact" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "{total} ontdekt" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} ontdekt" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "Aankomsttijd" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "Dit contact staat al in uw lijst." #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "Importeren Mislukt" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "Geen meshcore://-link gevonden op het klembord." #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Kon QR-codegegevens niet parseren: \n" "{}" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "Zoeken..." #: src/views/contacts_view.py:735 msgid "Path found" msgstr "Gevonden pad" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "Laden..." #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "Er is geen pad in de cache opgeslagen" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "Niet ondersteund" #: src/views/contacts_view.py:784 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:807 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: timed-out" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Aanvragen..." #: src/views/contacts_view.py:848 #, fuzzy msgid "No telemetry response (timed out)" msgstr "Geen reactie (time-out)" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "Geen reactie (time-out)" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Geen telemetriegegevens" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "Telemetrie ontvangen" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "Pad naar {}" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "Onbekende repeater" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "Gehoorde Herhalingen" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "Opnieuw Verzenden" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "Verwijderen" #: src/views/channels_view.py:186 msgid "Reply" msgstr "Antwoord" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "Tekst Kopiëren" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "Berichtpaden Bekijken" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 #, fuzzy msgid "Share My Contact" msgstr "Contact Delen" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Contact Delen" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 #, fuzzy msgid "Share Channel" msgstr "Kanaal" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Locatie Delen" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "Locatie Delen van Kaart" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "Je deelde een contact:" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} deelde een contact met jou:" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "Al toegevoegd" #: src/views/channels_view.py:431 src/views/chat_view.py:362 #, fuzzy msgid "You shared a channel:" msgstr "Je deelde een locatie:" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, fuzzy, python-brace-format msgid "{name} shared a channel:" msgstr "{name} deelde een locatie:" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 #, fuzzy msgid "Already joined" msgstr "Al Lid" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "Je deelde een locatie:" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} deelde een locatie:" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "Nieuwe Berichten" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "· %d herhaling" msgstr[1] "· %d herhalingen" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 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:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 #, fuzzy msgid "unknown" msgstr "Onbekend" #: src/views/channels_view.py:1193 #, fuzzy, python-brace-format msgid "SNR: {} dB" msgstr "SNR: {:.1f} dB" #: src/views/channels_view.py:1194 #, fuzzy, python-brace-format msgid "Path {}: {}" msgstr "Pad naar {}" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "Direct bericht" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "Er zijn geen repeaters gebruikt" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "Geen repeatergegevens" #: src/views/channels_view.py:1239 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:1308 msgid "Message Path" msgstr "Berichtpad" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "Nog geen herhalingen gehoord." #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "Openbaar" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, fuzzy, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "{visible}/{total} kanalen" msgstr[1] "{visible}/{total} kanalen" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, fuzzy, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "{total} kanaal" msgstr[1] "{total} kanaal" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "Kanaalinformatie" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "Privésleutel" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} deelnemer" msgstr[1] "{} deelnemers" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "Berichtbehoud" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 Dag" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 Week" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 Maand" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "3 Maanden" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "Altijd" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "Meldingsniveau" #: src/views/channels_view.py:2147 msgid "Default" msgstr "Standaard" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "Regio" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "Beperk flood-berichten tot een specifieke regio" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "Geen regio's gedefinieerd" #: src/views/channels_view.py:2207 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:2226 msgid "Clear Messages" msgstr "Wis Berichten" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "Kanaal verwijderen" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "Geblokkeerd" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} geblokkeerd" msgstr[1] "{} geblokkeerd" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "Deblokkeren" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "Blokkeren" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "{} Gedeblokkeerd" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "Blokkeer {}?" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "Blokkeer en Verwijder Berichten" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "{} Geblokkeerd" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "Geen Slots Beschikbaar" #: src/views/channels_view.py:2550 #, fuzzy, python-brace-format msgid "All {count} channel slots are in use." msgstr "Alle 8 kanaalslots zijn in gebruik." #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "Al Lid" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "Je bent al op het openbare kanaal." #: src/views/channels_view.py:2702 #, fuzzy msgid "No channel link found in clipboard." msgstr "Geen meshcore://-link gevonden op het klembord." #: src/views/channels_view.py:2731 #, fuzzy, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "Ongeldig kanaalgeheim in QR-code." #: src/views/cli_dialog.py:46 #, fuzzy, python-format msgid "Companion CLI — %s" msgstr "Companion GPS" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, fuzzy, python-format msgid "Device CLI — %s" msgstr "Companion GPS" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 #, fuzzy msgid "(empty response)" msgstr "Geen reactie" #: src/views/cli_console.py:148 #, fuzzy msgid "Waiting for response..." msgstr "Regio's worden geladen..." #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "Openbare sleutel van apparaat niet beschikbaar" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "Fabrieksreset?" #: src/views/device_view.py:204 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:212 #, fuzzy msgid "Reset" msgstr "Pad Opnieuw Instellen" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "Apparaat Opnieuw Opstarten?" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "Het apparaat wordt losgekoppeld en opnieuw opgestart." #: src/views/device_view.py:319 msgid "Not set" msgstr "Niet ingesteld" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "Locatie (handmatig ingesteld)" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "Locatie (GPS)" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "Locatie (GPS aan, geen fix)" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "Locatie (GPS uit)" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "Aanvragen…" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "{days}d" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "{hours}h" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "{mins}m" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "{} berichten" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "{count} fouten" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "Verbonden" #: src/views/device_view.py:498 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:520 msgid "e.g. aa,bb,cc" msgstr "bv. aa,bb,cc" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "bv. aabb,ccdd" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "bv. aabbcc,ddeeff" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "Geen repeaters met bekende locatie" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trace voltooid: {} hops, {}" #: src/views/device_view.py:681 msgid "Result" msgstr "Resultaat" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "Er is een time-out opgetreden bij het traceren" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "Ongeldige hex in pad" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "Traceren..." #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Bezig met scannen... {}s resterend" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "Toevoegen aan Contacten" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "{} toegevoegd" #: src/views/device_view.py:1000 #, fuzzy msgid "Query Regions" msgstr "Regio's" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 #, fuzzy msgid "No region response" msgstr "Geen reactie" #: src/views/device_view.py:1055 #, fuzzy msgid "No owner response" msgstr "Geen reactie" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "Node" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "Klaar. {} nodes gevonden." #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "Klaar. {} node gevonden." #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} in de afgelopen 2 minuten" #: src/views/device_view.py:1356 msgid "Size" msgstr "Grootte" #: src/views/device_view.py:1356 msgid "bytes" msgstr "bytes" #: src/views/device_view.py:1361 msgid "hops" msgstr "hops" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "Pad-hashes" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "byte per hop" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "Update beschikbaar: {version}" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "Alles Weigeren" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "Toestaan per Contact" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "Iedereen toestaan" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "Instellingen toegepast op apparaat" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "Onbekende fout" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "Instellingenfout: {}" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "TX-vermogen (dBm, max. {})" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "Locatie niet beschikbaar" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "Locatie ingevuld, klik op Toepassen om op te slaan" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "Kies Locatie" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "Locatie Instellen" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "Maak eerst verbinding met een apparaat" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "Repeaters binnen bereik ontdekken..." #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "{} regio's gevonden" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "{} regio gevonden" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "Geen regio's gevonden" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "Geen repeaters binnen bereik" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "van {}" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(al toegevoegd)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "{} repeaters opvragen..." #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "{} repeater opvragen..." #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "{} regio's toegevoegd" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "{} regio toegevoegd" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "Niet Verbonden" #: src/views/settings_view.py:1431 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:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "JSON bestanden" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "Back-up succesvol geëxporteerd" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "Exporteren mislukt: {}" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "Apparaat ondersteunt geen sleutelexport" #: src/views/settings_view.py:1542 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:1576 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:1591 msgid "No Auto Backups" msgstr "Geen Automatische Back-ups" #: src/views/settings_view.py:1592 #, fuzzy msgid "No automatic backups found." msgstr "Geen automatische back-ups gevonden voor dit apparaat." #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Kon back-upbestand niet lezen: {}" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "Bron: {}" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "{} contacten" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "{} kanalen" #: src/views/settings_view.py:1789 msgid "messages" msgstr "berichten" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "apparaatidentiteit" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "Geïmporteerd: {}" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "Er is niets nieuws om te importeren" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "Klok Resetten en Opnieuw Opstarten?" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "Opnieuw opstarten..." #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "Verzoek is verlopen" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} van {} buren" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "Burenkaart" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "{} vermelding" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "{} vermeldingen" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "Uit ACL Verwijderen?" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "{} verwijderen uit de toegangscontrolelijst?" #: src/views/repeater_view.py:711 msgid "Role" msgstr "Rol" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "Beheerder" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "Voeg toe aan Toegangscontrole" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "Globaal (wildcard)" #: src/views/repeater_view.py:842 msgid "Home" msgstr "Thuis" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "Flood toegestaan" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "Flood geweigerd" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "Flood Weigeren" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "Flood Toelaten" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "Instellen als Thuis" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "Verwijder eerst onderliggende regio's" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "Niet-opgeslagen wijzigingen" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "U heeft niet-opgeslagen regiowijzigingen." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "Weggooien" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "Beheerderswachtwoord gewijzigd — opnieuw inloggen vereist" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "Instellingen verzonden naar {}" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "Tijd wijkt af" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "Gedeelde Locatie" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "Gedeeld" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "QR-codebibliotheek niet beschikbaar" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "Locatie Niet Beschikbaar" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "Jouw companion heeft geen GPS locatie." #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "Automatisch Verzenden" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "Direct Verzenden" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "Flood Zenden" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "Verzenden" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} van {} contacten op kaart" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "{} van {} gelegen op de kaart" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 #, fuzzy msgid "Any time" msgstr "Zendduur" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "and {} meer" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "Positie is illustratief — echte locatie onbekend" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "Kies Traceroute" #: src/views/map_view.py:1263 #, fuzzy msgid "_Done" msgstr "Klaar" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "Tik op repeaters om de traceroute op te bouwen" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" #, fuzzy #~ msgid "Companion Management" #~ msgstr "Kamerbeheer" #, fuzzy #~ msgid "Enter a CLI command" #~ msgstr "CLI-opdracht..." #, fuzzy #~ msgid "WiFi IP" #~ msgstr "WiFi / TCP" #~ msgid "Query this contact's telemetry data" #~ msgstr "Vraag de telemetriegegevens van dit contact op" #, python-format #~ msgid "%d hops" #~ msgstr "%d hops" #~ msgid "Contact Info" #~ msgstr "Contactinformatie" #~ msgid "Favorite" #~ msgstr "Favoriet" #~ msgid "Pin to top of contacts list" #~ msgstr "Vastzetten bovenaan de lijst met contacten" #, python-brace-format #~ msgid "{total} channels" #~ msgstr "{total} kanalen" #~ msgid "Preset Configuration" #~ msgstr "Presetconfiguratie" #~ msgid "Select a Contact" #~ msgstr "Selecteer een Contact" #~ msgid "Select a Channel" #~ msgstr "Selecteer een Kanaal" #~ msgid "Choose a channel from the list" #~ msgstr "Kies een kanaal uit de lijst" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: {type}" #~ msgstr "" #~ "Naam: {name}\n" #~ "Type: {type}" #, python-brace-format #~ msgid "" #~ "Type: {type}\n" #~ "Key: {key}…" #~ msgstr "" #~ "Type: {type}\n" #~ "Sleutel: {key}…" #~ msgid "Import Contact" #~ msgstr "Contact Importeren" #, python-brace-format #~ msgid "Contact {}" #~ msgstr "Contact {}" #~ msgid "imported" #~ msgstr "geïmporteerd" #, python-brace-format #~ msgid "Add {} Contact" #~ msgstr "Contact {} Toevoegen" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Public Key: {key}…" #~ msgstr "" #~ "Naam: {name}\n" #~ "Openbare Sleutel: {key}…" #~ msgid "Scan" #~ msgstr "Scan" #~ msgid "Invalid public key in QR code." #~ msgstr "Ongeldige openbare sleutel in QR-code." #, python-brace-format #~ msgid "Public key must be 32 bytes, got {}." #~ msgstr "Openbare sleutel moet 32 bytes zijn, kreeg {}." #~ msgid "QR Code Scanned" #~ msgstr "QR-Code Gescand" #, python-brace-format #~ msgid "Public key: {}...{}" #~ msgstr "Openbare sleutel: {}...{}" #~ msgid "Battery & Storage" #~ msgstr "Batterij & Opslag" #~ msgid "Remove Contact?" #~ msgstr "Contact Verwijderen?" #, python-brace-format #~ msgid "Remove {} and all messages?" #~ msgstr "{} en alle berichten verwijderen?" #~ msgid "Clear Messages?" #~ msgstr "Berichten Wissen?" #, python-brace-format #~ msgid "Delete all messages in \"{}\"?" #~ msgstr "Verwijder alle berichten in \"{}\"?" #~ msgid "Remove Channel?" #~ msgstr "Kanaal Verwijderen?" #, python-brace-format #~ msgid "Remove \"{}\" and all its messages?" #~ msgstr "'{}' en alle bijbehorende berichten verwijderen?" #~ msgid "Pick Snapshot" #~ msgstr "Kies Momentopname" #~ msgid "Login and access status, CLI, neighbors, and settings" #~ msgstr "Log in en krijg toegang tot status, CLI, buren, en instellingen" #~ msgid "Login successful" #~ msgstr "Inloggen succesvol" #, python-brace-format #~ msgid "Logged in to {}" #~ msgstr "Ingelogd op {}" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: Private" #~ msgstr "" #~ "Naam: {name}\n" #~ "Type: Privé" #~ msgid "Channel secret must be 16 bytes, got {}." #~ msgstr "Kanaalgeheim moet 16 bytes zijn, kreeg {}." #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Regio toevoegen (bijv. nl)" #~ 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 "{}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?" #, 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.po000066400000000000000000002745011526212641700151120ustar00rootroot00000000000000# 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-08-08 19:07+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/settings-view.ui:312 data/ui/manual-backup-dialog.ui:106 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:113 #: src/window.py:649 src/views/settings_view.py:136 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:661 msgid "Channels" msgstr "Kanalen" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:254 src/window.py:675 #: src/window.py:678 src/views/map_view.py:1099 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:697 src/window.py:698 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:271 #: src/connection_controller.py:479 src/window.py:1065 src/window.py:1078 #: 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:270 #: src/connection_controller.py:478 src/window.py:1064 src/window.py:1077 #: 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:638 src/window.py:639 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 #: src/views/map_view.py:804 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:387 data/ui/repeater-view.ui:678 #: src/views/contact_detail_dialog.py:84 src/views/device_view.py:336 #: src/views/settings_view.py:134 src/views/map_view.py:824 #: src/views/map_view.py:828 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/__init__.py:771 src/views/contacts_view.py:933 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 #, 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:798 msgid "Flood" msgstr "Vloed" #: 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:748 #: src/views/contacts_view.py:800 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:131 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:1858 msgid "All Messages" msgstr "Alle berichten" #: data/ui/settings-view.ui:55 src/views/channels_view.py:1858 msgid "Mentions Only" msgstr "Alleen Vermeldingen" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:241 #: src/views/channels_view.py:1858 src/views/channels_view.py:1895 #: src/views/settings_view.py:159 src/views/repeater_view.py:1024 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:135 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:132 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:133 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 "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 "" #: 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 "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:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Contacten Automatisch Toevoegen" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "Voeg automatisch knooppunten toe die via aankondigingen worden ontdekt" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "Instellingen Automatisch Toevoegen" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "Aapparaattype, overschrijfbeleid, hoplimiet" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "Chat Knooppunten" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:623 src/views/contacts_view.py:164 msgid "Repeaters" msgstr "Repeaters" #: data/ui/settings-view.ui:336 src/views/__init__.py:624 #: src/views/contacts_view.py:165 msgid "Room Servers" msgstr "Kamerservers" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:625 src/views/contacts_view.py:166 msgid "Sensors" msgstr "Sensoren" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "Oudste overschrijven wanneer vol" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Hoplimiet" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0–63, laat leeg voor geen limiet" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:137 msgid "Telemetry Privacy" msgstr "Telemetrieprivacy" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "Bepaal wat anderen van jouw apparaat mogen opvragen" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "Batterij & Sensoren" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Batterij, spanning, temperatuur, stroom" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "GPS-coördinaten" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Omgeving" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Vochtigheid, druk, luchtkwaliteit" #: data/ui/settings-view.ui:403 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:138 msgid "Regions" msgstr "Regio's" #: data/ui/settings-view.ui:404 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:428 #, fuzzy msgid "Add region…" msgstr "Regio Toevoegen" #: data/ui/settings-view.ui:431 #, fuzzy msgid "Discover regions from repeaters" msgstr "Ontdek Regio's" #: data/ui/settings-view.ui:437 #, fuzzy msgid "Add region" msgstr "Regio Toevoegen" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Standaardbereik" #: data/ui/settings-view.ui:452 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:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:139 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 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:466 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/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Zoeken..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:549 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:332 #: src/views/contacts_view.py:502 src/views/channels_view.py:367 #: src/views/chat_view.py:311 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:166 src/views/chat_view.py:517 msgid "Type a message..." msgstr "Typ een bericht..." #: data/ui/channels-view.ui:10 src/views/channels_view.py:2307 msgid "Create a Private Channel" msgstr "Privékanaal Maken" #: data/ui/channels-view.ui:14 src/views/channels_view.py:2339 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:2391 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:162 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:166 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:320 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:398 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:1054 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:246 src/views/__init__.py:505 #: src/views/contacts_view.py:348 src/views/repeater_view.py:1014 msgid "Add" msgstr "Toevoegen" #: data/ui/repeater-view.ui:487 src/views/repeater_view.py:775 msgid "Loading regions..." msgstr "Regio's worden geladen..." #: data/ui/repeater-view.ui:495 src/views/settings_view.py:1043 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:307 src/views/repeater_view.py:983 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:317 src/views/channels_view.py:1759 #: src/views/channels_view.py:1773 src/views/channels_view.py:2043 #: src/views/chat_helpers.py:110 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 #, 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: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 "" #: 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:1489 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: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 #, 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:2045 #, 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:106 #: src/views/contacts_view.py:267 src/views/contacts_view.py:306 msgid "Discover Contacts" msgstr "Ontdek contacten" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:571 msgid "Search" msgstr "Zoeken" #: data/ui/discover-dialog.ui:27 #, fuzzy msgid "Filter by type" msgstr "Filter" #: data/ui/discover-dialog.ui:36 data/ui/channel-participants-dialog.ui:36 msgid "Sort by" 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 data/ui/custom-tiles-dialog.ui:28 #: src/views/channels_view.py:1780 src/views/map_view.py:799 msgid "Type" msgstr "Type" #: data/ui/contact-detail-dialog.ui:68 src/views/map_view.py:809 #, fuzzy msgid "Copy to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: data/ui/contact-detail-dialog.ui:79 src/views/contacts_view.py:318 #: src/views/channels_view.py:2044 src/views/chat_helpers.py:110 #: src/views/map_view.py:842 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:1306 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" #: 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 "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:1816 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:574 #: src/window.py:674 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 "" #: data/ui/custom-tiles-dialog.ui:33 src/window.py:516 msgid "URL Template" msgstr "" #: data/ui/custom-tiles-dialog.ui:55 #, fuzzy msgid "Reset to Default" msgstr "Pad Opnieuw Instellen" #: 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 knooppunten 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:277 msgid "Scanning..." msgstr "Scannen..." #: data/ui/discover-regions-dialog.ui:61 #, fuzzy msgid "Listening for nearby repeaters" msgstr "Zoeken naar MeshCore-apparaten in de buurt" #: data/ui/discover-regions-dialog.ui:72 src/views/settings_view.py:1030 #: src/views/settings_view.py:1191 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/__init__.py:622 #: src/views/contacts_view.py:163 msgid "Users" msgstr "Gebruikers" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Kamers" #: data/ui/map-sidebar-filters.ui:65 #, fuzzy msgid "Discovered Nodes" msgstr "Ontdek Nabijgelegen 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:341 #: src/views/contacts_view.py:496 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:341 #: src/views/contacts_view.py:496 src/views/device_view.py:949 msgid "Sensor" msgstr "Sensor" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:986 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:112 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 msgid "Password" msgstr "Wachtwoord" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Wachtwoord opslaan" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "Login" #: 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 "Poort" #: data/ui/trace-picker-bottom-bar.ui:27 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 src/views/channels_view.py:1949 #, fuzzy msgid "Clear" msgstr "Log Wissen" #: data/ui/trace-picker-bottom-bar.ui:49 msgid "Done" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 #, fuzzy msgid "Automatically update daily" msgstr "Automatisch starten bij aanmelden" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/application.py:245 #: src/connection_controller.py:159 src/connection_controller.py:288 #: src/connection_controller.py:452 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:347 src/views/contacts_view.py:501 #: src/views/channels_view.py:1948 src/views/channels_view.py:1978 #: src/views/channels_view.py:2228 src/views/channels_view.py:2315 #: src/views/channels_view.py:2348 src/views/channels_view.py:2399 #: src/views/device_view.py:227 src/views/device_view.py:243 #: src/views/settings_view.py:1463 src/views/repeater_view.py:319 #: src/views/repeater_view.py:700 src/views/repeater_view.py:1013 #: src/views/repeater_view.py:1286 msgid "Cancel" msgstr "Annuleren" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:116 src/application.py:189 src/window.py:650 #: src/views/contacts_view.py:341 src/views/contacts_view.py:496 #: src/views/channels_view.py:1813 src/views/device_view.py:946 msgid "Chat" msgstr "Chat" #: src/application.py:124 src/application.py:193 src/views/__init__.py:476 #: src/views/contact_detail_dialog.py:630 src/views/contacts_view.py:1076 #: src/views/channels_view.py:1149 src/views/device_view.py:857 #: src/views/device_view.py:1084 src/views/device_view.py:1092 #: src/views/settings_view.py:1518 msgid "Unknown" msgstr "Onbekend" #: src/application.py:126 src/application.py:163 #, 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 src/application.py:158 msgid "Invalid meshcore:// link" msgstr "Ongeldige meshcore:/- link" #: src/application.py:155 msgid "Invalid channel secret in link" msgstr "Ongeldig kanaalgeheim in link" #: src/application.py:156 msgid "Invalid channel secret length" msgstr "Ongeldige kanaalgeheimlengte" #: src/application.py:160 src/views/channels_view.py:1547 msgid "Hashtag" msgstr "Hashtag" #: src/application.py:160 src/views/channels_view.py:1548 msgid "Private" msgstr "Privé" #: src/application.py:163 msgid "Unnamed" msgstr "Naamloos" #: src/application.py:179 msgid "Invalid public key in link" msgstr "Ongeldige openbare sleutel in link" #: src/application.py:182 msgid "Invalid public key length" msgstr "Ongeldige lengte van de openbare sleutel" #: src/application.py:186 #, python-brace-format msgid "{} is already in your list" msgstr "{} is al in jouw lijst" #: src/application.py:186 msgid "Contact" msgstr "Contact" #: src/application.py:191 #, python-brace-format msgid "Add {} Contact" msgstr "{} Contact toevoegen" #: src/application.py:192 #, python-brace-format msgid "" "Name: {name}\n" "Public Key: {key}…" msgstr "" "Naam: {name}\n" "Openbare Sleutel: {key}…" #: src/application.py:197 #, python-brace-format msgid "Contact {} added" msgstr "Contact {} toegevoegd" #: src/application.py:202 msgid "Unrecognized meshcore:// link" msgstr "Niet-herkende meshcore://-link" #: src/application.py:318 src/application.py:339 src/application.py:363 msgid "Receiving messages in the background" msgstr "Berichten ontvangen op de achtergrond" #: src/application.py:432 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:150 src/connection_controller.py:291 #: src/window.py:1087 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:278 msgid "Stop" msgstr "Stop" #: src/connection_controller.py:284 src/connection_controller.py:448 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Opnieuw verbinding maken ({current}/{total})..." #: src/connection_controller.py:302 msgid "Syncing..." msgstr "Synchroniseren..." #: src/connection_controller.py:337 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:523 #, fuzzy msgid "Syncing channels" msgstr "Geen kanalen" #: src/connection_controller.py:595 #, fuzzy, 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:325 src/frame_handler.py:343 #, fuzzy msgid "Syncing contacts" msgstr "Geen contacten" #: src/frame_handler.py:499 #, python-brace-format msgid "Device error: {}" msgstr "Apparaatfout: {}" #: src/frame_handler.py:705 #, fuzzy, python-brace-format msgid "{} (late)" msgstr "{}s (later)" #: src/frame_handler.py:705 msgid "late" msgstr "later" #: src/frame_handler.py:776 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "Apparaatklok liep {}min achter — gesynchroniseerd" #: src/frame_handler.py:784 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Apparaatklok loopt {}min voor op het systeem" #: src/frame_handler.py:1292 src/window.py:793 src/views/__init__.py:748 #: src/views/contacts_view.py:916 src/views/channels_view.py:1647 #: src/views/channels_view.py:1753 src/views/channels_view.py:1774 #: src/views/channels_view.py:1945 src/views/channels_view.py:1975 #, python-brace-format msgid "Channel {}" msgstr "Kanaal {}" #: src/frame_handler.py:1293 msgid "Someone" msgstr "Iemand" #: src/frame_handler.py:1296 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:187 src/views/repeater_view.py:658 #: src/views/repeater_view.py:689 msgid "Me" msgstr "Ik" #: src/message_controller.py:230 msgid "flood" msgstr "flood" #: src/message_controller.py:232 src/views/channels_view.py:500 #: src/views/channels_view.py:1234 msgid "direct" msgstr "direct" #: src/message_controller.py:234 msgid "path" msgstr "pad" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, fuzzy, python-brace-format msgid "waiting {} (radio busy)" msgstr "wachten {:.1f}s (radio bezet)" #: src/window.py:498 msgid "Raster" msgstr "" #: src/window.py:498 msgid "Vector" msgstr "" #: src/window.py:506 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:510 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:516 msgid "Style JSON URL" msgstr "" #: src/window.py:539 msgid "Custom map tiles applied" msgstr "" #: src/window.py:541 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:577 msgid "Sort" msgstr "Sorteren" #: src/window.py:637 src/views/contact_detail_dialog.py:157 #: src/views/channels_view.py:1935 src/views/repeater_view.py:873 msgid "Actions" msgstr "Acties" #: src/window.py:662 msgid "Channel" msgstr "Kanaal" #: src/window.py:696 msgid "Backup & Restore" msgstr "Back-up & Terugzetten" #: src/window.py:774 src/window.py:838 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:795 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:860 msgid "Management" msgstr "Beheer" #: src/window.py:860 msgid "Guest" msgstr "Gast" #: src/window.py:861 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:962 msgid "Excellent" msgstr "" #: src/window.py:963 msgid "Good" msgstr "" #: src/window.py:964 msgid "Fair" msgstr "" #: src/window.py:965 msgid "Poor" msgstr "" #: src/window.py:966 msgid "Very poor" msgstr "" #: src/window.py:1194 msgid "flood routed" msgstr "flood gerouteerd" #: src/window.py:1194 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1195 #, python-brace-format msgid "Advert sent ({})" msgstr "Aankondiging verzonden ({})" #: src/window.py:1685 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Kanaal \"{}\" toegevoegd" #: src/window.py:1687 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:274 src/views/contacts_view.py:387 #: src/views/contacts_view.py:481 src/views/channels_view.py:992 #: src/views/channels_view.py:1215 src/views/channels_view.py:2297 #: src/views/channels_view.py:2372 src/views/settings_view.py:1308 #: src/views/settings_view.py:1418 src/views/settings_view.py:1447 #: src/views/settings_view.py:1458 src/views/settings_view.py:1513 #: src/views/chat_helpers.py:128 msgid "OK" msgstr "OK" #: src/views/__init__.py:52 msgid "Today" msgstr "" #: src/views/__init__.py:54 msgid "Yesterday" msgstr "" #: src/views/__init__.py:59 msgid "%A, %B %-d" msgstr "" #: src/views/__init__.py:62 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:507 src/views/contacts_view.py:226 #, python-brace-format msgid "{total} contacts" msgstr "{total} contacten" #: src/views/__init__.py:509 src/views/contacts_view.py:222 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "{visible}/{total} contacten" #: src/views/__init__.py:621 src/views/contacts_view.py:161 #: src/views/channels_view.py:1545 src/views/channels_view.py:2036 msgid "All" msgstr "Alle" #: src/views/__init__.py:635 msgid "Distance" msgstr "" #: src/views/__init__.py:790 #, python-brace-format msgid "Login to {}" msgstr "Inloggen op {}" #: src/views/__init__.py:822 msgid "Login failed — wrong password" msgstr "Inloggen mislukt - verkeerd wachtwoord" #: src/views/__init__.py:829 msgid "Login timed out" msgstr "Er is een time-out voor inloggen opgetreden" #: src/views/__init__.py:836 msgid "Retrying via flood..." msgstr "Opnieuw proberen via flood..." #: src/views/__init__.py:841 msgid "Logging in..." msgstr "Inloggen..." #: 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:1979 #: src/views/repeater_view.py:701 src/views/repeater_view.py:890 msgid "Remove" msgstr "Verwijder" #: src/views/connection_view.py:80 #, fuzzy msgid "Bluetooth not available" msgstr "Bluetooth is uitgeschakeld" #: 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 "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 "" #: 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. " "Klik op \"Installeer Regel\" om deze te installeren (beheerderswachtwoord " "vereist)." #: src/views/connection_view.py:280 msgid "Close" msgstr "" #: src/views/connection_view.py:281 #, fuzzy msgid "Copy Command" msgstr "CLI-opdracht..." #: src/views/connection_view.py:289 #, fuzzy msgid "Command copied to clipboard" msgstr "Privésleutel 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 #, fuzzy msgid "Change Address" msgstr "IP-adres" #: 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 "" #: 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:2165 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 "Repeater Management" msgstr "Repeaterbeheer" #: src/views/contact_detail_dialog.py:163 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Login and access CLI, settings, and regions" msgstr "" #: 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 dit knooppunt via flood" #: src/views/contact_detail_dialog.py:220 #, fuzzy msgid "Query regions from this repeater" msgstr "{} repeater opvragen..." #: src/views/contact_detail_dialog.py:230 #, fuzzy msgid "Querying..." msgstr "Zoeken..." #: src/views/contact_detail_dialog.py:243 src/views/contacts_view.py:774 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 #, fuzzy, python-format msgid "%d paths available" msgstr "Geen Slots Beschikbaar" #: src/views/contact_detail_dialog.py:389 #: src/views/contact_detail_dialog.py:435 #: src/views/contact_detail_dialog.py:527 #, fuzzy msgid "No paths yet" msgstr "Er is geen pad in de cache opgeslagen" #: 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 #, fuzzy, python-format msgid "%d hops" msgstr "hops" #: src/views/contact_detail_dialog.py:513 #, fuzzy msgid "No paths recorded yet" msgstr "Nog geen herhalingen gehoord." #: src/views/contact_detail_dialog.py:514 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:521 #, fuzzy msgid "Clear History" msgstr "Log Wissen" #: src/views/contact_detail_dialog.py:591 #, fuzzy msgid "No location data available" msgstr "Locatie niet beschikbaar" #: 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:966 #: src/views/contacts_view.py:1070 src/views/channels_view.py:1142 #: src/views/device_view.py:851 src/views/sensor_view.py:148 #: src/views/chat_helpers.py:73 src/views/map_view.py:629 #: src/views/map_view.py:979 src/views/map_view.py:1315 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:107 msgid "Add Manually" msgstr "Handmatig Toevoegen" #: src/views/contacts_view.py:108 msgid "Import from Clipboard" msgstr "Importeren vanaf Klembord" #: src/views/contacts_view.py:162 msgid "Favourites" msgstr "Favorieten" #: src/views/contacts_view.py:177 src/views/channels_view.py:1558 msgid "A–Z" msgstr "A–Z" #: src/views/contacts_view.py:178 msgid "Heard Recently" msgstr "Onlangs Gehoord" #: src/views/contacts_view.py:179 src/views/channels_view.py:1559 msgid "Latest Messages" msgstr "Laatste Berichten" #: src/views/contacts_view.py:186 msgid "Favorites First" msgstr "Favorieten Eerst" #: src/views/contacts_view.py:228 #, python-brace-format msgid "{total} contact" msgstr "{total} contact" #: src/views/contacts_view.py:269 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:301 src/views/map_view.py:842 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:310 #, fuzzy, python-brace-format msgid "{total} discovered" msgstr "{total} contact" #: src/views/contacts_view.py:311 #, fuzzy, python-brace-format msgid "{visible}/{total} discovered" msgstr "{visible}/{total} contacten" #: src/views/contacts_view.py:319 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:333 msgid "Enter the contact details." msgstr "Voer de contactgegevens in." #: src/views/contacts_view.py:341 src/views/contacts_view.py:496 #: src/views/device_view.py:948 msgid "Room Server" msgstr "Kamerserver" #: src/views/contacts_view.py:360 src/views/contacts_view.py:445 #: src/views/contacts_view.py:470 msgid "This contact is already in your list." msgstr "Dit contact staat al in uw lijst." #: src/views/contacts_view.py:384 src/views/contacts_view.py:480 #: src/views/settings_view.py:1510 msgid "Import Failed" msgstr "Importeren Mislukt" #: src/views/contacts_view.py:385 msgid "No meshcore:// link found in clipboard." msgstr "Geen meshcore://-link gevonden op het klembord." #: src/views/contacts_view.py:416 src/views/contacts_view.py:417 msgid "Invalid channel secret in QR code." msgstr "Ongeldig kanaalgeheim in QR-code." #: src/views/contacts_view.py:419 msgid "Invalid channel QR code." msgstr "" #: src/views/contacts_view.py:435 msgid "Invalid public key in QR code." msgstr "Ongeldige openbare sleutel in QR-code." #: src/views/contacts_view.py:440 #, python-brace-format msgid "Public key must be 32 bytes, got {}." msgstr "Openbare sleutel moet 32 bytes zijn, kreeg {}." #: src/views/contacts_view.py:477 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Kon QR-codegegevens niet parseren: \n" "{}" #: src/views/contacts_view.py:487 msgid "QR Code Scanned" msgstr "QR-Code Gescand" #: src/views/contacts_view.py:488 #, python-brace-format msgid "Public key: {}...{}" msgstr "Openbare sleutel: {}...{}" #: src/views/contacts_view.py:715 msgid "Searching..." msgstr "Zoeken..." #: src/views/contacts_view.py:754 src/views/channels_view.py:506 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/views/contacts_view.py:768 msgid "Path found" msgstr "Gevonden pad" #: src/views/contacts_view.py:783 msgid "Loading..." msgstr "Laden..." #: src/views/contacts_view.py:793 msgid "No path cached" msgstr "Er is geen pad in de cache opgeslagen" #: src/views/contacts_view.py:806 msgid "Not supported" msgstr "Niet ondersteund" #: src/views/contacts_view.py:817 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:840 #, python-brace-format msgid "Ping {}: {}" msgstr "Ping {}: {}" #: src/views/contacts_view.py:851 #, python-brace-format msgid "Ping {}: timed out" msgstr "Ping {}: timed-out" #: src/views/contacts_view.py:864 src/views/repeater_view.py:502 #: src/views/repeater_view.py:637 src/views/sensor_view.py:56 msgid "Requesting..." msgstr "Aanvragen..." #: src/views/contacts_view.py:869 src/views/device_view.py:375 #: src/views/sensor_view.py:190 msgid "No response (timed out)" msgstr "Geen reactie (time-out)" #: src/views/contacts_view.py:878 src/views/contacts_view.py:882 #: src/views/device_view.py:385 src/views/device_view.py:389 #: src/views/sensor_view.py:98 src/views/sensor_view.py:105 msgid "No telemetry data" msgstr "Geen telemetriegegevens" #: src/views/contacts_view.py:885 src/views/device_view.py:404 msgid "Telemetry received" msgstr "Telemetrie ontvangen" #: src/views/contacts_view.py:908 src/views/repeater_view.py:433 #, python-brace-format msgid "Telemetry — {}" msgstr "Telemetrie — {}" #: src/views/contacts_view.py:1049 #, python-brace-format msgid "Path to {}" msgstr "Pad naar {}" #: src/views/contacts_view.py:1101 src/views/channels_view.py:1041 #: src/views/channels_view.py:1150 src/views/device_view.py:858 #: src/views/map_view.py:1014 msgid "Unknown repeater" msgstr "Onbekende repeater" #: src/views/channels_view.py:153 src/views/channels_view.py:1212 #: src/views/channels_view.py:1219 msgid "Heard Repeats" msgstr "Gehoorde Herhalingen" #: src/views/channels_view.py:154 src/views/chat_view.py:102 msgid "Send Again" msgstr "Opnieuw Verzenden" #: src/views/channels_view.py:155 src/views/channels_view.py:161 #: src/views/chat_view.py:103 msgid "Delete" msgstr "Verwijderen" #: src/views/channels_view.py:158 msgid "Reply" msgstr "Antwoord" #: src/views/channels_view.py:159 msgid "Copy Text" msgstr "Tekst Kopiëren" #: src/views/channels_view.py:160 msgid "View Message Paths" msgstr "Berichtpaden Bekijken" #: src/views/channels_view.py:210 msgid "Send" msgstr "" #: src/views/channels_view.py:211 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:221 src/views/chat_helpers.py:101 #: src/views/chat_view.py:162 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:222 src/views/chat_helpers.py:143 #: src/views/chat_view.py:163 msgid "Share Location" msgstr "" #: src/views/channels_view.py:223 src/views/chat_view.py:164 #, fuzzy msgid "Share Location from Map" msgstr "Locatie handmatig instellen" #: src/views/channels_view.py:349 src/views/chat_view.py:293 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:353 src/views/chat_view.py:297 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:367 src/views/channels_view.py:480 #: src/views/chat_view.py:311 src/views/chat_view.py:423 msgid "Already added" msgstr "" #: src/views/channels_view.py:384 src/views/chat_view.py:328 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:388 src/views/chat_view.py:332 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:448 src/views/chat_view.py:391 msgid "New Messages" msgstr "Nieuwe Berichten" #: src/views/channels_view.py:489 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:517 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:988 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:1062 msgid "Direct message" msgstr "Direct bericht" #: src/views/channels_view.py:1063 msgid "No repeaters were used" msgstr "Er zijn geen repeaters gebruikt" #: src/views/channels_view.py:1070 msgid "No repeater details" msgstr "Geen repeatergegevens" #: src/views/channels_view.py:1071 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:1120 msgid "Message Path" msgstr "Berichtpad" #: src/views/channels_view.py:1213 msgid "No repeats heard yet." msgstr "Nog geen herhalingen gehoord." #: src/views/channels_view.py:1546 src/views/channels_view.py:2382 msgid "Public" msgstr "Openbaar" #: src/views/channels_view.py:1593 #, python-brace-format msgid "{visible}/{total} channels" msgstr "{visible}/{total} kanalen" #: src/views/channels_view.py:1597 #, python-brace-format msgid "{total} channels" msgstr "{total} kanalen" #: src/views/channels_view.py:1599 #, python-brace-format msgid "{total} channel" msgstr "{total} kanaal" #: src/views/channels_view.py:1756 msgid "Channel Info" msgstr "Kanaalinformatie" #: src/views/channels_view.py:1788 msgid "Private Key" msgstr "Privésleutel" #: src/views/channels_view.py:1799 msgid "Private key copied to clipboard" msgstr "Privésleutel gekopieerd naar klembord" #: src/views/channels_view.py:1817 src/views/channels_view.py:2113 #: src/views/channels_view.py:2119 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1830 #, fuzzy msgid "Message Retention" msgstr "Berichtenwachtrij" #: src/views/channels_view.py:1833 msgid "1 Day" msgstr "" #: src/views/channels_view.py:1834 msgid "1 Week" msgstr "" #: src/views/channels_view.py:1835 msgid "1 Month" msgstr "" #: src/views/channels_view.py:1836 msgid "3 Months" msgstr "" #: src/views/channels_view.py:1837 #, fuzzy msgid "Forever" msgstr "Nooit" #: src/views/channels_view.py:1856 msgid "Notification Level" msgstr "Meldingsniveau" #: src/views/channels_view.py:1858 msgid "Default" msgstr "Standaard" #: src/views/channels_view.py:1888 src/views/channels_view.py:1893 msgid "Region" msgstr "Regio" #: src/views/channels_view.py:1889 msgid "Limit flood messages to a specific region" msgstr "Beperk flood-berichten tot een specifieke regio" #: src/views/channels_view.py:1917 #, fuzzy msgid "No regions defined" msgstr "Geen regio's gevonden" #: src/views/channels_view.py:1918 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:1937 #, fuzzy msgid "Clear Messages" msgstr "Alle berichten" #: src/views/channels_view.py:1943 #, fuzzy msgid "Clear Messages?" msgstr "Alle berichten" #: src/views/channels_view.py:1944 #, python-brace-format msgid "Delete all messages in \"{}\"?" msgstr "" #: src/views/channels_view.py:1957 #, fuzzy msgid "Messages cleared" msgstr "Berichtenwachtrij" #: src/views/channels_view.py:1967 msgid "Remove Channel" msgstr "Kanaal verwijderen" #: src/views/channels_view.py:1973 msgid "Remove Channel?" msgstr "Kanaal verwijderen?" #: src/views/channels_view.py:1974 #, python-brace-format msgid "Remove \"{}\" and all its messages?" msgstr "'{}' en alle bijbehorende berichten verwijderen?" #: src/views/channels_view.py:2036 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2115 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2145 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2151 src/views/channels_view.py:2229 msgid "Block" msgstr "" #: src/views/channels_view.py:2160 #, fuzzy, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} berichten" msgstr[1] "{} berichten" #: src/views/channels_view.py:2176 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2221 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2223 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:2230 #, fuzzy msgid "Block and Delete Messages" msgstr "Laatste Berichten" #: src/views/channels_view.py:2247 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2294 msgid "No Slots Available" msgstr "Geen Slots Beschikbaar" #: src/views/channels_view.py:2295 msgid "All 8 channel slots are in use." msgstr "Alle 8 kanaalslots zijn in gebruik." #: src/views/channels_view.py:2308 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:2316 msgid "Create" msgstr "Aanmaken" #: src/views/channels_view.py:2340 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:2349 src/views/channels_view.py:2400 msgid "Join" msgstr "Lid Worden" #: src/views/channels_view.py:2370 msgid "Already Joined" msgstr "Al Lid" #: src/views/channels_view.py:2370 msgid "You are already on the public channel." msgstr "Je bent al op het openbare kanaal." #: src/views/channels_view.py:2392 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:1287 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:392 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 "" #: 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 "" #: 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 #, fuzzy msgid "No repeaters with known location" msgstr "Geen repeaters in Contacten" #: src/views/device_view.py:683 #, fuzzy, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Trace voltooid: {} hops, {:.1f}s" #: src/views/device_view.py:689 msgid "Result" msgstr "" #: 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 src/views/map_view.py:847 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 "Knooppunt" #: src/views/device_view.py:1112 #, python-brace-format msgid "Done. {} nodes found." msgstr "Klaar. {} knoppunten gevonden." #: src/views/device_view.py:1114 #, python-brace-format msgid "Done. {} node found." msgstr "Klaar. {} knoppunt gevonden." #: 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 "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:144 msgid "Custom" msgstr "Aangepast" #: src/views/settings_view.py:152 msgid "Deny All" msgstr "Alles Weigeren" #: src/views/settings_view.py:152 msgid "Allow per Contact" msgstr "Toestaan per Contact" #: src/views/settings_view.py:152 msgid "Allow All" msgstr "Iedereen toestaan" #: src/views/settings_view.py:575 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:714 msgid "Settings applied on device" msgstr "Instellingen toegepast op apparaat" #: src/views/settings_view.py:739 msgid "Unknown error" msgstr "Onbekende fout" #: src/views/settings_view.py:740 #, python-brace-format msgid "Settings error: {}" msgstr "Instellingenfout: {}" #: src/views/settings_view.py:764 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "TX-vermogen (dBm, max. {})" #: src/views/settings_view.py:859 msgid "Location unavailable" msgstr "Locatie niet beschikbaar" #: src/views/settings_view.py:863 msgid "Location filled, click Apply to save" msgstr "Locatie ingevuld, klik op Toepassen om op te slaan" #: src/views/settings_view.py:886 msgid "Pick Location" msgstr "Kies Locatie" #: src/views/settings_view.py:886 msgid "Set Location" msgstr "Locatie Instellen" #: src/views/settings_view.py:996 msgid "Connect to a device first" msgstr "Maak eerst verbinding met een apparaat" #: src/views/settings_view.py:1008 src/views/settings_view.py:1190 #, fuzzy msgid "Discovering repeaters in range..." msgstr "{} repeaters opvragen..." #: src/views/settings_view.py:1026 #, python-brace-format msgid "Found {} regions" msgstr "{} regio's gevonden" #: src/views/settings_view.py:1028 #, python-brace-format msgid "Found {} region" msgstr "{} regio gevonden" #: src/views/settings_view.py:1036 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1040 msgid "No regions found" msgstr "Geen regio's gevonden" #: src/views/settings_view.py:1042 #, fuzzy msgid "No repeaters in range" msgstr "Geen repeaters in Contacten" #: src/views/settings_view.py:1058 src/views/settings_view.py:1067 #, python-brace-format msgid "from {}" msgstr "van {}" #: src/views/settings_view.py:1060 src/views/settings_view.py:1069 msgid "(already added)" msgstr "(al toegevoegd)" #: src/views/settings_view.py:1139 #, python-brace-format msgid "Querying {} repeaters..." msgstr "{} repeaters opvragen..." #: src/views/settings_view.py:1141 #, python-brace-format msgid "Querying {} repeater..." msgstr "{} repeater opvragen..." #: src/views/settings_view.py:1235 #, python-brace-format msgid "Added {} regions" msgstr "{} regio's toegevoegd" #: src/views/settings_view.py:1237 #, python-brace-format msgid "Added {} region" msgstr "{} regio toegevoegd" #: src/views/settings_view.py:1305 src/views/settings_view.py:1415 #: src/views/settings_view.py:1444 msgid "Not Connected" msgstr "Niet Verbonden" #: src/views/settings_view.py:1306 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:1344 src/views/settings_view.py:1425 msgid "JSON files" msgstr "JSON bestanden" #: src/views/settings_view.py:1357 msgid "Backup exported successfully" msgstr "Back-up succesvol geëxporteerd" #: src/views/settings_view.py:1361 #, python-brace-format msgid "Export failed: {}" msgstr "Exporteren mislukt: {}" #: src/views/settings_view.py:1371 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1416 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:1445 #, 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:1455 #, fuzzy msgid "No Auto Backups" msgstr "Back-up Exporteren" #: src/views/settings_view.py:1456 msgid "No automatic backups found for this device." msgstr "" #: src/views/settings_view.py:1462 msgid "Pick Snapshot" msgstr "" #: src/views/settings_view.py:1511 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Kon back-upbestand niet lezen: {}" #: src/views/settings_view.py:1527 #, python-brace-format msgid "Source: {}" msgstr "Bron: {}" #: src/views/settings_view.py:1558 #, python-brace-format msgid "{} contacts" msgstr "{} contacten" #: src/views/settings_view.py:1560 #, python-brace-format msgid "{} channels" msgstr "{} kanalen" #: src/views/settings_view.py:1564 msgid "messages" msgstr "berichten" #: src/views/settings_view.py:1566 msgid "device identity" msgstr "" #: src/views/settings_view.py:1568 #, python-brace-format msgid "Imported: {}" msgstr "Geïmporteerd: {}" #: src/views/settings_view.py:1570 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:151 msgid "sensor" msgstr "" #: src/views/repeater_view.py:152 #, fuzzy msgid "repeater" msgstr "Repeater" #: src/views/repeater_view.py:305 #, fuzzy, python-brace-format msgid "Time sync sent to {}" msgstr "Tijdsynchronisatie verzonden naar repeater" #: src/views/repeater_view.py:312 msgid "Reset Clock & Reboot?" msgstr "Klok Resetten en Opnieuw Opstarten?" #: src/views/repeater_view.py:314 #, fuzzy 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 "" "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:329 msgid "Rebooting..." msgstr "Opnieuw opstarten..." #: src/views/repeater_view.py:331 #, fuzzy, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "Klokreset en herstart naar repeater verzonden" #: src/views/repeater_view.py:344 src/views/repeater_view.py:511 #: src/views/repeater_view.py:643 src/views/repeater_view.py:820 msgid "Request timed out" msgstr "Verzoek is verlopen" #: src/views/repeater_view.py:535 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} van {} buren" #: src/views/repeater_view.py:552 msgid "Neighbor Map" msgstr "Burenkaart" #: src/views/repeater_view.py:678 #, python-brace-format msgid "{} entry" msgstr "{} vermelding" #: src/views/repeater_view.py:678 #, python-brace-format msgid "{} entries" msgstr "{} vermeldingen" #: src/views/repeater_view.py:697 msgid "Remove from ACL?" msgstr "Uit ACL Verwijderen?" #: src/views/repeater_view.py:698 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "{} verwijderen uit de toegangscontrolelijst?" #: src/views/repeater_view.py:720 msgid "Role" msgstr "Rol" #: src/views/repeater_view.py:721 msgid "Admin" msgstr "Beheerder" #: src/views/repeater_view.py:721 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:721 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:746 msgid "Add to Access Control" msgstr "Voeg toe aan Toegangscontrole" #: src/views/repeater_view.py:848 msgid "Global (wildcard)" msgstr "Globaal (wildcard)" #: src/views/repeater_view.py:851 msgid "Home" msgstr "Thuis" #: src/views/repeater_view.py:852 msgid "Flood allowed" msgstr "Flood toegestaan" #: src/views/repeater_view.py:852 msgid "Flood denied" msgstr "Flood geweigerd" #: src/views/repeater_view.py:885 msgid "Deny Flood" msgstr "Flood Weigeren" #: src/views/repeater_view.py:885 msgid "Allow Flood" msgstr "Flood Toelaten" #: src/views/repeater_view.py:889 msgid "Set as Home" msgstr "Instellen als Thuis" #: src/views/repeater_view.py:941 msgid "Remove child regions first" msgstr "Verwijder eerst onderliggende regio's" #: src/views/repeater_view.py:979 msgid "Unsaved Changes" msgstr "Niet-opgeslagen wijzigingen" #: src/views/repeater_view.py:980 msgid "You have unsaved region changes." msgstr "U heeft niet-opgeslagen regiowijzigingen." #: src/views/repeater_view.py:982 msgid "Discard" msgstr "Weggooien" #: src/views/repeater_view.py:1010 msgid "Add Region" msgstr "Regio Toevoegen" #: src/views/repeater_view.py:1011 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:1208 msgid "Admin password changed — re-login required" msgstr "Beheerderswachtwoord gewijzigd — opnieuw inloggen vereist" #: src/views/repeater_view.py:1275 #, python-brace-format msgid "Settings sent to {}" msgstr "Instellingen verzonden naar {}" #: src/views/repeater_view.py:1279 #, fuzzy, python-brace-format msgid "Reboot {}?" msgstr "Opnieuw Opstarten" #: src/views/repeater_view.py:1282 #, python-brace-format msgid "Reboot {}? It will be offline briefly." msgstr "{} opnieuw opstarten? Die zal kort offline zijn." #: src/views/repeater_view.py:1424 msgid "Time is off" msgstr "Tijd wijkt af" #: src/views/chat_helpers.py:60 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:62 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:104 src/views/chat_helpers.py:144 msgid "Share" msgstr "" #: src/views/chat_helpers.py:125 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:126 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:148 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:149 #, fuzzy msgid "Send Direct" msgstr "Direct" #: src/views/chat_view.py:150 #, fuzzy msgid "Send Flood" msgstr "Flood Weigeren" #: src/views/chat_view.py:430 src/views/chat_view.py:431 msgid "Sending" msgstr "Verzenden" #: src/views/chat_view.py:515 msgid "Read-only room" msgstr "" #: src/views/map_view.py:350 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} van {} contacten op kaart" #: src/views/map_view.py:352 #, python-brace-format msgid "{} of {} located on map" msgstr "{} van {} gelegen op de kaart" #: src/views/map_view.py:354 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:587 #, python-brace-format msgid "and {} more" msgstr "en {} meer" #: src/views/map_view.py:823 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:831 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:831 msgid "SNR" msgstr "" #: src/views/map_view.py:1204 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1392 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:137 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" #~ msgid "Login and access status, CLI, neighbors, and settings" #~ msgstr "Log in en krijg toegang tot status, CLI, buren, en instellingen" #~ msgid "Login successful" #~ msgstr "Inloggen succesvol" #, fuzzy, python-brace-format #~ msgid "Logged in to {}" #~ msgstr "Inloggen op {}" #, fuzzy #~ msgid "Login failed" #~ msgstr "Er is een time-out voor inloggen opgetreden" #, python-brace-format #~ msgid "" #~ "Name: {name}\n" #~ "Type: Private" #~ msgstr "" #~ "Naam: {name}\n" #~ "Type: Privé" #~ msgid "Channel secret must be 16 bytes, got {}." #~ msgstr "Kanaalgeheim moet 16 bytes zijn, kreeg {}." #, fuzzy #~ msgid "SNR: {} dB" #~ msgstr "SNR: {:.1f} dB" #~ msgid "Add Region (e.g. Prague)" #~ msgstr "Regio toevoegen (bijv. nl)" #~ 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" #, fuzzy #~ msgid "{} min ago" #~ msgid_plural "{} min ago" #~ msgstr[0] "{}m geleden" #~ msgstr[1] "{}m geleden" #, fuzzy #~ msgid "{} hour ago" #~ msgid_plural "{} hours ago" #~ msgstr[0] "{}h geleden" #~ msgstr[1] "{}h geleden" #, fuzzy #~ msgid "{} day ago" #~ msgid_plural "{} days ago" #~ msgstr[0] "{}d geleden" #~ msgstr[1] "{}d geleden" #, 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" #~ 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 "Select a contact and role." #~ msgstr "Selecteer een contact en een rol." #~ msgid "Reboot Repeater?" #~ msgstr "Repeater opnieuw opstarten?" #, 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.po000066400000000000000000002477131526212641700136200ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/pt.po000066400000000000000000002473271526212641700136310ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/ro.po000066400000000000000000002474301526212641700136210ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: ro\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==0 || (n%100 > 0 && 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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/ru.po000066400000000000000000003700471526212641700136300ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-09-04 10:19+0000\n" "Last-Translator: Sanyanov \n" "Language-Team: Russian \n" "Language: ru\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 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" "X-Generator: Weblate 2026.8.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 "Клиент mesh-сети MeshCore" #: data/page.codeberg.sesivany.Meshy.desktop.in:11 msgid "mesh;lora;radio;chat;" msgstr "Mesh;LoRa;Радио;Чат;" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:11 #, fuzzy msgid "" "Meshy is a client for MeshCore 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 the mesh network." msgstr "" "Meshy - это клиент для LoRa устройств с прошивкой MeshCore. Подключитесь к " "своему устройству через Bluetooth, чтобы получать и отправлять зашифрованные " "сообщения, управлять контактами, настраивать каналы, и следить за своей mesh-" "сетью." #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "Особенности:" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 #, fuzzy msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "Возможность соединения по Bluetooth LE и USB" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "Оконечное шифрование сообщений" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "Публичные, приватные и хэштег-каналы" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "Управление контактами с учётом местоположения" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "Интерактивная карта с трассировкой маршрута" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "Управление конечными устройствами и ретрансляторами" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "Сканирование QR-кодов для быстрого обмена контактами" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "Jiri Eischmann" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "Чаты каналов" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "Управление ретрансляторами" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "Настройки - Тёмная тема" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "Навигация" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "Устройство" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "Контакты" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "Каналы" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "Карта" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "Настройки" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "Сообщения" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "Поиск контактов" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "Выбрать окно сообщения" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "Предыдущий контакт/канал" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "Следующий контакт/канал" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "Прошлые непрочитанные" #: data/gtk/help-overlay.ui:113 msgid "Next Unread" msgstr "Следующие непрочитанные" #: data/ui/window.ui:27 data/ui/window.ui:48 msgid "About Meshy" msgstr "О Meshy" #: data/ui/window.ui:78 data/ui/window.ui:330 src/connection_controller.py:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "Нет соединения" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "Статус" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "Отключено" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "Имя узла" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "Чип" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "Прошивка" #: data/ui/device-view.ui:102 msgid "Update" msgstr "Обновление" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "Открыть инструмент прошивки" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "Публичный ключ" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "Сохранить в буфер обмена" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "Телеметрия" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "Запросить телеметрию" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "Расположение" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "Синхронизация…" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "Показать на карте" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "Батарея" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "Хранилище" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "Радио" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "Частота" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "Полоса пропускания" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "Коэффициент расширения" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "Коэффициент кодирования" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "Мощность передачи" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "Размер хэша маршрута по умолчанию" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "Статистика" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "Уровень шума" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 #, fuzzy msgid "Show live graph" msgstr "Показать на карте" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "Последний RSSI" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "Последний SNR" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "Время передачи" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "Пакеты" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "Широковещательно" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "Направленно" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "Время работы" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "Очередь сообщений" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "Обновить" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "Обновить статистику" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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 "MeshCore Dark" #: 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:2147 msgid "All Messages" msgstr "Все сообщения" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "Только упоминания" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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:81 msgid "LoRa radio parameters and regional presets" msgstr "Параметры LoRa и региональные настройки" #: data/ui/settings-view.ui:84 msgid "Regional Preset" msgstr "Региональные настройки" #: data/ui/settings-view.ui:89 msgid "Radio 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:723 msgid "Frequency (MHz)" msgstr "Частота (МГц)" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "Полоса пропускания (кГц)" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "Мощность передачи (дБм)" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "Стандартный" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "1 байт (максимум 64 хопа)" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "2 байта (максимум 32 хопа)" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 msgid "3-byte (max 21 hops)" msgstr "3 байта (максимум 21 хоп)" #: 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 "Количество отправляемых ACK при получении личных сообщений" #: data/ui/settings-view.ui:240 msgid "Set your companion's position via GPS or manually" msgstr "Установить расположение узла через GPS или вручную" #: data/ui/settings-view.ui:243 msgid "Companion GPS" msgstr "GPS-модуль узла" #: data/ui/settings-view.ui:244 msgid "Enable GPS on the companion, your position will update automatically" msgstr "Включить GPS на узле. Ваша позиция обновится автоматически" #: 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:678 msgid "Latitude" msgstr "Широта" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "Долгота" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "Использовать моё расположение" #: data/ui/settings-view.ui:265 #, fuzzy msgid "Set from this device's location" msgstr "Установить по расположению компьютера" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "Выбрать на карте" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "Выбрать расположение на карте" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "Обнаружение и автоматическое добавление контактов" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "Автоматически добавлять новые контакты" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "Автоматически добавлять узлы, объявления которых получены" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "Настройки автоматического добавления" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "Типы устройств, перезапись, максимум хопов" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "Узлы чатов" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "Ретрансляторы" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "Серверы комнат" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "Датчики" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "Перезаписывать самые старые контакты" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "Максимум хопов" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "0–63, оставьте пустым для отключения лимита" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "Онлайн-база данных узлов" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "Скачать информацию об узлах с map.meshcore.io" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "Приватность телеметрии" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "Управление информацией, доступной для других устройств" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "Батарея и датчики" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "Батарея, напряжение,температура, ток" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "Координаты GPS" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "Окружающая среда" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "Влажность, давление, качество воздуха" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "Регионы" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "Ограничить широковещательные сообщения в каналах конкретным регионом" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "Добавить регион…" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "Уточнить регион через ретрансляторы" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "Добавить регион" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "Диапазон по умолчанию" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" "При выборе данной настройки все широковещательные пакеты будут ограничены " "заданным регионом" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "Bluetooth" #: data/ui/settings-view.ui:463 msgid "" "After changing the PIN, restart the device, then unpair and re-pair. Setting " "0 resets the PIN to default." msgstr "" "После смены PIN, перезагрузите устройство, затем удалите его из сохранённых " "устройств и подключите его снова. Установка значения 0 сбрасывает PIN до " "значений по умолчанию." #: data/ui/settings-view.ui:466 msgid "Pairing PIN" msgstr "PIN подключения" #: data/ui/connection-view.ui:23 msgid "Connect to a companion device to start using Meshy." msgstr "Подключитесь к узлу, чтобы начать использовать Meshy." #: data/ui/connection-view.ui:48 msgid "Pair with a New Companion" msgstr "Подключиться к новому узлу" #: data/ui/connection-view.ui:56 msgid "USB Serial" msgstr "USB-подключение" #: data/ui/connection-view.ui:71 msgid "WiFi / TCP" msgstr "WiFi / TCP" #: data/ui/connection-view.ui:84 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "Добавить узел через TCP" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "Поиск..." #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "Нет контактов" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "Добавить контакт" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "Пока что здесь нет контактов" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "Поделиться" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "Напишите сообщение..." #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "Создать приватный канал" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "Присоединиться к приватному каналу" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "Присоединиться к публичному каналу" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "Присоединиться к хэштег-каналу" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "Вставить из буфера обмена" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "Просканировать QR-код" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "Нет каналов" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "Добавить канал" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "Пока что здесь нет каналов" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "Добавьте каналы, чтобы начать общение" #: data/ui/channel-chat-widget.ui:193 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:329 msgid "Reset & Reboot" msgstr "Сброс и перезагрузка" #: data/ui/repeater-view.ui:94 msgid "Error Events" msgstr "Сообщения об ошибках" #: data/ui/repeater-view.ui:125 msgid "TX Airtime" msgstr "Время TX" #: data/ui/repeater-view.ui:131 msgid "RX Airtime" msgstr "Время RX" #: 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "Запросить телеметрию" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "CLI" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "Соседи" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "Загрузить больше" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "Показать на карте" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "Контроль доступа" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "Добавить" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "Загрузка регионов..." #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "Повторить" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "Ограничение региона по умолчанию" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "Применить" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "Базовый" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "Получить" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "Сохранить" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "Имя" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "Повторить отправку пакета" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "Гостевой доступ" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "Разрешить гостевой доступ без пароля в режиме только для чтения" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "Пароли" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "Пароль администратора" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "Гостевой пароль" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "Объявление" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "Локальный интервал (мин)" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "Интервал широковещательной передачи (час)" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 #, fuzzy msgid "Path Hash Size" msgstr "Хэши пути" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 #, fuzzy msgid "1 byte" msgstr "байт" #: data/ui/repeater-view.ui:869 #, fuzzy msgid "2 bytes" msgstr "байт" #: data/ui/repeater-view.ui:870 #, fuzzy msgid "3 bytes" msgstr "байт" #: data/ui/repeater-view.ui:878 #, fuzzy msgid "Default Region (Scope)" msgstr "Ограничение региона по умолчанию" #: data/ui/repeater-view.ui:879 #, fuzzy msgid "Flood packets are scoped to this region unless overridden" msgstr "" "При выборе данной настройки все широковещательные пакеты будут ограничены " "заданным регионом" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "Опасная зона" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "Перезагрузить устройство" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "Выбрать устройство" #: data/ui/repeater-view.ui:944 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 "Вещать через mesh-сеть" #: 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 src/views/contact_detail_dialog.py:382 msgid "Show QR Code" msgstr "Показать QR-код" #: 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:824 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 "Rx Log" #: data/ui/device-actions.ui:107 msgid "View received radio packets" msgstr "Показать полученные пакеты" #: data/ui/device-actions.ui:121 #, fuzzy msgid "Device CLI" msgstr "Устройство" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 "dBm за последние 2 минуты" #: 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 "Ежедневные копии, хранятся 7 дней" #: data/ui/settings-sidebar.ui:51 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "_Отменить" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "_Экспортировать" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "Идентификатор устройства" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "Настройки устройства" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "Локальные настройки" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "Список контактов" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "Обнаруженные контакты" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "Сообщения и маршрутизация" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "Сообщения" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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 "Ежедневные резервные копии после синхронизации, хранятся 7 дней" #: data/ui/import-backup-dialog.ui:8 msgid "Import Backup" msgstr "Импортировать резервную копию" #: data/ui/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "_Восстановить" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "Восстановить из автоматической резервной копии" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "Показать все устройства" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "Обнаружить контакты" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "Выбрать тему" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Add to favorites" msgstr "Добавить в Контакты" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 #, fuzzy msgid "Remote CLI" msgstr "Удалить" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "Тип" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "Последний раз онлайн" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "Маршрутизация" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "Исходящий путь" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "Сбросить путь" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" "Удалить установленный путь и переключиться на широковещательную передачу" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "Доступные маршруты" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "Автоматический сброс пути" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" "Переключиться на широковещательный режим после неудачных попыток доставки" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "Принудительно переключиться на широковещательный режим" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" "Всегда использовать вещание, никогда не использовать установленный путь" #: data/ui/contact-detail-dialog.ui:230 #, fuzzy msgid "Permissions" msgstr "Доступ к USB запрещён" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "Удалить контакт" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "Перезагрузка" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 #, fuzzy msgid "Get name" msgstr "Неназванный" #: data/ui/cli-dialog.ui:62 #, fuzzy msgid "Radio settings" msgstr "Региональные настройки" #: data/ui/cli-dialog.ui:68 #, fuzzy msgid "Version" msgstr "Доступ к USB запрещён" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "Команда CLI..." #: data/ui/custom-name-dialog.ui:8 #, fuzzy msgid "Rename" msgstr "Неназванный" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "_Сохранить" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 #, fuzzy msgid "Original Name" msgstr "Имя региона" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 #, fuzzy msgid "Custom Name" msgstr "Пользовательский" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 #, fuzzy msgid "New Companion" msgstr "Добавить новый узел" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 #, fuzzy msgid "Set Up as New Companion" msgstr "Добавить новый узел" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 #, fuzzy msgid "Restore from a Backup File" msgstr "Восстановить из автоматической резервной копии" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 #, fuzzy msgid "Radio Preset" msgstr "Региональные настройки" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "_Трассировка пути" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "Путь" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "Выбрать маршрут на карте" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "Внесите хэши пути и нажмите \"Запустить трассировку\"" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "_Добавить" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "Внесите данные контакта." #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "Тип контакта" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "Публичный ключ (64 шестнадцатеричных символа)" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "Присоединиться к каналу" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "Открыть в Картах" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "Участники" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "Фильтр" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "Приватный канал" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "С_оздать" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" "Будет сгенерирован случайный ключ. Передайте его остальным, чтобы они могли " "присоединиться." #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 msgid "URL Template" msgstr "Шаблон URL" #: 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "Добавить выбранные" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "Хэштег-канал" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "_Присоединиться" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" "Введите имя хэштега. Все, у кого есть данный хэштег, могут общаться в канале." #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "Хэштег (напр. #general)" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "Введите имя канала и приватный ключ, который Вам должны предоставить." #: data/ui/join-private-channel-content.ui:64 msgid "Private Key (32 hex characters)" msgstr "Приватный ключ (32 шестнадцатеричных символа)" #: data/ui/location-dialog.ui:8 msgid "Device Location" msgstr "Расположение устройства" #: data/ui/map-sidebar-filters.ui:17 src/views/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "Пользователи" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "Комнаты" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "Обнаруженные узлы" #: data/ui/map-view.ui:30 msgid "User" msgstr "Пользователь" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "Ретранслятор" #: data/ui/map-view.ui:62 msgid "Room" msgstr "Комната" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "Датчик" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 msgid "Message Paths" msgstr "Пути сообщения" #: data/ui/pair-dialog.ui:10 msgid "Looking for nearby MeshCore devices" msgstr "Поиск ближайших устройств MeshCore" #: data/ui/pair-dialog.ui:13 msgid "Pair New Companion" msgstr "Добавить новый узел" #: data/ui/pair-dialog.ui:41 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "Сканирование устройств MeshCore..." #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "Введите PIN подключения" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "_Cопряжение" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "Введите PIN, отображаемый на Вашем устройстве MeshCore." #: data/ui/passkey-dialog-content.ui:59 msgid "PIN" msgstr "PIN" #: data/ui/qr-code-dialog.ui:8 msgid "Your Contact QR Code" msgstr "Ваш контактный QR-код" #: data/ui/qr-code-dialog.ui:40 msgid "Scan this QR code to add this contact" msgstr "Просканируйте QR-код, чтобы добавить контакт" #: data/ui/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "Скопировать публичный ключ" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "Включить камеру" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "Наведите камеру на QR-код..." #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "Добавить регион" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "Введите имя и при желании добавьте родительский регион." #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "Имя региона" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "Родительский" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "Просканировать устройства" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "Пароль" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "Сохранить пароль" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "Войти" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "П_одключиться" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "Введите IP-адрес и порт Вашего устройства MeshCore." #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "Имя хоста / IP-адрес" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "Порт" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "Изменить адрес" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "Введите новый адрес для этого узла." #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "Отменить" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "Очистить" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "Онлайн-база данных узлов" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" "Информация об узлах будет скачана с map.meshcore.io (~15 МБайт). В контакты " "будут добавлены только устройства, содержащие соответствующий пресет радио и " "обновлённые на карте в последние 3 месяца." #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "Автоматически обновлять каждый день" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "Отменить" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "Скачать" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "Отключить онлайн-базу данных узлов?" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "Обновление узлов из онлайн-источников будет остановлено." #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "Удалить все контакты из онлайн-базы данных" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "Отключить" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "Недействительная ссылка meshcore://" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "Ссылка содержит недействительный секретный ключ канала" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "Недопустимая длина секретного ключа канала" #: src/application.py:122 msgid "Invalid public key in link" msgstr "Ссылка содержит недействительный публичный ключ канала" #: src/application.py:123 msgid "Invalid public key length" msgstr "Недопустимая длина публичного ключа канала" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "Ссылка meshcore:// не распознана" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "{} уже в Вашем списке" #: src/application.py:146 msgid "Contact" msgstr "Контакт" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "Контакт {} добавлен" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "Получение сообщений в фоновом режиме" #: src/application.py:388 #, fuzzy msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" "gallegonovato - Испанский\n" "lebeno - Французский, Нидерландский\n" "bjawebos - Немецкий\n" "Manuel Traut - Немецкий\n" "lejun - Французский\n" "LievenBlancke - Нидерландский\n" "sesivany - Чешский" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "GTK-клиент для LoRa устройств MeshCore" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "Символьные иконки Adwaita" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "Пользовательский" #: src/companion_setup.py:192 #, fuzzy msgid "Set Up Companion" msgstr "Удалить узел" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 #, fuzzy msgid "_Back" msgstr "Резервная копия" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 #, fuzzy msgid "New companion set up" msgstr "Нет сохранённых узлов" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "Подключение..." #: src/connection_controller.py:288 msgid "Stop" msgstr "Остановить" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "Переподключение ({current}/{total})..." #: src/connection_controller.py:312 msgid "Syncing..." msgstr "Синхронизация..." #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" "Устройство не отвечает. Возможно, оно не поддерживает этот тип соединения." #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "Синхронизация каналов" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "{} Сообщение" msgstr[1] "{} Сообщения" msgstr[2] "{} Сообщений" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "{} Сообщение в каналах" msgstr[1] "{} Сообщения в каналах" msgstr[2] "{} Сообщений в каналах" #: src/connection_controller.py:681 msgid " and " msgstr " и " #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "Вы получили {} пока были отключены." #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "Синхронизация контактов" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "Ошибка устройства: {}" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "{} (поздно)" #: src/frame_handler.py:752 msgid "late" msgstr "поздно" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "Часы устройства отставали на {} минут - время синхронизировано" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "Часы устройства опережают системное время на {} минут" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "Канал {}" #: src/frame_handler.py:1406 msgid "Someone" msgstr "Кто-то" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "{sender}: {text}" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "Я" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "широковещательно" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "прямое соединение" #: src/message_controller.py:234 msgid "path" msgstr "путь" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "– {route}" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "({attempt}/{total}) – {route}" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "Ожидание {} (канал занят)" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "%d хоп" msgstr[1] "%d хопа" msgstr[2] "%d хопов" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "Неправильный PIN - попробуйте ещё раз" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "Сопряжение отменено" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "Сопряжение отменено - не удалось соединиться" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "Отсутствует плагин GStreamer." #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "Ошибка канала камеры: {}" #: src/qr_scanner.py:469 msgid "" "No QR code detected yet.\n" "Make sure the code is well-lit, sharp, and fills most of the frame." msgstr "" "QR-коды не обнаружены.\n" "Убедитесь, что код хорошо освещён, его чётко видно, и он заполняет большую " "часть окна." #: src/qr_scanner.py:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" "Не удалось обнаружить QR-код.\n" "Попробуйте импортировать контакт при помощи буфера обмена." #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "QR-код обнаружен!" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "Ошибка камеры: {}" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "Ошибка камеры" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "ОК" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, fuzzy, python-brace-format msgid "{} mi" msgstr "{} минута назад" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "{d}д" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "{h}ч" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "{m}м" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "{secs}с" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "{} секунда назад" msgstr[1] "{} секунды назад" msgstr[2] "{} секунд назад" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "{} минута назад" msgstr[1] "{} минуты назад" msgstr[2] "{} минут назад" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} час назад" msgstr[1] "{} часа назад" msgstr[2] "{} часов назад" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} день назад" msgstr[1] "{} дня назад" msgstr[2] "{} дней назад" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "Растровый" #: src/window.py:555 msgid "Vector" msgstr "Векторный" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" "Используйте для заполнения {z},{x},{y}.\n" "Пример: https://tile.openstreetmap.org/{z}/{x}/{y}.png" #: src/window.py:567 msgid "" "URL to a MapLibre/Mapbox style JSON.\n" "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" msgstr "" "URL JSON-файла MapLibre/Mapbox.\n" "Пример: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY" #: src/window.py:573 msgid "Style JSON URL" msgstr "URL JSON стилей" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "Пользовательские ячейки карты применены" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "Не удалось загрузить пользовательские ячейки карты" #: src/window.py:637 msgid "Sort" msgstr "Сортировать" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "Действия" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "Информация об устройстве" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "Чат" #: src/window.py:733 msgid "Channel" msgstr "Канал" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "Резервное копирование и восстановление" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "{name} ({path})" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "{name} ({region})" #: src/window.py:1031 msgid "Management" msgstr "Управление" #: src/window.py:1031 msgid "Guest" msgstr "Гость" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "{name} — {role}" #: src/window.py:1166 msgid "Excellent" msgstr "Отлично" #: src/window.py:1167 msgid "Good" msgstr "Хорошо" #: src/window.py:1168 msgid "Fair" msgstr "Нормально" #: src/window.py:1169 msgid "Poor" msgstr "Плохо" #: src/window.py:1170 msgid "Very poor" msgstr "Очень плохо" #: src/window.py:1432 msgid "flood routed" msgstr "широковещательная маршрутизация" #: src/window.py:1432 msgid "zero-hop" msgstr "zero-hop" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "Объявление отправлено ({})" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 #, fuzzy msgid "Could not share contact" msgstr "Вы поделились контактом:" #: src/window.py:1764 #, fuzzy msgid "Contact List Full" msgstr "Список контактов" #: src/window.py:1766 #, fuzzy msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" "Список контактов на устройстве заполнен. Удалите контакты, чтобы увидеть " "регионы." #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "{} удалено" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "Канал \"{}\" уже добавлен" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "Отсутствуют свободные слоты каналов" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "Канал \"{}\" добавлен" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "Сообщения в \"{}\" очищены" #: src/views/__init__.py:66 msgid "Today" msgstr "Сегодня" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "Вчера" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 msgid "%A, %B %-d" msgstr "%-d %B, %A" #. Translators: date format with year, strftime-style. #. %A = weekday, %B = month name, %-d = day number, %Y = year. #: src/views/__init__.py:76 msgid "%A, %B %-d, %Y" msgstr "%-d %B %Y, %A" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "Неизвестно" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "Контактов: {total}" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "Контактов: {visible}/{total}" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "Все" #: src/views/__init__.py:656 msgid "Distance" msgstr "Расстояние" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "Войти в {}" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "Вход не удался — неверный пароль" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "Вход не удался — таймаут соединения" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "Повторная попытка с использованием широковещательной маршрутизации..." #: src/views/__init__.py:860 msgid "Logging in..." msgstr "Вход..." #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "Сервер комнаты" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "Публичный ключ скопирован" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "Неназванный" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "Хэштег" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "Приватный" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 msgid "Remove" msgstr "Удалить" #: src/views/connection_view.py:80 msgid "Bluetooth not available" msgstr "Bluetooth недоступен" #: src/views/connection_view.py:81 msgid "BLE requires running from app bundle on macOS" msgstr "В macOS для работы BLE необходимо использовать пакет приложения" #: src/views/connection_view.py:96 msgid "Bluetooth is disabled" msgstr "Bluetooth отключен" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "Для соединения включите Bluetooth в системных настройках" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "Нет сопряжённых устройств" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "Используйте кнопку внизу, чтобы подключиться к новому узлу" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "USB-устройства не обнаружены" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "Подключиться к узлу MeshCore по USB" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "Удалить узел" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" "Удалить {} ({})? Вам нужно будет снова провести сопряжение для соединения." #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "Удалить сохранённые данные" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "Не удалось установить USB-соединение" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "Доступ к USB запрещён" #: src/views/connection_view.py:253 #, 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 "" "Невозможно получить доступ к {}.\n" "\n" "Для подключения по USB необходимо установить правила udev. Нажмите " "\"Установить правила\", чтобы это сделать (потребуется пароль " "администратора)." #: src/views/connection_view.py:260 msgid "Install Rule" msgstr "Установить правило" #: src/views/connection_view.py:282 #, 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 "" "Нет доступа к {}.\n" "\n" "Для подключения по USB необходимо установить правила udev. Введите в " "терминал следующую команду:\n" "\n" "{}" #: src/views/connection_view.py:288 msgid "Close" msgstr "Закрыть" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "Скопировать команду" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "Команда скопирована в буфер обмена" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "Установка не удалась" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" "Не получилось установить правила udev:\n" "{}" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "Нет сохранённых узлов" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "Используйте кнопку внизу, чтобы добавить узел" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "Удалить узел TCP {}?" #: src/views/connection_view.py:590 msgid "Pair" msgstr "Сопряжение" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "Никогда" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "Показать путь на карте" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "Показать путь сообщения на карте" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "Разрешить запросы телеметрии" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" "Разрешить этому контакту запрашивать заряд батареи, напряжение, температуру" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "Включать положение" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "Включать координаты GPS в ответы телеметрии" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "Включать датчики окружающей среды" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "Включать данные о влажности, атмосферном давлении, качестве воздуха" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "Управление комнатой" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "Управление ретрансляторами" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "Управление датчиками" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" "Войдите, чтобы получить доступ к интерфейсу командной строки, настрокам и " "регионам" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "Пинг" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "Измерить время прохождения сообщения от узла и к нему" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "Обнаружить пути" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "Найти путь к этому узлу через широковещательную передачу" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "Запросить регионы с этого ретранслятора" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "Запрос..." #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "Нет ответа" #: src/views/contact_detail_dialog.py:354 #, fuzzy msgid "Remove from favorites" msgstr "Удалить из СКД?" #: src/views/contact_detail_dialog.py:380 #, fuzzy msgid "Zero Hop Advert" msgstr "Прямое подключение" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 #, fuzzy msgid "Link copied" msgstr "Публичный ключ скопирован" #: src/views/contact_detail_dialog.py:412 #, fuzzy msgid "Contact QR Code" msgstr "Ваш контактный QR-код" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "%d путей доступно" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "Пути ещё не добавлены" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "Умная маршрутизация — %s" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "Доступные пути" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "Оценка: %.2f" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "Вес: %.1f" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "Пути ещё не записаны" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "Пути обнаружены, обмен сообщениями произошёл успешно" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "Очистить историю" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "Нет данных о местоположении" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "Маршруты до %s" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "Моё устройство" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "Добавить вручную" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "Избранное" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "А-Я" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "Недавно услышанные" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "Последние сообщения" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "Сначала избранное" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "Контактов: {total}" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "%x" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "Обнаружено: {total}" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "Обнаружено: {visible}/{total}" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "Время прибытия" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "Этот контакт уже есть в Вашем списке." #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "Импорт не удался" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "Ссылка meshcore:// не обнаружена в буфере обмена." #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" "Не удалось получить данные QR-кода:\n" "{}" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "Поиск..." #: src/views/contacts_view.py:735 msgid "Path found" msgstr "Путь найден" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "Загрузка..." #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "Пути не сохранены" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "Не поддерживается" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "Пинг недоступен при 3-битных хэшах пути" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "Пинг {}: {}" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "Пинг {}: истекло время подключения" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "Производим запрос..." #: src/views/contacts_view.py:848 #, fuzzy msgid "No telemetry response (timed out)" msgstr "Нет ответа (истекло время запроса)" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "Нет ответа (истекло время запроса)" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "Нет данных телеметрии" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "Телеметрия получена" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "Телеметрия — {}" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "Путь к {}" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "Неизвестный ретранслятор" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "Услышано узлов" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "Отправить повторно" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "Удалить" #: src/views/channels_view.py:186 msgid "Reply" msgstr "Ответить" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "Скопировать текст" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "Показать пути сообщений" #: src/views/channels_view.py:241 msgid "Send" msgstr "Отправить" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "Отправить без ограничений" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "Поделиться своим контактом" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "Поделиться контактом" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "Поделиться каналом" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "Поделиться местоположением" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "Поделиться расположением на карте" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "Вы поделились контактом:" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "{name} поделился с Вами контактом:" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "Уже добавлено" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "Вы поделились каналом:" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "{name} поделился каналом:" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "Уже присоединились" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "Вы поделились местоположением:" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "{name} поделился местоположением:" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "Новые сообщения" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "%d ретрансляция" msgstr[1] "%d ретрансляции" msgstr[2] "%d ретрансляций" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "%d повтор" msgstr[1] "%d повтора" msgstr[2] "%d повторов" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" "Нет доступной информации о пути. Данные о пути записываются только для " "сообщений, полученных в подключенном состоянии." #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 #, fuzzy msgid "unknown" msgstr "Неизвестно" #: src/views/channels_view.py:1193 #, fuzzy, python-brace-format msgid "SNR: {} dB" msgstr "{} dB" #: src/views/channels_view.py:1194 #, fuzzy, python-brace-format msgid "Path {}: {}" msgstr "Путь к {}" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "Личное сообщение" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "Ретрансляторы не использовались" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "Нет информации о ретрансляторах" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" "Информация о пути записывается только через LOG_DATA, когда устройство " "подключено" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "Путь сообщения" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "Ретрансляции не обнаружены." #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "Публичный" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "{visible}/{total} канал" msgstr[1] "{visible}/{total} канала" msgstr[2] "{visible}/{total} каналов" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "{total} канал" msgstr[1] "{total} канала" msgstr[2] "{total} каналов" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "Информация о канале" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "Приватный ключ" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "Приватный ключ скопирован в буфер обмена" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "{} участник" msgstr[1] "{} участника" msgstr[2] "{} участников" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "Время хранения сообщений" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "1 день" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "1 неделя" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "1 месяц" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "3 месяца" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "Навсегда" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "Уровень уведомлений" #: src/views/channels_view.py:2147 msgid "Default" msgstr "По умолчанию" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "Регион" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "Проводить широковещательную отправку только в регионе" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "Регионы не указаны" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "Добавьте регионы в Настройки, чтобы распределить их по каналам" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "Очистить сообщения" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "Удалить канал" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "Заблокировано" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "{} заблокирован" msgstr[1] "{} заблокировано" msgstr[2] "{} заблокировано" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "Разблокировать" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "Заблокировать" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "Разблокировано {}" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "Заблокировать {}?" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "Заблокировать и Удалить сообщения" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "Заблокировано {}" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "Нет доступных слотов" #: src/views/channels_view.py:2550 #, fuzzy, python-brace-format msgid "All {count} channel slots are in use." msgstr "Все 8 слотов каналов уже используются." #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "Уже присоединились" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "Вы уже в публичном канале." #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "В буфере обмена не обнаружена ссылка на канал." #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" "По QR-коду не обнаружен канал:\n" "{}" #: src/views/cli_dialog.py:46 #, fuzzy, python-format msgid "Companion CLI — %s" msgstr "GPS-модуль узла" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, fuzzy, python-format msgid "Device CLI — %s" msgstr "GPS-модуль узла" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 #, fuzzy msgid "(empty response)" msgstr "Нет ответа" #: src/views/cli_console.py:148 #, fuzzy msgid "Waiting for response..." msgstr "Загрузка регионов..." #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "Публичный ключ устройства недоступен" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "Выполнить сброс до заводских настроек?" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "Сбросить" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "Перезагрузить устройство?" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "Устройство будет отсоединено и перезагружено." #: src/views/device_view.py:319 msgid "Not set" msgstr "Не установлено" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "Местоположение (установлено вручную)" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "Местоположение (GPS)" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "Местоположение (GPS включен, нет сигнала)" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "Местоположение (GPS выключен)" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "Запрос…" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "{days}д" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "{hours}ч" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "{mins}м" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "Сообщений: {}" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "Ошибок: {count}" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "Подключено" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "Отслеживание пути не поддерживается для 3-байт хэшей пути" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "напр., aa,bb,cc" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "напр., aabb,ccdd" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "напр., aabbcc,ddeeff" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "Нет ретрансляторов с известным положением" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "Трассировка завершена: {} хопов, {}" #: src/views/device_view.py:681 msgid "Result" msgstr "Результат" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "Трассировка не удалась: превышено время ожидания" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "Некорректное шестнадцатеричное значение в пути" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "Трассировка..." #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "Сканирование...осталось {}с" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "Добавить в Контакты" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "Добавлено {}" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "Запросить регионы" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "Запросить имя и владельца" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "Запросить подробности" #: src/views/device_view.py:1032 msgid "No region response" msgstr "Нет ответа региона" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "Нет ответа владельца" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "Нет информации о владельце" #: src/views/device_view.py:1076 msgid "Owner" msgstr "Владелец" #: src/views/device_view.py:1105 msgid "Node" msgstr "Узел" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "Выполнено. Найдено {} узлов." #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "Выполнено. Найден {} узел." #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "{unit} в течение последних 2 минут" #: src/views/device_view.py:1356 msgid "Size" msgstr "Размер" #: src/views/device_view.py:1356 msgid "bytes" msgstr "байт" #: src/views/device_view.py:1361 msgid "hops" msgstr "хопов" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "Хэши пути" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "байт за хоп" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "Доступно обновление: {version}" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "Отклонить все" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "Разрешить контактам" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "Разрешить всем" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "Удалены контакты онлайн-узлов" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "Настройки применены на устройстве" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "Неизвестная ошибка" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "Ошибка настроек: {}" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "Мощность передачи (dBm, max {})" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "Местоположение недоступно" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "Местоположение заполнено, нажмите Принять для сохранения" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "Выбрать местоположение" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "Установить местоположение" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "Сначала соединитесь с устройством" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "Обнаружение ретрансляторов в радиусе действия..." #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "Найдено {} регионов" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "Найден {} регион" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" "Список контактов на устройстве заполнен. Удалите контакты, чтобы увидеть " "регионы." #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "Регионы не найдены" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "Ретрансляторы в радиусе действия не обнаружены" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "от {}" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "(уже добавлено)" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "Запрос {} ретрансляторов..." #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "Запрос {} ретранслятора..." #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "Добавлено {} регионов" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "Добавлен {} регион" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "Не подключено" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "Присоединитесь к устройству, чтобы экспортировать резервную копию." #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "Файлы JSON" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "Резервная копия успешно экспортирована" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "Экспорт не удался: {}" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "Устройство не поддерживает экспорт ключей" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" "Чтобы импортировать резервную копию, сначала подключитесь к устройству." #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "Чтобы восстановить резервную копию, сначала подключитесь к устройству." #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "Нет автоматических резервных копий" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "Автоматические резервные копии не обнаружены." #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "{} (сейчас)" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "Показывать только это устройство" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "Не удалось прочитать файл резервной копии: {}" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "Источник: {}" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "Контактов: {}" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "Каналов: {}" #: src/views/settings_view.py:1789 msgid "messages" msgstr "сообщений" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "идентификатор устройства" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "Импортировано: {}" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "Нечего импортировать" #: src/views/repeater_view.py:156 msgid "room" msgstr "комната" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "датчик" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "ретранслятор" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "Синхронизация времени с {}" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "Сбросить часы и перезагрузить?" #: src/views/repeater_view.py:323 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 "" "Часы устройства опережают реальное время.\n" "Прошивка не позволяет уменьшать время.\n" "\n" "Это действие сбросит часы и перезагрузит устройство." #: src/views/repeater_view.py:338 msgid "Rebooting..." msgstr "Перезагрузка..." #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "Команда сброса часов и перезагрузки отправлена на {}" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "Ошибка запроса: истекло время ожидания" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "{} из {} соседей" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "Карта соседей" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "{} поле" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "{} полей" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "Удалить из СКД?" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "Удалить {} из списка контроля доступа?" #: src/views/repeater_view.py:711 msgid "Role" msgstr "Роль" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "Администратор" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "Чтение-запись" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "Только чтение" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "Добавить к контролю доступа" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "Глобально (без региона)" #: src/views/repeater_view.py:842 msgid "Home" msgstr "Домашняя" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "Широковещательная отправка разрешена" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "Широковещательная отправка запрещена" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "Запретить широковещательные сообщения" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "Разрешить широковещательные сообщения" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "Установить как Домашнюю" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "Сначала удалить под-регионы" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "Несохранённые изменения" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "У Вас есть несохранённые изменения настроек региона." #: src/views/repeater_view.py:975 msgid "Discard" msgstr "Отклонить" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 #, fuzzy msgid "Unscoped" msgstr "Отправить без ограничений" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "Пароль администратора изменён — необходим повторный вход" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "Настройки отправлены на {}" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "Перезагрузить {}?" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "Время на устройстве некорректно" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "Отправленное местоположение" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "Отправленное" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "Библиотека QR-кодов недоступна" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "Местоположение недоступно" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "Ваш узел не может определять местоположение по GPS." #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "Отправить автоматически" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "Отправить напрямую" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "Отправить широковещательно" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "Отправка" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "Комната только для чтения" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "{} из {} контактов на карте" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "{} из {}, найденных на карте" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "Обнаружено: {}" #: src/views/map_view.py:427 #, fuzzy msgid "Any time" msgstr "Время передачи" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "и ещё {}" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" "Положение представлено в иллюстративных целях - реальное положение неизвестно" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "{} dB" #: src/views/map_view.py:876 msgid "SNR" msgstr "SNR" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "Выбрать маршрут трассировки" #: src/views/map_view.py:1263 msgid "_Done" msgstr "_Выполнено" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "Нажмите на ретрансляторы, чтобы выстроить маршрут трассировки" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "{count} узлов импортировано из онлайн-базы данных" #, fuzzy #~ msgid "Companion Management" #~ msgstr "Управление комнатой" #, fuzzy #~ msgid "Enter a CLI command" #~ msgstr "Команда CLI..." #, fuzzy #~ msgid "WiFi IP" #~ msgstr "WiFi / TCP" #~ msgid "Query this contact's telemetry data" #~ msgstr "Запросить телеметрию у этого контакта" #, python-format #~ msgid "%d hops" #~ msgstr "%d хопов" #~ msgid "Contact Info" #~ msgstr "Информация о контакте" #~ msgid "Favorite" #~ msgstr "Добавить в Избранное" #~ msgid "Pin to top of contacts list" #~ msgstr "Закрепить наверху списка контактов" meshy/po/ru_UA.po000066400000000000000000002474651526212641700142240ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: ru_UA\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 ? 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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/sk.po000066400000000000000000002476531526212641700136250ustar00rootroot00000000000000# 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-09-27 21:18+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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" msgstr[2] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/sv.po000066400000000000000000002475221526212641700136330ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: 2026-10-08 06:27+0000\n" "Last-Translator: yeager \n" "Language-Team: Swedish \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" "X-Generator: Weblate 2026.9.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 "" #: 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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/uk.po000066400000000000000000002474621526212641700136250ustar00rootroot00000000000000# 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-09-27 21:18+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: uk\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 ? 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, USB or TCP to send and receive encrypted " "messages, manage contacts, configure channels, and monitor the mesh network." msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:16 msgid "Features:" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:18 msgid "Bluetooth LE, USB serial, TCP/Wifi connectivity" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:19 msgid "End-to-end encrypted messaging" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:20 msgid "Public, private, and hashtag channels" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:21 msgid "Contact management with location tracking" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:22 msgid "Interactive map view with route tracing" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:23 msgid "Device and repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:24 msgid "QR code scanning for quick contact exchange" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:29 msgid "Jiri Eischmann" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:41 msgid "Channel chats" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:45 msgid "Repeater management" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:49 msgid "Settings - Dark Style" msgstr "" #: data/page.codeberg.sesivany.Meshy.metainfo.xml.in:53 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:37 msgid "Toggle Fullscreen" msgstr "" #: data/gtk/help-overlay.ui:45 msgid "Navigation" msgstr "" #: data/gtk/help-overlay.ui:48 data/ui/window.ui:181 data/ui/device-view.ui:65 msgid "Device" msgstr "" #: data/gtk/help-overlay.ui:54 data/ui/window.ui:197 #: data/ui/settings-view.ui:312 data/ui/manual-backup-dialog.ui:124 #: data/ui/auto-backup-dialog.ui:120 data/ui/import-backup-dialog.ui:131 #: src/window.py:716 src/views/settings_view.py:142 msgid "Contacts" msgstr "" #: data/gtk/help-overlay.ui:60 data/ui/window.ui:227 #: data/ui/manual-backup-dialog.ui:165 data/ui/auto-backup-dialog.ui:161 #: data/ui/import-backup-dialog.ui:172 src/window.py:732 msgid "Channels" msgstr "" #. Navigation button for collapsed mode #: data/gtk/help-overlay.ui:66 data/ui/window.ui:254 src/window.py:744 #: src/window.py:747 src/views/map_view.py:1144 msgid "Map" msgstr "" #: data/gtk/help-overlay.ui:72 data/ui/window.ui:15 data/ui/window.ui:274 #: data/ui/repeater-view.ui:543 data/ui/manual-backup-dialog.ui:71 #: data/ui/auto-backup-dialog.ui:67 data/ui/import-backup-dialog.ui:78 #: src/window.py:766 src/window.py:767 msgid "Settings" msgstr "" #: data/gtk/help-overlay.ui:80 msgid "Messaging" msgstr "" #: data/gtk/help-overlay.ui:83 msgid "Search Contacts" msgstr "" #: data/gtk/help-overlay.ui:89 msgid "Focus Message Input" msgstr "" #: data/gtk/help-overlay.ui:95 msgid "Previous Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:101 msgid "Next Contact/Channel" msgstr "" #: data/gtk/help-overlay.ui:107 msgid "Previous Unread" msgstr "" #: data/gtk/help-overlay.ui:113 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:281 #: src/connection_controller.py:529 src/window.py:1270 src/window.py:1283 #: src/views/connection_view.py:125 src/views/connection_view.py:169 #: src/views/connection_view.py:399 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:280 #: src/connection_controller.py:528 src/window.py:1269 src/window.py:1282 #: src/views/device_view.py:355 msgid "Not connected" msgstr "" #: data/ui/device-view.ui:24 data/ui/repeater-view.ui:19 #: data/ui/sensor-view.ui:38 msgid "Status" msgstr "" #: data/ui/device-view.ui:25 src/views/device_view.py:489 msgid "Disconnected" msgstr "" #: data/ui/device-view.ui:86 data/ui/new-companion-dialog.ui:141 msgid "Node Name" msgstr "" #: data/ui/device-view.ui:92 data/ui/cli-dialog.ui:56 msgid "Board" msgstr "" #: data/ui/device-view.ui:98 msgid "Firmware" msgstr "" #: data/ui/device-view.ui:102 msgid "Update" msgstr "" #: data/ui/device-view.ui:105 msgid "Open firmware flasher" msgstr "" #: data/ui/device-view.ui:116 data/ui/contact-detail-dialog.ui:111 #: data/ui/confirm-contact-dialog.ui:60 src/views/map_view.py:849 msgid "Public Key" msgstr "" #: data/ui/device-view.ui:123 data/ui/contact-detail-dialog.ui:118 #: src/views/map_view.py:854 msgid "Copy to clipboard" msgstr "" #: data/ui/device-view.ui:133 msgid "Telemetry" msgstr "" #: data/ui/device-view.ui:139 msgid "Request telemetry" msgstr "" #: data/ui/device-view.ui:149 data/ui/settings-view.ui:239 #: data/ui/settings-view.ui:387 data/ui/repeater-view.ui:647 #: src/views/contact_detail_dialog.py:96 src/views/device_view.py:327 #: src/views/settings_view.py:140 src/views/map_view.py:869 #: src/views/map_view.py:873 msgid "Location" msgstr "" #: data/ui/device-view.ui:150 msgid "Syncing…" msgstr "" #: data/ui/device-view.ui:155 msgid "Show on map" msgstr "" #: data/ui/device-view.ui:166 data/ui/repeater-view.ui:76 #: src/views/__init__.py:792 src/views/contacts_view.py:915 msgid "Battery" msgstr "" #: data/ui/device-view.ui:180 msgid "Storage" msgstr "" #: data/ui/device-view.ui:208 data/ui/settings-view.ui:80 #: data/ui/repeater-view.ui:104 data/ui/repeater-view.ui:692 #: src/views/settings_view.py:141 msgid "Radio" msgstr "" #: data/ui/device-view.ui:229 msgid "Frequency" msgstr "" #: data/ui/device-view.ui:235 msgid "Bandwidth" msgstr "" #: data/ui/device-view.ui:241 data/ui/settings-view.ui:103 #: data/ui/repeater-view.ui:733 msgid "Spreading Factor" msgstr "" #: data/ui/device-view.ui:247 data/ui/settings-view.ui:116 #: data/ui/repeater-view.ui:745 msgid "Coding Rate" msgstr "" #: data/ui/device-view.ui:253 msgid "TX Power" msgstr "" #: data/ui/device-view.ui:259 data/ui/settings-view.ui:204 msgid "Default Path Hash Size" msgstr "" #: data/ui/device-view.ui:279 msgid "Statistics" msgstr "" #: data/ui/device-view.ui:300 data/ui/repeater-view.ui:119 #: src/views/device_view.py:118 msgid "Noise Floor" msgstr "" #: data/ui/device-view.ui:307 data/ui/device-view.ui:324 #: data/ui/device-view.ui:341 msgid "Show live graph" msgstr "" #: data/ui/device-view.ui:317 data/ui/repeater-view.ui:107 #: src/views/device_view.py:119 msgid "Last RSSI" msgstr "" #: data/ui/device-view.ui:334 data/ui/repeater-view.ui:113 #: src/views/device_view.py:120 msgid "Last SNR" msgstr "" #: data/ui/device-view.ui:351 msgid "Airtime" msgstr "" #: data/ui/device-view.ui:357 data/ui/repeater-view.ui:153 msgid "Packets" msgstr "" #: data/ui/device-view.ui:363 data/ui/repeater-view.ui:175 src/models.py:188 #: src/models.py:190 src/views/contacts_view.py:765 msgid "Flood" msgstr "" #: data/ui/device-view.ui:369 data/ui/repeater-view.ui:181 src/models.py:192 #: src/views/contact_detail_dialog.py:592 #: src/views/contact_detail_dialog.py:791 src/views/contacts_view.py:715 #: src/views/contacts_view.py:767 msgid "Direct" msgstr "" #: data/ui/device-view.ui:375 data/ui/repeater-view.ui:82 msgid "Uptime" msgstr "" #: data/ui/device-view.ui:381 data/ui/repeater-view.ui:88 msgid "Message Queue" msgstr "" #: data/ui/device-view.ui:390 data/ui/repeater-view.ui:201 #: data/ui/repeater-view.ui:313 data/ui/repeater-view.ui:398 msgid "Refresh" msgstr "" #: data/ui/device-view.ui:392 msgid "Refresh statistics" msgstr "" #: data/ui/settings-view.ui:16 src/views/settings_view.py:137 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:2147 msgid "All Messages" msgstr "" #: data/ui/settings-view.ui:55 src/views/channels_view.py:2147 msgid "Mentions Only" msgstr "" #: data/ui/settings-view.ui:56 src/views/contact_detail_dialog.py:251 #: src/views/channels_view.py:2147 src/views/channels_view.py:2184 #: src/views/settings_view.py:165 src/views/repeater_view.py:1009 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: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 "Radio 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:723 msgid "Frequency (MHz)" msgstr "" #: data/ui/settings-view.ui:98 data/ui/repeater-view.ui:728 msgid "Bandwidth (kHz)" msgstr "" #: data/ui/settings-view.ui:131 src/views/device_view.py:93 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:757 msgid "TX Power (dBm)" msgstr "" #: data/ui/settings-view.ui:155 src/views/settings_view.py:138 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:139 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:160 msgid "Classic" msgstr "" #: data/ui/settings-view.ui:184 data/ui/contact-detail-dialog.ui:166 #: src/models.py:186 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:284 msgid "1-byte (max 64 hops)" msgstr "" #: data/ui/settings-view.ui:211 src/views/device_view.py:285 msgid "2-byte (max 32 hops)" msgstr "" #: data/ui/settings-view.ui:212 src/views/device_view.py:286 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:678 msgid "Latitude" msgstr "" #: data/ui/settings-view.ui:259 data/ui/repeater-view.ui:683 msgid "Longitude" msgstr "" #: data/ui/settings-view.ui:264 msgid "Use My Location" msgstr "" #: data/ui/settings-view.ui:265 msgid "Set from this device's location" msgstr "" #: data/ui/settings-view.ui:285 data/ui/trace-path-dialog.ui:57 msgid "Pick on Map" msgstr "" #: data/ui/settings-view.ui:286 msgid "Choose a location on the map" msgstr "" #: data/ui/settings-view.ui:313 msgid "Contact discovery and automatic adding" msgstr "" #: data/ui/settings-view.ui:316 msgid "Auto-Add Contacts" msgstr "" #: data/ui/settings-view.ui:317 msgid "Automatically add nodes discovered via adverts" msgstr "" #: data/ui/settings-view.ui:322 msgid "Auto-Add Settings" msgstr "" #: data/ui/settings-view.ui:323 msgid "Device types, overwrite policy, hop limit" msgstr "" #: data/ui/settings-view.ui:326 msgid "Chat Nodes" msgstr "" #: data/ui/settings-view.ui:331 data/ui/map-sidebar-filters.ui:28 #: src/views/__init__.py:644 src/views/contacts_view.py:182 msgid "Repeaters" msgstr "" #: data/ui/settings-view.ui:336 src/views/__init__.py:645 #: src/views/contacts_view.py:183 msgid "Room Servers" msgstr "" #: data/ui/settings-view.ui:341 data/ui/map-sidebar-filters.ui:50 #: src/views/__init__.py:646 src/views/contacts_view.py:184 msgid "Sensors" msgstr "" #: data/ui/settings-view.ui:346 msgid "Overwrite oldest when full" msgstr "" #: data/ui/settings-view.ui:351 msgid "Hop limit" msgstr "" #: data/ui/settings-view.ui:352 msgid "0–63, leave empty for no limit" msgstr "" #: data/ui/settings-view.ui:367 msgid "Online node database" msgstr "" #: data/ui/settings-view.ui:368 msgid "Download node info from map.meshcore.io" msgstr "" #: data/ui/settings-view.ui:377 src/views/settings_view.py:143 msgid "Telemetry Privacy" msgstr "" #: data/ui/settings-view.ui:378 msgid "Control what others can query from your device" msgstr "" #: data/ui/settings-view.ui:381 msgid "Battery & Sensors" msgstr "" #: data/ui/settings-view.ui:382 msgid "Battery, voltage, temperature, current" msgstr "" #: data/ui/settings-view.ui:388 msgid "GPS coordinates" msgstr "" #: data/ui/settings-view.ui:393 msgid "Environment" msgstr "" #: data/ui/settings-view.ui:394 msgid "Humidity, pressure, air quality" msgstr "" #: data/ui/settings-view.ui:403 data/ui/repeater-view.ui:427 #: data/ui/manual-backup-dialog.ui:110 data/ui/auto-backup-dialog.ui:106 #: data/ui/import-backup-dialog.ui:117 src/views/contact_detail_dialog.py:229 #: src/views/device_view.py:1040 src/views/settings_view.py:144 msgid "Regions" msgstr "" #: data/ui/settings-view.ui:404 msgid "Limit flood scope of channel messages to specific regions" msgstr "" #: data/ui/settings-view.ui:428 msgid "Add region…" msgstr "" #: data/ui/settings-view.ui:431 msgid "Discover regions from repeaters" msgstr "" #: data/ui/settings-view.ui:437 msgid "Add region" msgstr "" #: data/ui/settings-view.ui:451 msgid "Default Scope" msgstr "" #: data/ui/settings-view.ui:452 msgid "All flood packets will be scoped to this region if set" msgstr "" #: data/ui/settings-view.ui:462 data/ui/connection-view.ui:35 #: src/views/settings_view.py:145 msgid "Bluetooth" msgstr "" #: data/ui/settings-view.ui:463 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:466 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 data/ui/tcp-add-content.ui:8 msgid "Add TCP Companion" msgstr "" #: data/ui/contacts-view.ui:16 data/ui/channels-view.ui:43 #: data/ui/discover-dialog.ui:51 data/ui/channel-participants-dialog.ui:49 msgid "Search..." msgstr "" #: data/ui/contacts-view.ui:41 data/ui/discover-dialog.ui:74 #: src/views/__init__.py:570 msgid "No contacts" msgstr "" #: data/ui/contacts-view.ui:54 data/ui/add-contact-content.ui:8 #: data/ui/channel-message-item.ui:161 data/ui/chat-message-item.ui:163 #: data/ui/confirm-contact-dialog.ui:8 src/views/channels_view.py:416 #: src/views/chat_view.py:347 msgid "Add Contact" msgstr "" #: data/ui/chat-view.ui:22 msgid "No Contacts Yet" msgstr "" #: data/ui/chat-view.ui:23 msgid "Add contacts 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:115 data/ui/chat-view.ui:117 #: data/ui/channel-chat-widget.ui:115 data/ui/channel-chat-widget.ui:117 #: data/ui/contact-detail-dialog.ui:45 data/ui/contact-detail-dialog.ui:47 #: src/views/chat_helpers.py:112 src/views/chat_helpers.py:190 #: src/views/chat_helpers.py:270 msgid "Share" msgstr "" #: data/ui/chat-view.ui:142 data/ui/channel-chat-widget.ui:142 msgid "" "Messages this long may not record repeat and path information on some " "receivers." msgstr "" #: data/ui/chat-view.ui:193 src/views/chat_view.py:666 msgid "Type a message..." msgstr "" #: data/ui/chat-view.ui:210 data/ui/chat-view.ui:212 #: data/ui/channel-chat-widget.ui:210 data/ui/channel-chat-widget.ui:212 msgid "Insert emoji" msgstr "" #: data/ui/channels-view.ui:10 msgid "Create a Private Channel" msgstr "" #: data/ui/channels-view.ui:14 msgid "Join a Private Channel" msgstr "" #: data/ui/channels-view.ui:18 msgid "Join the Public Channel" msgstr "" #: data/ui/channels-view.ui:22 msgid "Join a Hashtag Channel" msgstr "" #: data/ui/channels-view.ui:26 src/views/contacts_view.py:126 msgid "Import from Clipboard" msgstr "" #: data/ui/channels-view.ui:30 data/ui/qr-scanner-dialog.ui:8 #: src/views/contacts_view.py:130 msgid "Scan QR Code" msgstr "" #: data/ui/channels-view.ui:67 src/views/chat_helpers.py:241 msgid "No channels" msgstr "" #: data/ui/channels-view.ui:79 data/ui/confirm-channel-dialog.ui:8 msgid "Add Channel" msgstr "" #: data/ui/channel-chat-widget.ui:22 msgid "No Channels Yet" msgstr "" #: data/ui/channel-chat-widget.ui:23 msgid "Add channels to start chatting" msgstr "" #: data/ui/channel-chat-widget.ui:193 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:329 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:209 data/ui/contact-detail-dialog.ui:65 #: data/ui/contact-detail-dialog.ui:67 src/views/repeater_view.py:407 msgid "Request Telemetry" msgstr "" #: data/ui/repeater-view.ui:229 msgid "CLI" msgstr "" #: data/ui/repeater-view.ui:266 msgid "Neighbors" msgstr "" #: data/ui/repeater-view.ui:321 msgid "Load More" msgstr "" #: data/ui/repeater-view.ui:330 data/ui/trace-path-dialog.ui:132 #: src/views/contact_detail_dialog.py:575 src/views/channels_view.py:1220 msgid "Show on Map" msgstr "" #: data/ui/repeater-view.ui:351 msgid "Access Control" msgstr "" #: data/ui/repeater-view.ui:406 data/ui/repeater-view.ui:511 #: src/views/__init__.py:526 msgid "Add" msgstr "" #: data/ui/repeater-view.ui:456 src/views/repeater_view.py:766 msgid "Loading regions..." msgstr "" #: data/ui/repeater-view.ui:464 src/views/settings_view.py:1151 msgid "Retry" msgstr "" #: data/ui/repeater-view.ui:489 msgid "Default Region Scope" msgstr "" #: data/ui/repeater-view.ui:490 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:519 data/ui/custom-tiles-dialog.ui:60 #: src/views/settings_view.py:330 src/views/repeater_view.py:976 msgid "Apply" msgstr "" #: data/ui/repeater-view.ui:561 msgid "Basic" msgstr "" #: data/ui/repeater-view.ui:572 data/ui/repeater-view.ui:658 #: data/ui/repeater-view.ui:703 data/ui/repeater-view.ui:784 #: data/ui/repeater-view.ui:843 msgid "Fetch" msgstr "" #: data/ui/repeater-view.ui:581 data/ui/repeater-view.ui:667 #: data/ui/repeater-view.ui:712 data/ui/repeater-view.ui:793 #: data/ui/repeater-view.ui:852 msgid "Save" msgstr "" #: data/ui/repeater-view.ui:592 data/ui/add-contact-content.ui:64 #: data/ui/confirm-channel-dialog.ui:49 data/ui/confirm-contact-dialog.ui:50 #: src/views/contacts_view.py:334 src/views/channels_view.py:2048 #: src/views/channels_view.py:2062 src/views/channels_view.py:2301 #: src/views/chat_helpers.py:118 msgid "Name" msgstr "" #: data/ui/repeater-view.ui:597 msgid "Packet Repeat" msgstr "" #: data/ui/repeater-view.ui:602 msgid "Guest Access" msgstr "" #: data/ui/repeater-view.ui:603 msgid "Allow read-only guest access without password" msgstr "" #: data/ui/repeater-view.ui:612 msgid "Passwords" msgstr "" #: data/ui/repeater-view.ui:615 msgid "Admin Password" msgstr "" #: data/ui/repeater-view.ui:629 msgid "Guest Password" msgstr "" #: data/ui/repeater-view.ui:773 msgid "Advertisement" msgstr "" #: data/ui/repeater-view.ui:804 msgid "Local Interval (min)" msgstr "" #: data/ui/repeater-view.ui:816 msgid "Flood Interval (hrs)" msgstr "" #: data/ui/repeater-view.ui:832 msgid "Network" msgstr "" #: data/ui/repeater-view.ui:863 msgid "Path Hash Size" msgstr "" #: data/ui/repeater-view.ui:864 msgid "Size of the path IDs this node advertises" msgstr "" #: data/ui/repeater-view.ui:868 msgid "1 byte" msgstr "" #: data/ui/repeater-view.ui:869 msgid "2 bytes" msgstr "" #: data/ui/repeater-view.ui:870 msgid "3 bytes" msgstr "" #: data/ui/repeater-view.ui:878 msgid "Default Region (Scope)" msgstr "" #: data/ui/repeater-view.ui:879 msgid "Flood packets are scoped to this region unless overridden" msgstr "" #: data/ui/repeater-view.ui:888 data/ui/contact-detail-dialog.ui:238 msgid "Danger Zone" msgstr "" #: data/ui/repeater-view.ui:891 data/ui/device-actions.ui:136 msgid "Reboot Device" msgstr "" #: data/ui/repeater-view.ui:915 msgid "Select a device" msgstr "" #: data/ui/repeater-view.ui:944 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 src/views/contact_detail_dialog.py:382 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:824 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/device-actions.ui:121 msgid "Device CLI" msgstr "" #: data/ui/device-actions.ui:122 msgid "Run CLI commands on this device" 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 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 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:18 data/ui/import-backup-dialog.ui:18 #: data/ui/restore-snapshot-dialog.ui:18 data/ui/custom-name-dialog.ui:18 #: data/ui/new-companion-dialog.ui:18 data/ui/trace-path-dialog.ui:19 #: data/ui/add-contact-content.ui:18 #: data/ui/create-private-channel-content.ui:18 #: data/ui/join-hashtag-channel-content.ui:18 #: data/ui/join-private-channel-content.ui:18 data/ui/map-picker-dialog.ui:16 #: data/ui/passkey-dialog-content.ui:18 data/ui/confirm-channel-dialog.ui:18 #: data/ui/confirm-contact-dialog.ui:18 data/ui/region-add-content.ui:18 #: data/ui/tcp-add-content.ui:18 data/ui/tcp-change-address-content.ui:18 #: src/companion_setup.py:198 src/views/map_view.py:1259 msgid "_Cancel" msgstr "" #: data/ui/manual-backup-dialog.ui:24 msgid "_Export" msgstr "" #: data/ui/manual-backup-dialog.ui:53 data/ui/auto-backup-dialog.ui:49 #: data/ui/import-backup-dialog.ui:61 msgid "Device Identity" msgstr "" #: data/ui/manual-backup-dialog.ui:86 data/ui/auto-backup-dialog.ui:82 #: data/ui/import-backup-dialog.ui:93 msgid "Device Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:98 data/ui/auto-backup-dialog.ui:94 #: data/ui/import-backup-dialog.ui:105 msgid "Local Settings" msgstr "" #: data/ui/manual-backup-dialog.ui:139 data/ui/auto-backup-dialog.ui:135 #: data/ui/import-backup-dialog.ui:146 msgid "Contact List" msgstr "" #: data/ui/manual-backup-dialog.ui:151 data/ui/auto-backup-dialog.ui:147 #: data/ui/import-backup-dialog.ui:158 msgid "Discovered Contacts" msgstr "" #: data/ui/manual-backup-dialog.ui:183 data/ui/auto-backup-dialog.ui:179 #: data/ui/import-backup-dialog.ui:189 msgid "Messages and Routing" msgstr "" #: data/ui/manual-backup-dialog.ui:198 data/ui/auto-backup-dialog.ui:194 #: data/ui/import-backup-dialog.ui:204 src/views/channels_view.py:2303 msgid "Messages" msgstr "" #: data/ui/manual-backup-dialog.ui:210 data/ui/auto-backup-dialog.ui:206 #: data/ui/import-backup-dialog.ui:216 msgid "Smart Route History" 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/import-backup-dialog.ui:24 data/ui/restore-snapshot-dialog.ui:24 msgid "_Restore" msgstr "" #: data/ui/restore-snapshot-dialog.ui:8 data/ui/new-companion-dialog.ui:100 msgid "Restore from Automatic Backup" msgstr "" #: data/ui/restore-snapshot-dialog.ui:67 src/views/settings_view.py:1639 msgid "Show All Devices" msgstr "" #: data/ui/discover-dialog.ui:8 src/views/contacts_view.py:124 #: src/views/contacts_view.py:287 src/views/contacts_view.py:323 msgid "Discover Contacts" msgstr "" #: data/ui/discover-dialog.ui:18 data/ui/channel-participants-dialog.ui:18 #: src/window.py:629 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/theme-chooser-dialog.ui:9 msgid "Choose Theme" msgstr "" #: data/ui/contact-detail-dialog.ui:55 data/ui/contact-detail-dialog.ui:57 #: src/views/contact_detail_dialog.py:354 msgid "Add to favorites" msgstr "" #: data/ui/contact-detail-dialog.ui:75 data/ui/contact-detail-dialog.ui:78 msgid "Remote CLI" msgstr "" #: data/ui/contact-detail-dialog.ui:104 data/ui/custom-tiles-dialog.ui:28 #: data/ui/confirm-channel-dialog.ui:54 src/views/channels_view.py:2069 #: src/views/map_view.py:844 msgid "Type" msgstr "" #: data/ui/contact-detail-dialog.ui:129 data/ui/map-sidebar-filters.ui:65 #: src/views/contacts_view.py:335 src/views/channels_view.py:2302 #: src/views/chat_helpers.py:118 src/views/map_view.py:887 msgid "Last Seen" msgstr "" #: data/ui/contact-detail-dialog.ui:140 msgid "Routing" msgstr "" #: data/ui/contact-detail-dialog.ui:179 msgid "Out Path" msgstr "" #: data/ui/contact-detail-dialog.ui:186 msgid "Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:187 msgid "Clear the established path and switch to flood" msgstr "" #: data/ui/contact-detail-dialog.ui:199 msgid "Available Routes" msgstr "" #: data/ui/contact-detail-dialog.ui:212 msgid "Automatically Reset Path" msgstr "" #: data/ui/contact-detail-dialog.ui:213 msgid "Switch to flood after failed delivery attempts" msgstr "" #: data/ui/contact-detail-dialog.ui:219 msgid "Force Flood" msgstr "" #: data/ui/contact-detail-dialog.ui:220 msgid "Always broadcast, never use an established path" msgstr "" #: data/ui/contact-detail-dialog.ui:230 msgid "Permissions" msgstr "" #: data/ui/contact-detail-dialog.ui:242 msgid "Remove Contact" msgstr "" #: data/ui/cli-dialog.ui:38 src/views/device_view.py:228 #: src/views/repeater_view.py:1309 msgid "Reboot" msgstr "" #: data/ui/cli-dialog.ui:44 msgid "Shut down" msgstr "" #: data/ui/cli-dialog.ui:50 msgid "Get name" msgstr "" #: data/ui/cli-dialog.ui:62 msgid "Radio settings" msgstr "" #: data/ui/cli-dialog.ui:68 msgid "Version" msgstr "" #: data/ui/cli-console.ui:38 msgid "CLI command..." msgstr "" #: data/ui/custom-name-dialog.ui:8 msgid "Rename" msgstr "" #: data/ui/custom-name-dialog.ui:24 data/ui/tcp-change-address-content.ui:24 msgid "_Save" msgstr "" #: data/ui/custom-name-dialog.ui:41 src/views/contact_detail_dialog.py:489 msgid "Original Name" msgstr "" #: data/ui/custom-name-dialog.ui:49 src/views/contact_detail_dialog.py:486 msgid "Custom Name" msgstr "" #: data/ui/new-companion-dialog.ui:8 src/companion_setup.py:197 msgid "New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:24 src/companion_setup.py:199 msgid "_Forward" msgstr "" #: data/ui/new-companion-dialog.ui:58 msgid "" "This companion looks newly flashed and hasn’t been set up yet. Choose how " "you’d like to continue." msgstr "" #: data/ui/new-companion-dialog.ui:75 msgid "Set Up as New Companion" msgstr "" #: data/ui/new-companion-dialog.ui:76 msgid "Choose a node name and radio preset" msgstr "" #: data/ui/new-companion-dialog.ui:88 msgid "Restore from a Backup File" msgstr "" #: data/ui/new-companion-dialog.ui:89 msgid "Import a Meshy or MeshCore backup file" msgstr "" #: data/ui/new-companion-dialog.ui:101 msgid "Restore one of the daily snapshots stored on this computer" msgstr "" #: data/ui/new-companion-dialog.ui:129 msgid "" "Choose a name for your companion and the radio preset used by the network in " "your area." msgstr "" #: data/ui/new-companion-dialog.ui:146 msgid "Radio Preset" msgstr "" #: data/ui/trace-path-dialog.ui:25 msgid "_Run Trace" msgstr "" #: data/ui/trace-path-dialog.ui:49 data/ui/trace-path-dialog.ui:52 #: src/views/device_view.py:1361 msgid "Path" msgstr "" #: data/ui/trace-path-dialog.ui:58 msgid "Choose route on the map" msgstr "" #: data/ui/trace-path-dialog.ui:89 msgid "Enter path hashes and press Run Trace" msgstr "" #: data/ui/add-contact-content.ui:24 data/ui/confirm-channel-dialog.ui:24 #: data/ui/confirm-contact-dialog.ui:24 data/ui/region-add-content.ui:24 msgid "_Add" msgstr "" #: data/ui/add-contact-content.ui:43 msgid "Enter the contact details." msgstr "" #: data/ui/add-contact-content.ui:59 data/ui/confirm-contact-dialog.ui:55 msgid "Contact Type" msgstr "" #: data/ui/add-contact-content.ui:69 msgid "Public Key (64 hex characters)" msgstr "" #: data/ui/channel-message-item.ui:231 data/ui/chat-message-item.ui:233 #: src/views/channels_view.py:458 src/views/chat_view.py:392 msgid "Join Channel" msgstr "" #: data/ui/channel-message-item.ui:289 data/ui/chat-message-item.ui:291 msgid "Open in Maps App" msgstr "" #: data/ui/channel-participants-dialog.ui:8 src/views/channels_view.py:2105 msgid "Participants" msgstr "" #: data/ui/channel-participants-dialog.ui:27 src/window.py:633 #: src/window.py:743 msgid "Filter" msgstr "" #: data/ui/create-private-channel-content.ui:8 #: data/ui/join-private-channel-content.ui:8 msgid "Private Channel" msgstr "" #: data/ui/create-private-channel-content.ui:24 msgid "C_reate" msgstr "" #: data/ui/create-private-channel-content.ui:43 msgid "A random PSK will be generated. Share it with others so they can join." msgstr "" #: data/ui/create-private-channel-content.ui:59 #: data/ui/join-private-channel-content.ui:59 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:573 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:287 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:1138 #: src/views/settings_view.py:1299 msgid "Add Selected" msgstr "" #: data/ui/join-hashtag-channel-content.ui:8 msgid "Hashtag Channel" msgstr "" #: data/ui/join-hashtag-channel-content.ui:24 #: data/ui/join-private-channel-content.ui:24 msgid "_Join" msgstr "" #: data/ui/join-hashtag-channel-content.ui:43 msgid "Enter a hashtag name. Anyone using the same hashtag can communicate." msgstr "" #: data/ui/join-hashtag-channel-content.ui:59 msgid "Hashtag (e.g. #general)" msgstr "" #: data/ui/join-private-channel-content.ui:43 msgid "Enter the channel name and private key shared with you." msgstr "" #: data/ui/join-private-channel-content.ui:64 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/__init__.py:643 #: src/views/contacts_view.py:181 msgid "Users" msgstr "" #: data/ui/map-sidebar-filters.ui:39 msgid "Rooms" msgstr "" #: data/ui/map-sidebar-filters.ui:74 msgid "Discovered Nodes" msgstr "" #: data/ui/map-view.ui:30 msgid "User" msgstr "" #: data/ui/map-view.ui:46 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:940 msgid "Repeater" msgstr "" #: data/ui/map-view.ui:62 msgid "Room" msgstr "" #: data/ui/map-view.ui:78 src/views/__init__.py:884 #: src/views/contacts_view.py:354 src/views/device_view.py:942 msgid "Sensor" msgstr "" #: data/ui/message-paths-dialog.ui:8 src/views/channels_view.py:1144 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 data/ui/scan-dialog-content.ui:27 msgid "Scanning for MeshCore devices..." msgstr "" #: data/ui/passkey-dialog-content.ui:8 msgid "Enter Pairing PIN" msgstr "" #: data/ui/passkey-dialog-content.ui:24 msgid "_Pair" msgstr "" #: data/ui/passkey-dialog-content.ui:43 msgid "Enter the PIN shown on your MeshCore device." msgstr "" #: data/ui/passkey-dialog-content.ui:59 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/confirm-contact-dialog.ui:65 msgid "Copy Public Key" msgstr "" #: data/ui/qr-scanner-dialog.ui:21 msgid "Switch Camera" msgstr "" #: data/ui/qr-scanner-dialog.ui:44 msgid "Point the camera at a QR code..." msgstr "" #: data/ui/region-add-content.ui:8 msgid "Add Region" msgstr "" #: data/ui/region-add-content.ui:43 msgid "Enter a name and optionally choose a parent region." msgstr "" #: data/ui/region-add-content.ui:59 msgid "Region Name" msgstr "" #: data/ui/region-add-content.ui:64 msgid "Parent" msgstr "" #: data/ui/scan-dialog-content.ui:8 msgid "Scan for Devices" msgstr "" #: data/ui/room-login-dialog.ui:33 msgid "Password" msgstr "" #: data/ui/room-login-dialog.ui:38 msgid "Save password" msgstr "" #: data/ui/room-login-dialog.ui:66 msgid "Login" msgstr "" #: data/ui/tcp-add-content.ui:24 msgid "C_onnect" msgstr "" #: data/ui/tcp-add-content.ui:43 msgid "Enter the IP address and port of your MeshCore device." msgstr "" #: data/ui/tcp-add-content.ui:59 data/ui/tcp-change-address-content.ui:59 msgid "Hostname / IP Address" msgstr "" #: data/ui/tcp-add-content.ui:65 data/ui/tcp-change-address-content.ui:65 msgid "Port" msgstr "" #: data/ui/tcp-change-address-content.ui:8 src/views/connection_view.py:409 msgid "Change Address" msgstr "" #: data/ui/tcp-change-address-content.ui:43 msgid "Enter the new address for this companion." msgstr "" #: data/ui/trace-picker-bottom-bar.ui:27 src/window.py:2244 msgid "Undo" msgstr "" #: data/ui/trace-picker-bottom-bar.ui:36 msgid "Clear" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:8 msgid "Online Node Database" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:9 msgid "" "Node information will be downloaded from map.meshcore.io (~15 MB). Only " "nodes matching your device's radio preset and updated on the map in the last " "3 months will be imported into discovered contacts." msgstr "" #: data/ui/online-nodes-enable-dialog.ui:16 msgid "Automatically update daily" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:23 #: data/ui/online-nodes-disable-dialog.ui:23 src/connection_controller.py:298 #: src/connection_controller.py:501 src/views/connection_view.py:188 #: src/views/connection_view.py:259 src/views/connection_view.py:461 #: src/views/channels_view.py:2486 src/views/device_view.py:211 #: src/views/device_view.py:227 src/views/repeater_view.py:328 #: src/views/repeater_view.py:691 src/views/repeater_view.py:1308 msgid "Cancel" msgstr "" #: data/ui/online-nodes-enable-dialog.ui:24 msgid "Download" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:8 msgid "Disable Online Node Database?" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:9 msgid "Online node updates will be stopped." msgstr "" #: data/ui/online-nodes-disable-dialog.ui:16 msgid "Delete all contacts from online database" msgstr "" #: data/ui/online-nodes-disable-dialog.ui:24 msgid "Disable" msgstr "" #: src/application.py:118 src/application.py:119 src/application.py:130 msgid "Invalid meshcore:// link" msgstr "" #: src/application.py:120 msgid "Invalid channel secret in link" msgstr "" #: src/application.py:121 msgid "Invalid channel secret length" msgstr "" #: src/application.py:122 msgid "Invalid public key in link" msgstr "" #: src/application.py:123 msgid "Invalid public key length" msgstr "" #: src/application.py:124 msgid "Unrecognized meshcore:// link" msgstr "" #: src/application.py:146 #, python-brace-format msgid "{} is already in your list" msgstr "" #: src/application.py:146 msgid "Contact" msgstr "" #: src/application.py:160 src/application.py:173 #, python-brace-format msgid "Contact {} added" msgstr "" #: src/application.py:256 src/application.py:279 src/application.py:315 msgid "Receiving messages in the background" msgstr "" #: src/application.py:388 msgid "" "gallegonovato — Spanish\n" "lebeno — French, Dutch\n" "bjawebos — German\n" "Manuel Traut — German\n" "bobidle — German\n" "lejun — French\n" "LievenBlancke — Dutch\n" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian" msgstr "" #: src/application.py:401 msgid "A GTK client for MeshCore LoRa mesh network devices" msgstr "" #: src/application.py:450 msgid "Adwaita Symbolic Icons" msgstr "" #: src/companion_setup.py:178 src/views/settings_view.py:150 msgid "Custom" msgstr "" #: src/companion_setup.py:192 msgid "Set Up Companion" msgstr "" #: src/companion_setup.py:193 src/companion_setup.py:226 #: src/companion_setup.py:232 msgid "_Back" msgstr "" #: src/companion_setup.py:194 msgid "_Finish" msgstr "" #: src/companion_setup.py:288 msgid "New companion set up" msgstr "" #: src/connection_controller.py:166 src/connection_controller.py:301 #: src/window.py:1292 msgid "Connecting..." msgstr "" #: src/connection_controller.py:288 msgid "Stop" msgstr "" #: src/connection_controller.py:294 src/connection_controller.py:497 #, python-brace-format msgid "Reconnecting ({current}/{total})..." msgstr "" #: src/connection_controller.py:312 msgid "Syncing..." msgstr "" #: src/connection_controller.py:347 msgid "Device did not respond. It may not support this connection type." msgstr "" #: src/connection_controller.py:593 msgid "Syncing channels" msgstr "" #: src/connection_controller.py:666 src/views/channels_view.py:2418 #, python-brace-format msgid "{} message" msgid_plural "{} messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:675 #, python-brace-format msgid "{} channel message" msgid_plural "{} channel messages" msgstr[0] "" msgstr[1] "" #: src/connection_controller.py:681 msgid " and " msgstr "" #: src/connection_controller.py:684 #, python-brace-format msgid "You received {} while disconnected." msgstr "" #: src/frame_handler.py:335 src/frame_handler.py:353 msgid "Syncing contacts" msgstr "" #: src/frame_handler.py:524 #, python-brace-format msgid "Device error: {}" msgstr "" #: src/frame_handler.py:752 #, python-brace-format msgid "{} (late)" msgstr "" #: src/frame_handler.py:752 msgid "late" msgstr "" #: src/frame_handler.py:826 #, python-brace-format msgid "Device clock was {}min behind — synced" msgstr "" #: src/frame_handler.py:834 #, python-brace-format msgid "Device clock is {}min ahead of system" msgstr "" #. Already deleted from DB #: src/frame_handler.py:1405 src/window.py:861 src/window.py:2098 #: src/window.py:2129 src/views/__init__.py:769 src/views/contacts_view.py:898 #: src/views/channels_view.py:1938 src/views/channels_view.py:2042 #: src/views/channels_view.py:2063 src/views/chat_helpers.py:289 #, python-brace-format msgid "Channel {}" msgstr "" #: src/frame_handler.py:1406 msgid "Someone" msgstr "" #: src/frame_handler.py:1409 #, python-brace-format msgid "{sender}: {text}" msgstr "" #: src/message_controller.py:187 src/views/repeater_view.py:649 #: src/views/repeater_view.py:680 msgid "Me" msgstr "" #: src/message_controller.py:230 src/views/channels_view.py:1185 msgid "flood" msgstr "" #: src/message_controller.py:232 src/views/channels_view.py:630 #: src/views/channels_view.py:1187 src/views/channels_view.py:1471 msgid "direct" msgstr "" #: src/message_controller.py:234 msgid "path" msgstr "" #: src/message_controller.py:237 src/message_controller.py:241 #, python-brace-format msgid "– {route}" msgstr "" #: src/message_controller.py:242 #, python-brace-format msgid "({attempt}/{total}) – {route}" msgstr "" #: src/message_controller.py:297 #, python-brace-format msgid "waiting {} (radio busy)" msgstr "" #: src/models.py:193 src/views/contact_detail_dialog.py:597 #: src/views/contact_detail_dialog.py:789 src/views/contacts_view.py:721 #: src/views/channels_view.py:636 src/views/channels_view.py:1182 #: src/views/channels_view.py:1189 src/views/channels_view.py:1469 #, python-format msgid "%d hop" msgid_plural "%d hops" msgstr[0] "" msgstr[1] "" #: src/platforms/linux/ble_bluez.py:1133 msgid "Wrong PIN — try again" msgstr "" #: src/platforms/linux/ble_bluez.py:1137 msgid "Pairing cancelled" msgstr "" #: src/platforms/linux/ble_bluez.py:1139 msgid "Pairing failed — could not connect" msgstr "" #: src/platforms/linux/ble_bluez.py:1141 msgid "Pairing failed" 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:378 msgid "Missing GStreamer plugin." msgstr "" #: src/qr_scanner.py:424 #, python-brace-format msgid "Camera pipeline error: {}" msgstr "" #: src/qr_scanner.py:469 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:480 msgid "" "Could not detect QR code.\n" "Try importing the contact via clipboard instead." msgstr "" #: src/qr_scanner.py:486 msgid "QR code found!" msgstr "" #: src/qr_scanner.py:496 #, python-brace-format msgid "Camera error: {}" msgstr "" #: src/qr_scanner.py:517 msgid "Camera Error" msgstr "" #: src/qr_scanner.py:518 src/window.py:1770 src/views/connection_view.py:221 #: src/views/connection_view.py:351 src/views/connection_view.py:358 #: src/views/contacts_view.py:294 src/views/contacts_view.py:396 #: src/views/contacts_view.py:462 src/views/channels_view.py:1150 #: src/views/channels_view.py:1450 src/views/channels_view.py:2552 #: src/views/channels_view.py:2623 src/views/channels_view.py:2735 #: src/views/settings_view.py:1433 src/views/settings_view.py:1544 #: src/views/settings_view.py:1578 src/views/settings_view.py:1594 #: src/views/settings_view.py:1736 src/views/chat_helpers.py:174 msgid "OK" msgstr "" #: src/utils.py:191 #, python-brace-format msgid "{} m" msgstr "" #: src/utils.py:192 #, python-brace-format msgid "{} km" msgstr "" #: src/utils.py:195 #, python-brace-format msgid "{} ft" msgstr "" #: src/utils.py:196 #, python-brace-format msgid "{} mi" msgstr "" #: src/utils.py:241 #, python-brace-format msgid "{d}d" msgstr "" #: src/utils.py:243 #, python-brace-format msgid "{h}h" msgstr "" #: src/utils.py:245 #, python-brace-format msgid "{m}m" msgstr "" #: src/utils.py:246 #, python-brace-format msgid "{secs}s" msgstr "" #: src/utils.py:253 #, python-brace-format msgid "{} sec ago" msgid_plural "{} secs ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:256 #, python-brace-format msgid "{} min ago" msgid_plural "{} min ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:259 #, python-brace-format msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" #: src/utils.py:261 #, python-brace-format msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" #: src/window.py:536 msgid "Fullscreen off" msgstr "" #: src/window.py:540 msgid "Fullscreen on" msgstr "" #: src/window.py:555 msgid "Raster" msgstr "" #: src/window.py:555 msgid "Vector" msgstr "" #: src/window.py:563 #, python-brace-format msgid "" "Use {z}, {x}, {y} as placeholders.\n" "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png" msgstr "" #: src/window.py:567 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:573 msgid "Style JSON URL" msgstr "" #: src/window.py:596 msgid "Custom map tiles applied" msgstr "" #: src/window.py:598 msgid "Failed to load custom map tiles" msgstr "" #: src/window.py:637 msgid "Sort" msgstr "" #. Sidebar = Device Info button + Actions, content = device info #. Actions group #: src/window.py:704 src/views/contact_detail_dialog.py:163 #: src/views/channels_view.py:2224 src/views/repeater_view.py:866 msgid "Actions" msgstr "" #: src/window.py:705 src/window.py:706 msgid "Device Information" msgstr "" #. ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── #: src/window.py:717 src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/channels_view.py:2102 src/views/device_view.py:939 msgid "Chat" msgstr "" #: src/window.py:733 msgid "Channel" msgstr "" #. Sidebar = Settings button + hint, content = settings with Apply in headerbar #: src/window.py:765 msgid "Backup & Restore" msgstr "" #: src/window.py:842 src/window.py:1009 #, python-brace-format msgid "{name} ({path})" msgstr "" #: src/window.py:863 #, python-brace-format msgid "{name} ({region})" msgstr "" #: src/window.py:1031 msgid "Management" msgstr "" #: src/window.py:1031 msgid "Guest" msgstr "" #: src/window.py:1033 #, python-brace-format msgid "{name} — {role}" msgstr "" #: src/window.py:1166 msgid "Excellent" msgstr "" #: src/window.py:1167 msgid "Good" msgstr "" #: src/window.py:1168 msgid "Fair" msgstr "" #: src/window.py:1169 msgid "Poor" msgstr "" #: src/window.py:1170 msgid "Very poor" msgstr "" #: src/window.py:1432 msgid "flood routed" msgstr "" #: src/window.py:1432 msgid "zero-hop" msgstr "" #: src/window.py:1433 #, python-brace-format msgid "Advert sent ({})" msgstr "" #: src/window.py:1438 #, python-brace-format msgid "Sharing {}" msgstr "" #: src/window.py:1439 msgid "Could not share contact" msgstr "" #: src/window.py:1764 msgid "Contact List Full" msgstr "" #: src/window.py:1766 msgid "" "The contact list on your device is full. Remove unused contacts to make room " "for new ones." msgstr "" #: src/window.py:1947 src/window.py:2100 #, python-brace-format msgid "{} removed" msgstr "" #: src/window.py:2037 src/views/channels_view.py:2603 #: src/views/channels_view.py:2658 #, python-brace-format msgid "Channel \"{}\" is already added" msgstr "" #: src/window.py:2041 msgid "No empty channel slots available" msgstr "" #: src/window.py:2044 #, python-brace-format msgid "Channel \"{}\" added" msgstr "" #: src/window.py:2131 #, python-brace-format msgid "Messages in \"{}\" cleared" msgstr "" #: src/views/__init__.py:66 msgid "Today" msgstr "" #: src/views/__init__.py:68 msgid "Yesterday" msgstr "" #. Translators: date format for current year, strftime-style. #. %A = weekday, %B = month name, %-d = day number. #: src/views/__init__.py:73 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:76 msgid "%A, %B %-d, %Y" msgstr "" #: src/views/__init__.py:494 src/views/contact_detail_dialog.py:750 #: src/views/contacts_view.py:1058 src/views/channels_view.py:1337 #: src/views/device_view.py:850 src/views/settings_view.py:1647 #: src/views/settings_view.py:1741 msgid "Unknown" msgstr "" #: src/views/__init__.py:528 src/views/contacts_view.py:244 #, python-brace-format msgid "{total} contacts" msgstr "" #: src/views/__init__.py:530 src/views/contacts_view.py:240 #, python-brace-format msgid "{visible}/{total} contacts" msgstr "" #: src/views/__init__.py:642 src/views/contacts_view.py:179 #: src/views/channels_view.py:1815 src/views/channels_view.py:2294 msgid "All" msgstr "" #: src/views/__init__.py:656 msgid "Distance" msgstr "" #: src/views/__init__.py:809 #, python-brace-format msgid "Login to {}" msgstr "" #: src/views/__init__.py:841 msgid "Login failed — wrong password" msgstr "" #: src/views/__init__.py:848 msgid "Login timed out" msgstr "" #: src/views/__init__.py:855 msgid "Retrying via flood..." msgstr "" #: src/views/__init__.py:860 msgid "Logging in..." msgstr "" #: src/views/__init__.py:884 src/views/contacts_view.py:354 #: src/views/device_view.py:941 msgid "Room Server" msgstr "" #: src/views/__init__.py:944 src/views/contact_detail_dialog.py:418 #: src/views/device_view.py:174 msgid "Public key copied" msgstr "" #: src/views/__init__.py:986 msgid "Unnamed" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1817 msgid "Hashtag" msgstr "" #: src/views/__init__.py:987 src/views/channels_view.py:1818 msgid "Private" msgstr "" #: src/views/connection_view.py:44 src/views/connection_view.py:189 #: src/views/connection_view.py:410 src/views/connection_view.py:462 #: src/views/repeater_view.py:692 src/views/repeater_view.py:883 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:96 msgid "Bluetooth is disabled" msgstr "" #: src/views/connection_view.py:97 msgid "Enable Bluetooth in system settings to connect" msgstr "" #: src/views/connection_view.py:109 msgid "No paired devices" msgstr "" #: src/views/connection_view.py:110 msgid "Use the button below to pair a new companion" msgstr "" #: src/views/connection_view.py:153 msgid "No USB devices detected" msgstr "" #: src/views/connection_view.py:154 msgid "Connect a MeshCore companion via USB" msgstr "" #: src/views/connection_view.py:183 src/views/connection_view.py:458 msgid "Remove Companion" msgstr "" #: src/views/connection_view.py:184 #, python-brace-format msgid "Remove {} ({})? You will need to pair again to reconnect." msgstr "" #: src/views/connection_view.py:192 src/views/connection_view.py:465 msgid "Delete stored data" msgstr "" #: src/views/connection_view.py:218 msgid "USB Connection Failed" msgstr "" #: src/views/connection_view.py:251 src/views/connection_view.py:280 msgid "USB Permission Denied" msgstr "" #: src/views/connection_view.py:253 #, 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:260 msgid "Install Rule" msgstr "" #: src/views/connection_view.py:282 #, 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:288 msgid "Close" msgstr "" #: src/views/connection_view.py:289 msgid "Copy Command" msgstr "" #: src/views/connection_view.py:295 msgid "Command copied to clipboard" msgstr "" #: src/views/connection_view.py:348 src/views/connection_view.py:355 msgid "Installation Failed" msgstr "" #: src/views/connection_view.py:349 #, python-brace-format msgid "" "Could not install udev rule:\n" "{}" msgstr "" #: src/views/connection_view.py:385 msgid "No saved companions" msgstr "" #: src/views/connection_view.py:386 msgid "Use the button below to add one" msgstr "" #: src/views/connection_view.py:459 #, python-brace-format msgid "Remove TCP companion {}?" msgstr "" #: src/views/connection_view.py:590 msgid "Pair" msgstr "" #: src/views/contact_detail_dialog.py:74 src/views/channels_view.py:2423 msgid "Never" msgstr "" #: src/views/contact_detail_dialog.py:112 msgid "Show Path on Map" msgstr "" #: src/views/contact_detail_dialog.py:113 msgid "Visualize the message route on the map" msgstr "" #: src/views/contact_detail_dialog.py:135 msgid "Allow Telemetry Requests" msgstr "" #: src/views/contact_detail_dialog.py:136 msgid "Allow this contact to query battery, voltage, temperature" msgstr "" #: src/views/contact_detail_dialog.py:140 msgid "Include Location" msgstr "" #: src/views/contact_detail_dialog.py:141 msgid "Include GPS coordinates in telemetry responses" msgstr "" #: src/views/contact_detail_dialog.py:145 msgid "Include Environment Sensors" msgstr "" #: src/views/contact_detail_dialog.py:146 msgid "Include humidity, pressure, air quality data" msgstr "" #: src/views/contact_detail_dialog.py:156 msgid "Allow Remote CLI" msgstr "" #: src/views/contact_detail_dialog.py:157 msgid "Let this contact run CLI commands on this companion" msgstr "" #: src/views/contact_detail_dialog.py:167 msgid "Room Management" msgstr "" #: src/views/contact_detail_dialog.py:168 msgid "Repeater Management" msgstr "" #: src/views/contact_detail_dialog.py:169 msgid "Sensor Management" msgstr "" #: src/views/contact_detail_dialog.py:173 msgid "Login and access CLI, settings, and regions" msgstr "" #: src/views/contact_detail_dialog.py:193 msgid "Ping" msgstr "" #: src/views/contact_detail_dialog.py:194 msgid "Measure round-trip time to this node" msgstr "" #: src/views/contact_detail_dialog.py:209 msgid "Discover Paths" msgstr "" #: src/views/contact_detail_dialog.py:210 msgid "Find routes to this node via flood" msgstr "" #: src/views/contact_detail_dialog.py:230 msgid "Query regions from this repeater" msgstr "" #: src/views/contact_detail_dialog.py:240 msgid "Querying..." msgstr "" #: src/views/contact_detail_dialog.py:253 src/views/contacts_view.py:741 msgid "No response" msgstr "" #: src/views/contact_detail_dialog.py:354 msgid "Remove from favorites" msgstr "" #: src/views/contact_detail_dialog.py:380 msgid "Zero Hop Advert" msgstr "" #: src/views/contact_detail_dialog.py:381 msgid "Copy Link" msgstr "" #: src/views/contact_detail_dialog.py:402 msgid "Link copied" msgstr "" #: src/views/contact_detail_dialog.py:412 msgid "Contact QR Code" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:553 #, python-format msgid "%d paths available" msgstr "" #: src/views/contact_detail_dialog.py:478 #: src/views/contact_detail_dialog.py:555 #: src/views/contact_detail_dialog.py:647 msgid "No paths yet" msgstr "" #: src/views/contact_detail_dialog.py:572 #, python-format msgid "Smart Routes — %s" msgstr "" #: src/views/contact_detail_dialog.py:583 msgid "Available Paths" msgstr "" #: src/views/contact_detail_dialog.py:594 #, python-format msgid "Score: %.2f" msgstr "" #: src/views/contact_detail_dialog.py:595 #, python-format msgid "Weight: %.1f" msgstr "" #: src/views/contact_detail_dialog.py:633 msgid "No paths recorded yet" msgstr "" #: src/views/contact_detail_dialog.py:634 msgid "Paths are discovered as messages are exchanged" msgstr "" #: src/views/contact_detail_dialog.py:641 msgid "Clear History" msgstr "" #: src/views/contact_detail_dialog.py:711 msgid "No location data available" msgstr "" #: src/views/contact_detail_dialog.py:715 #, python-format msgid "Routes to %s" msgstr "" #: src/views/contact_detail_dialog.py:746 src/views/contacts_view.py:948 #: src/views/contacts_view.py:1052 src/views/channels_view.py:1330 #: src/views/cli_dialog.py:62 src/views/device_view.py:195 #: src/views/device_view.py:844 src/views/sensor_view.py:147 #: src/views/chat_helpers.py:81 src/views/map_view.py:674 #: src/views/map_view.py:1024 src/views/map_view.py:1374 msgid "My Device" msgstr "" #: src/views/contacts_view.py:125 msgid "Add Manually" msgstr "" #: src/views/contacts_view.py:180 msgid "Favourites" msgstr "" #: src/views/contacts_view.py:195 src/views/channels_view.py:1828 msgid "A–Z" msgstr "" #: src/views/contacts_view.py:196 msgid "Heard Recently" msgstr "" #: src/views/contacts_view.py:197 src/views/channels_view.py:1829 msgid "Latest Messages" msgstr "" #: src/views/contacts_view.py:204 msgid "Favorites First" msgstr "" #: src/views/contacts_view.py:246 #, python-brace-format msgid "{total} contact" msgstr "" #: src/views/contacts_view.py:289 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:318 src/views/map_view.py:887 #, python-format msgid "%x" msgstr "" #: src/views/contacts_view.py:327 #, python-brace-format msgid "{total} discovered" msgstr "" #: src/views/contacts_view.py:328 #, python-brace-format msgid "{visible}/{total} discovered" msgstr "" #: src/views/contacts_view.py:336 msgid "Arrival Time" msgstr "" #: src/views/contacts_view.py:369 src/views/contacts_view.py:433 msgid "This contact is already in your list." msgstr "" #: src/views/contacts_view.py:393 src/views/contacts_view.py:461 #: src/views/channels_view.py:2734 src/views/settings_view.py:1733 msgid "Import Failed" msgstr "" #: src/views/contacts_view.py:394 src/views/channels_view.py:2679 msgid "No meshcore:// link found in clipboard." msgstr "" #: src/views/contacts_view.py:417 #, python-brace-format msgid "" "Could not parse QR code data:\n" "{}" msgstr "" #: src/views/contacts_view.py:682 msgid "Searching..." msgstr "" #: src/views/contacts_view.py:735 msgid "Path found" msgstr "" #: src/views/contacts_view.py:750 msgid "Loading..." msgstr "" #: src/views/contacts_view.py:760 msgid "No path cached" msgstr "" #: src/views/contacts_view.py:773 msgid "Not supported" msgstr "" #: src/views/contacts_view.py:784 msgid "Ping is not supported with 3-byte path hashes" msgstr "" #: src/views/contacts_view.py:807 #, python-brace-format msgid "Ping {}: {}" msgstr "" #: src/views/contacts_view.py:818 #, python-brace-format msgid "Ping {}: timed out" msgstr "" #: src/views/contacts_view.py:842 src/views/repeater_view.py:489 #: src/views/repeater_view.py:628 src/views/sensor_view.py:55 msgid "Requesting..." msgstr "" #: src/views/contacts_view.py:848 msgid "No telemetry response (timed out)" msgstr "" #: src/views/contacts_view.py:850 src/views/cli_console.py:136 #: src/views/device_view.py:365 src/views/sensor_view.py:189 msgid "No response (timed out)" msgstr "" #: src/views/contacts_view.py:859 src/views/contacts_view.py:863 #: src/views/device_view.py:375 src/views/device_view.py:379 #: src/views/sensor_view.py:97 src/views/sensor_view.py:104 msgid "No telemetry data" msgstr "" #: src/views/contacts_view.py:867 src/views/device_view.py:394 msgid "Telemetry received" msgstr "" #: src/views/contacts_view.py:890 src/views/repeater_view.py:443 #, python-brace-format msgid "Telemetry — {}" msgstr "" #: src/views/contacts_view.py:1031 #, python-brace-format msgid "Path to {}" msgstr "" #: src/views/contacts_view.py:1083 src/views/channels_view.py:1207 #: src/views/channels_view.py:1338 src/views/device_view.py:851 #: src/views/map_view.py:1059 msgid "Unknown repeater" msgstr "" #: src/views/channels_view.py:181 src/views/channels_view.py:1447 #: src/views/channels_view.py:1454 msgid "Heard Repeats" msgstr "" #: src/views/channels_view.py:182 src/views/chat_view.py:118 msgid "Send Again" msgstr "" #: src/views/channels_view.py:183 src/views/channels_view.py:189 #: src/views/chat_view.py:119 msgid "Delete" msgstr "" #: src/views/channels_view.py:186 msgid "Reply" msgstr "" #: src/views/channels_view.py:187 msgid "Copy Text" msgstr "" #: src/views/channels_view.py:188 msgid "View Message Paths" msgstr "" #: src/views/channels_view.py:241 msgid "Send" msgstr "" #: src/views/channels_view.py:242 msgid "Send Unscoped" msgstr "" #: src/views/channels_view.py:252 src/views/chat_view.py:181 msgid "Share My Contact" msgstr "" #: src/views/channels_view.py:253 src/views/chat_helpers.py:109 #: src/views/chat_view.py:182 msgid "Share Contact" msgstr "" #: src/views/channels_view.py:254 src/views/chat_helpers.py:210 #: src/views/chat_view.py:183 msgid "Share Channel" msgstr "" #: src/views/channels_view.py:255 src/views/chat_helpers.py:189 #: src/views/chat_view.py:184 msgid "Share Location" msgstr "" #: src/views/channels_view.py:256 src/views/chat_view.py:185 msgid "Share Location from Map" msgstr "" #: src/views/channels_view.py:398 src/views/chat_view.py:329 msgid "You shared a contact:" msgstr "" #: src/views/channels_view.py:402 src/views/chat_view.py:333 #, python-brace-format msgid "{name} shared a contact with you:" msgstr "" #: src/views/channels_view.py:416 src/views/channels_view.py:583 #: src/views/chat_view.py:347 src/views/chat_view.py:510 msgid "Already added" msgstr "" #: src/views/channels_view.py:431 src/views/chat_view.py:362 msgid "You shared a channel:" msgstr "" #: src/views/channels_view.py:435 src/views/chat_view.py:366 #, python-brace-format msgid "{name} shared a channel:" msgstr "" #: src/views/channels_view.py:458 src/views/channels_view.py:610 #: src/views/chat_view.py:392 src/views/chat_view.py:541 msgid "Already joined" msgstr "" #: src/views/channels_view.py:474 src/views/chat_view.py:408 msgid "You shared a location:" msgstr "" #: src/views/channels_view.py:478 src/views/chat_view.py:412 #, python-brace-format msgid "{name} shared a location:" msgstr "" #: src/views/channels_view.py:548 src/views/chat_view.py:475 msgid "New Messages" msgstr "" #: src/views/channels_view.py:619 #, python-format msgid "· %d repeat" msgid_plural "· %d repeats" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:657 #, python-format msgid "· %d resend" msgid_plural "· %d resends" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1146 msgid "" "No path information available. Path data is only captured for messages " "received while connected." msgstr "" #: src/views/channels_view.py:1162 #, python-brace-format msgid "Message from {sender} received via {count} path:" msgid_plural "Message from {sender} received via {count} paths:" msgstr[0] "" msgstr[1] "" msgstr[2] "" #: src/views/channels_view.py:1165 msgid "unknown" msgstr "" #: src/views/channels_view.py:1193 #, python-brace-format msgid "SNR: {} dB" msgstr "" #: src/views/channels_view.py:1194 #, python-brace-format msgid "Path {}: {}" msgstr "" #: src/views/channels_view.py:1208 #, python-brace-format msgid "Hop {} · prefix {}" msgstr "" #: src/views/channels_view.py:1228 msgid "Direct message" msgstr "" #: src/views/channels_view.py:1229 msgid "No repeaters were used" msgstr "" #: src/views/channels_view.py:1238 msgid "No repeater details" msgstr "" #: src/views/channels_view.py:1239 msgid "Path data is only captured via LOG_DATA while connected" msgstr "" #: src/views/channels_view.py:1308 msgid "Message Path" msgstr "" #: src/views/channels_view.py:1448 msgid "No repeats heard yet." msgstr "" #: src/views/channels_view.py:1816 src/views/channels_view.py:2633 msgid "Public" msgstr "" #: src/views/channels_view.py:1864 src/views/chat_helpers.py:245 #, python-brace-format msgid "{visible}/{total} channel" msgid_plural "{visible}/{total} channels" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:1871 src/views/chat_helpers.py:252 #, python-brace-format msgid "{total} channel" msgid_plural "{total} channels" msgstr[0] "" msgstr[1] "" #. Info group #: src/views/channels_view.py:2045 msgid "Channel Info" msgstr "" #: src/views/channels_view.py:2077 msgid "Private Key" msgstr "" #: src/views/channels_view.py:2088 msgid "Private key copied to clipboard" msgstr "" #: src/views/channels_view.py:2106 src/views/channels_view.py:2371 #: src/views/channels_view.py:2377 #, python-brace-format msgid "{} participant" msgid_plural "{} participants" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2119 msgid "Message Retention" msgstr "" #: src/views/channels_view.py:2122 msgid "1 Day" msgstr "" #: src/views/channels_view.py:2123 msgid "1 Week" msgstr "" #: src/views/channels_view.py:2124 msgid "1 Month" msgstr "" #: src/views/channels_view.py:2125 msgid "3 Months" msgstr "" #: src/views/channels_view.py:2126 msgid "Forever" msgstr "" #: src/views/channels_view.py:2145 msgid "Notification Level" msgstr "" #: src/views/channels_view.py:2147 msgid "Default" msgstr "" #: src/views/channels_view.py:2177 src/views/channels_view.py:2182 msgid "Region" msgstr "" #: src/views/channels_view.py:2178 msgid "Limit flood messages to a specific region" msgstr "" #: src/views/channels_view.py:2206 msgid "No regions defined" msgstr "" #: src/views/channels_view.py:2207 msgid "Add regions in Settings to assign them to channels" msgstr "" #: src/views/channels_view.py:2226 msgid "Clear Messages" msgstr "" #: src/views/channels_view.py:2239 msgid "Remove Channel" msgstr "" #: src/views/channels_view.py:2294 msgid "Blocked" msgstr "" #: src/views/channels_view.py:2373 #, python-brace-format msgid "{} blocked" msgid_plural "{} blocked" msgstr[0] "" msgstr[1] "" #: src/views/channels_view.py:2403 msgid "Unblock" msgstr "" #: src/views/channels_view.py:2409 src/views/channels_view.py:2487 msgid "Block" msgstr "" #: src/views/channels_view.py:2434 #, python-brace-format msgid "Unblocked {}" msgstr "" #: src/views/channels_view.py:2479 #, python-brace-format msgid "Block {}?" msgstr "" #: src/views/channels_view.py:2481 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:2488 msgid "Block and Delete Messages" msgstr "" #: src/views/channels_view.py:2505 #, python-brace-format msgid "Blocked {}" msgstr "" #: src/views/channels_view.py:2549 msgid "No Slots Available" msgstr "" #: src/views/channels_view.py:2550 #, python-brace-format msgid "All {count} channel slots are in use." msgstr "" #: src/views/channels_view.py:2621 msgid "Already Joined" msgstr "" #: src/views/channels_view.py:2621 msgid "You are already on the public channel." msgstr "" #: src/views/channels_view.py:2702 msgid "No channel link found in clipboard." msgstr "" #: src/views/channels_view.py:2731 #, python-brace-format msgid "" "No channel found in QR code:\n" "{}" msgstr "" #: src/views/cli_dialog.py:46 #, python-format msgid "Companion CLI — %s" msgstr "" #: src/views/cli_dialog.py:54 msgid "" "Type a command and press Enter. The target companion must allow this contact " "for remote CLI." msgstr "" #: src/views/cli_dialog.py:64 #, python-format msgid "Device CLI — %s" msgstr "" #: src/views/cli_dialog.py:69 msgid "Type a command and press Enter." msgstr "" #: src/views/cli_console.py:131 msgid "(empty response)" msgstr "" #: src/views/cli_console.py:148 msgid "Waiting for response..." msgstr "" #: src/views/device_view.py:184 msgid "Device public key not available" msgstr "" #: src/views/device_view.py:202 msgid "Factory Reset?" msgstr "" #: src/views/device_view.py:204 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:212 msgid "Reset" msgstr "" #: src/views/device_view.py:224 msgid "Reboot Device?" msgstr "" #: src/views/device_view.py:225 msgid "The device will disconnect and restart." msgstr "" #: src/views/device_view.py:319 msgid "Not set" msgstr "" #. No GPS hardware on companion #: src/views/device_view.py:327 src/views/device_view.py:334 msgid "Location (manually set)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS)" msgstr "" #: src/views/device_view.py:330 msgid "Location (GPS on, no fix)" msgstr "" #: src/views/device_view.py:334 msgid "Location (GPS off)" msgstr "" #: src/views/device_view.py:359 src/views/repeater_view.py:401 msgid "Requesting…" msgstr "" #: src/views/device_view.py:443 #, python-brace-format msgid "{days}d" msgstr "" #: src/views/device_view.py:445 #, python-brace-format msgid "{hours}h" msgstr "" #: src/views/device_view.py:446 #, python-brace-format msgid "{mins}m" msgstr "" #: src/views/device_view.py:448 #, python-brace-format msgid "{} messages" msgstr "" #: src/views/device_view.py:469 #, python-brace-format msgid "{count} errors" msgstr "" #: src/views/device_view.py:484 src/views/device_view.py:486 msgid "Connected" msgstr "" #: src/views/device_view.py:498 msgid "Trace Path is not supported with 3-byte path hashes" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aa,bb,cc" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabb,ccdd" msgstr "" #: src/views/device_view.py:520 msgid "e.g. aabbcc,ddeeff" msgstr "" #: src/views/device_view.py:613 msgid "No repeaters with known location" msgstr "" #: src/views/device_view.py:675 #, python-brace-format msgid "Trace complete: {} hops, {}" msgstr "" #: src/views/device_view.py:681 msgid "Result" msgstr "" #: src/views/device_view.py:729 msgid "Trace timed out" msgstr "" #: src/views/device_view.py:745 msgid "Invalid hex in path" msgstr "" #: src/views/device_view.py:756 msgid "Tracing..." msgstr "" #: src/views/device_view.py:925 src/views/device_view.py:1135 #, python-brace-format msgid "Scanning... {}s remaining" msgstr "" #: src/views/device_view.py:970 src/views/map_view.py:892 msgid "Add to Contacts" msgstr "" #: src/views/device_view.py:977 #, python-brace-format msgid "Added {}" msgstr "" #: src/views/device_view.py:1000 msgid "Query Regions" msgstr "" #: src/views/device_view.py:1006 msgid "Query Name & Owner" msgstr "" #: src/views/device_view.py:1019 msgid "Query Details" msgstr "" #: src/views/device_view.py:1032 msgid "No region response" msgstr "" #: src/views/device_view.py:1055 msgid "No owner response" msgstr "" #: src/views/device_view.py:1068 msgid "No owner info set" msgstr "" #: src/views/device_view.py:1076 msgid "Owner" msgstr "" #: src/views/device_view.py:1105 msgid "Node" msgstr "" #: src/views/device_view.py:1143 #, python-brace-format msgid "Done. {} nodes found." msgstr "" #: src/views/device_view.py:1145 #, python-brace-format msgid "Done. {} node found." msgstr "" #: src/views/device_view.py:1207 #, python-brace-format msgid "{unit} over last 2 minutes" msgstr "" #: src/views/device_view.py:1356 msgid "Size" msgstr "" #: src/views/device_view.py:1356 msgid "bytes" msgstr "" #: src/views/device_view.py:1361 msgid "hops" msgstr "" #: src/views/device_view.py:1362 msgid "Path Hashes" msgstr "" #: src/views/device_view.py:1362 msgid "byte per hop" msgstr "" #: src/views/device_view.py:1421 #, python-brace-format msgid "Update available: {version}" msgstr "" #. Telemetry combo models — shared label set #: src/views/settings_view.py:158 msgid "Deny All" msgstr "" #: src/views/settings_view.py:158 msgid "Allow per Contact" msgstr "" #: src/views/settings_view.py:158 msgid "Allow All" msgstr "" #: src/views/settings_view.py:642 msgid "Online node contacts deleted" msgstr "" #: src/views/settings_view.py:797 msgid "Settings applied on device" msgstr "" #: src/views/settings_view.py:822 msgid "Unknown error" msgstr "" #: src/views/settings_view.py:823 #, python-brace-format msgid "Settings error: {}" msgstr "" #: src/views/settings_view.py:847 #, python-brace-format msgid "TX Power (dBm, max {})" msgstr "" #: src/views/settings_view.py:967 msgid "Location unavailable" msgstr "" #: src/views/settings_view.py:971 msgid "Location filled, click Apply to save" msgstr "" #: src/views/settings_view.py:994 msgid "Pick Location" msgstr "" #: src/views/settings_view.py:994 msgid "Set Location" msgstr "" #: src/views/settings_view.py:1104 msgid "Connect to a device first" msgstr "" #: src/views/settings_view.py:1116 src/views/settings_view.py:1298 msgid "Discovering repeaters in range..." msgstr "" #: src/views/settings_view.py:1134 #, python-brace-format msgid "Found {} regions" msgstr "" #: src/views/settings_view.py:1136 #, python-brace-format msgid "Found {} region" msgstr "" #: src/views/settings_view.py:1144 msgid "" "Contact list on the device is full. Remove some contacts to discover regions." msgstr "" #: src/views/settings_view.py:1148 msgid "No regions found" msgstr "" #: src/views/settings_view.py:1150 msgid "No repeaters in range" msgstr "" #: src/views/settings_view.py:1166 src/views/settings_view.py:1175 #, python-brace-format msgid "from {}" msgstr "" #: src/views/settings_view.py:1168 src/views/settings_view.py:1177 msgid "(already added)" msgstr "" #: src/views/settings_view.py:1247 #, python-brace-format msgid "Querying {} repeaters..." msgstr "" #: src/views/settings_view.py:1249 #, python-brace-format msgid "Querying {} repeater..." msgstr "" #: src/views/settings_view.py:1343 #, python-brace-format msgid "Added {} regions" msgstr "" #: src/views/settings_view.py:1345 #, python-brace-format msgid "Added {} region" msgstr "" #: src/views/settings_view.py:1430 src/views/settings_view.py:1541 #: src/views/settings_view.py:1575 msgid "Not Connected" msgstr "" #: src/views/settings_view.py:1431 msgid "Connect to a device first to export a backup." msgstr "" #: src/views/settings_view.py:1470 src/views/settings_view.py:1551 msgid "JSON files" msgstr "" #: src/views/settings_view.py:1483 msgid "Backup exported successfully" msgstr "" #: src/views/settings_view.py:1487 #, python-brace-format msgid "Export failed: {}" msgstr "" #: src/views/settings_view.py:1497 msgid "Device does not support key export" msgstr "" #: src/views/settings_view.py:1542 msgid "Connect to a device first to import a backup." msgstr "" #: src/views/settings_view.py:1576 msgid "Connect to a device first to restore a backup." msgstr "" #: src/views/settings_view.py:1591 msgid "No Auto Backups" msgstr "" #: src/views/settings_view.py:1592 msgid "No automatic backups found." msgstr "" #: src/views/settings_view.py:1658 #, python-brace-format msgid "{} (current)" msgstr "" #: src/views/settings_view.py:1685 msgid "Show Only This Device" msgstr "" #: src/views/settings_view.py:1734 #, python-brace-format msgid "Could not read backup file: {}" msgstr "" #: src/views/settings_view.py:1750 #, python-brace-format msgid "Source: {}" msgstr "" #: src/views/settings_view.py:1783 #, python-brace-format msgid "{} contacts" msgstr "" #: src/views/settings_view.py:1785 #, python-brace-format msgid "{} channels" msgstr "" #: src/views/settings_view.py:1789 msgid "messages" msgstr "" #: src/views/settings_view.py:1791 msgid "device identity" msgstr "" #: src/views/settings_view.py:1793 #, python-brace-format msgid "Imported: {}" msgstr "" #: src/views/settings_view.py:1795 msgid "Nothing new to import" msgstr "" #: src/views/repeater_view.py:156 msgid "room" msgstr "" #: src/views/repeater_view.py:158 msgid "sensor" msgstr "" #: src/views/repeater_view.py:159 msgid "repeater" msgstr "" #: src/views/repeater_view.py:314 #, python-brace-format msgid "Time sync sent to {}" msgstr "" #: src/views/repeater_view.py:321 msgid "Reset Clock & Reboot?" msgstr "" #: src/views/repeater_view.py:323 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:338 msgid "Rebooting..." msgstr "" #: src/views/repeater_view.py:340 #, python-brace-format msgid "Clock reset & reboot sent to {}" msgstr "" #: src/views/repeater_view.py:353 src/views/repeater_view.py:498 #: src/views/repeater_view.py:634 src/views/repeater_view.py:811 msgid "Request timed out" msgstr "" #: src/views/repeater_view.py:522 #, python-brace-format msgid "{} of {} neighbors" msgstr "" #: src/views/repeater_view.py:539 msgid "Neighbor Map" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entry" msgstr "" #: src/views/repeater_view.py:669 #, python-brace-format msgid "{} entries" msgstr "" #: src/views/repeater_view.py:688 msgid "Remove from ACL?" msgstr "" #: src/views/repeater_view.py:689 #, python-brace-format msgid "Remove {} from the access control list?" msgstr "" #: src/views/repeater_view.py:711 msgid "Role" msgstr "" #: src/views/repeater_view.py:712 msgid "Admin" msgstr "" #: src/views/repeater_view.py:712 msgid "Read-Write" msgstr "" #: src/views/repeater_view.py:712 msgid "Read Only" msgstr "" #: src/views/repeater_view.py:737 msgid "Add to Access Control" msgstr "" #: src/views/repeater_view.py:839 msgid "Global (wildcard)" msgstr "" #: src/views/repeater_view.py:842 msgid "Home" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood allowed" msgstr "" #: src/views/repeater_view.py:843 msgid "Flood denied" msgstr "" #: src/views/repeater_view.py:878 msgid "Deny Flood" msgstr "" #: src/views/repeater_view.py:878 msgid "Allow Flood" msgstr "" #: src/views/repeater_view.py:882 msgid "Set as Home" msgstr "" #: src/views/repeater_view.py:934 msgid "Remove child regions first" msgstr "" #: src/views/repeater_view.py:972 msgid "Unsaved Changes" msgstr "" #: src/views/repeater_view.py:973 msgid "You have unsaved region changes." msgstr "" #: src/views/repeater_view.py:975 msgid "Discard" msgstr "" #: src/views/repeater_view.py:1201 src/views/repeater_view.py:1388 msgid "Unscoped" msgstr "" #: src/views/repeater_view.py:1213 msgid "Admin password changed — re-login required" msgstr "" #: src/views/repeater_view.py:1297 #, python-brace-format msgid "Settings sent to {}" msgstr "" #: src/views/repeater_view.py:1301 #, python-brace-format msgid "Reboot {}?" msgstr "" #: src/views/repeater_view.py:1304 #, 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:1462 msgid "Time is off" msgstr "" #: src/views/chat_helpers.py:68 msgid "Shared Location" msgstr "" #: src/views/chat_helpers.py:70 msgid "Shared" msgstr "" #: src/views/chat_helpers.py:145 msgid "QR code library not available" msgstr "" #: src/views/chat_helpers.py:171 msgid "Location Not Available" msgstr "" #: src/views/chat_helpers.py:172 msgid "Your companion does not have a GPS location." msgstr "" #: src/views/chat_view.py:167 msgid "Send Automatically" msgstr "" #: src/views/chat_view.py:168 msgid "Send Direct" msgstr "" #: src/views/chat_view.py:169 msgid "Send Flood" msgstr "" #: src/views/chat_view.py:548 src/views/chat_view.py:549 msgid "Sending" msgstr "" #: src/views/chat_view.py:664 msgid "Read-only room" msgstr "" #: src/views/map_view.py:381 #, python-brace-format msgid "{} of {} contacts on map" msgstr "" #: src/views/map_view.py:383 #, python-brace-format msgid "{} of {} located on map" msgstr "" #: src/views/map_view.py:385 #, python-brace-format msgid "{} discovered" msgstr "" #: src/views/map_view.py:427 msgid "Any time" msgstr "" #: src/views/map_view.py:427 msgid "Past month" msgstr "" #: src/views/map_view.py:427 msgid "Past day" msgstr "" #: src/views/map_view.py:427 msgid "Past hour" msgstr "" #: src/views/map_view.py:632 #, python-brace-format msgid "and {} more" msgstr "" #: src/views/map_view.py:868 msgid "Position is illustrative — real location unknown" msgstr "" #: src/views/map_view.py:876 #, python-brace-format msgid "{} dB" msgstr "" #: src/views/map_view.py:876 msgid "SNR" msgstr "" #: src/views/map_view.py:1250 msgid "Pick Trace Route" msgstr "" #: src/views/map_view.py:1263 msgid "_Done" msgstr "" #: src/views/map_view.py:1450 msgid "Tap repeaters to build the trace route" msgstr "" #: src/map_nodes.py:193 #, python-brace-format msgid "{count} nodes imported from online database" msgstr "" meshy/po/update-translations.sh000077500000000000000000000055501526212641700171740ustar00rootroot00000000000000#!/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. # # Runs meson's gettext machinery directly, so updating translations does not # require configuring the project (and thus no glib/gtk development packages). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" POTFILES="$SCRIPT_DIR/POTFILES" # Check required tools up front for a clear error instead of a meson failure missing_tools=() for tool in meson msgfmt xgettext; do command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool") done if [ ${#missing_tools[@]} -gt 0 ]; then echo "ERROR: Missing required tools: ${missing_tools[*]}" >&2 echo "On Fedora install them with: sudo dnf install gettext meson" >&2 exit 1 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 # Reuse meson's 'glib' xgettext preset so the generated template matches the # one a normal meson build would produce. if ! GLIB_PRESET=$(python3 -c \ "from mesonbuild.modules.i18n import PRESET_ARGS; print('@@'.join(PRESET_ARGS['glib']))" 2>/dev/null); then echo "ERROR: Could not read meson's gettext preset (is meson available as a Python module?)" >&2 exit 1 fi echo "Regenerating .pot template..." meson --internal gettext pot \ --source-root="$PROJECT_DIR" --subdir=po --pkgname=meshy \ --extra-args="$GLIB_PRESET" 2>/dev/null echo "Updating .po files..." meson --internal gettext update_po \ --source-root="$PROJECT_DIR" --subdir=po --pkgname=meshy \ --extra-args="$GLIB_PRESET" 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.toml000066400000000000000000000040351526212641700151270ustar00rootroot00000000000000[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", "msgpack>=1.1.0", ] 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/000077500000000000000000000000001526212641700130005ustar00rootroot00000000000000meshy/src/__init__.py000066400000000000000000000004441526212641700151130ustar00rootroot00000000000000# 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.py000066400000000000000000000411631526212641700156620ustar00rootroot00000000000000# 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 from meshy.constants import SCHEMA_ID log = logging.getLogger(__name__) class MeshyApplication(Adw.Application): def __init__(self, version="26.10", **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._background_granted = 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=SCHEMA_ID) 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 from meshy.protocol import parse_meshcore_uri from meshy.views import show_confirm_contact_dialog _error_msgs = { "invalid_hex": _("Invalid meshcore:// link"), "invalid_advert_frame": _("Invalid meshcore:// link"), "invalid_secret": _("Invalid channel secret in link"), "invalid_secret_length": _("Invalid channel secret length"), "invalid_public_key": _("Invalid public key in link"), "invalid_public_key_length": _("Invalid public key length"), "unrecognized_link": _("Unrecognized meshcore:// link"), } result = parse_meshcore_uri(uri) if result["kind"] is None: self._window.show_toast(_error_msgs.get(result["error"], _("Invalid meshcore:// link"))) return if result["kind"] == "channel": from meshy.views import show_confirm_channel_dialog show_confirm_channel_dialog( self._window, result["name"], result["secret"], result.get("region_scope") ) return # kind == "contact" pub_key_hex = result["pub_key_hex"] name = result["name"] contact_type = result["type"] raw_frame = result["raw_frame"] if self._window.is_contact_known(pub_key_hex): self._window.show_toast(_("{} is already in your list").format(name or _("Contact"))) return if raw_frame: orig_name = name orig_type = contact_type def _on_add_raw(pk, ct, n): # Import raw frame first (preserves path from advert) if not self._window.import_contact(raw_frame): return # If user edited name/type in dialog, send an update if n != orig_name or ct != orig_type: self._window.add_contact(pk, ct, n) self._window.show_toast(_("Contact {} added").format(n)) show_confirm_contact_dialog( self._window, pub_key_hex, name=name, contact_type=contact_type, on_add=_on_add_raw, ) else: def _on_add(pk, ct, n): if self._window.add_contact(pk, ct, n): self._window.show_toast(_("Contact {} added").format(n)) show_confirm_contact_dialog( self._window, pub_key_hex, name=name, contact_type=contact_type, on_add=_on_add, ) 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) else: log.warning( "meshy.gresource not found at %s; bundled icons and UI resources " "will be missing (build with meson or set MESHY_PKGDATADIR)", resource_path, ) 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=SCHEMA_ID) 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 self._background_granted = False 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 self._background_granted = 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) self._background_granted = bg_granted if not bg_granted and self._background_held: self.release() self._background_held = False if self._window: self._window.set_background_mode(False) self._bg_portal.clear_status() elif bg_granted and self._window: # Background permission is now in effect; publish the current # companion name as the status message shown by the background # apps monitor. self._window._update_background_status() def set_background_message(self, message): """Set (or clear, with an empty string) the background status message.""" if self._background_granted: self._bg_portal.set_status(message) 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._save_window_state() 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"]) # Register both the number row and the keypad: they produce different # keysyms (GDK_KEY_5 vs GDK_KEY_KP_5), so a top-row-only accelerator # never matches a keypad press. for i, view in enumerate(("device", "contacts", "channels", "map", "settings"), start=1): self.set_accels_for_action( f"win.navigate-{view}", [f"{i}", f"KP_{i}"], ) 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"]) self.set_accels_for_action("win.toggle-fullscreen", ["f"]) 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", "TeTeHacko", "stereo", "Jascha", "vanous", "Hylke Bons", "Moritz Bitsch", "Petr Menšík", "Manuel Traut", "Lukáš Viktora", "clauwn", "nakonechny", ], 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" "Jiri Eischmann — Czech\n" "Sanyanov — Russian\n" "Priit Jõerüüt — Estonian\n" "yeager — Swedish" ), 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.py000066400000000000000000000112251526212641700170530ustar00rootroot00000000000000# 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: # Portal expects a single line of at most 96 characters. message = " ".join(str(message).split())[:96] 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.py000066400000000000000000001036261526212641700146270ustar00rootroot00000000000000# 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, timedelta from gi.repository import GLib from meshy.constants import SCHEMA_ID from meshy.models import ( ChannelMessage, Contact, Message, MessageStatus, NotificationLevel, ) from meshy.protocol import ( MAX_PATH_SIZE, build_add_update_contact, build_device_query, build_set_auto_add_config, build_set_channel, build_set_default_flood_scope, compute_scope_key, encode_path_len, ) log = logging.getLogger(__name__) _MISSING = object() 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: self._export_device_settings(backup, win, si, di) if "channels" in sections: self._export_channels(backup, win) if "contacts" in sections: self._export_contacts(backup, win) if win.storage: if "messages" in sections: self._export_messages(backup, win) if "regions" in sections: self._export_regions(backup, win) if "discovered_contacts" in sections: self._export_discovered_contacts(backup, win) if "path_history" in sections: self._export_path_history(backup, win) if "local_settings" in sections: self._export_local_settings(backup, win) return backup def _export_device_settings(self, backup, win, si, di): 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), "path_hash_mode": getattr(di, "path_hash_mode", 0), } backup["auto_add_settings"] = win.get_auto_add_config() def _export_channels(self, backup, win): channels_list = [] for ch in win.channels: if not ch.is_empty: region = (ch.region or "").lstrip("#") channels_list.append( { "name": ch.name, "secret": ch.psk_hex, "region_scope_name": f"#{region}" if region else None, "region_scope_key": compute_scope_key(region).hex() if region else None, } ) 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 def _export_contacts(self, backup, win): contacts_list = [] for c in win.contacts: contacts_list.append( { "type": c.type, "name": c.name, "custom_name": c.custom_name or None, "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_list": self._contact_out_path(c), "path_hash_mode": max(c.path_hash_size - 1, 0), } ) if contacts_list: backup["contacts"] = contacts_list @staticmethod def _contact_out_path(c) -> str | None: """Encode a contact path as the official ``out_path_list``. ``None`` = unknown/flood routing, ``""`` = direct (zero hops), otherwise a comma-separated list of hex hop hashes. """ if c.path_length < 0: return None if c.path_length == 0 or not c.path_hops: return "" return ",".join(h.lower() for h in c.path_hops) def _export_messages(self, backup, win): 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) def _export_regions(self, backup, win): regions = win.storage.get_regions() if regions: backup["regions"] = regions def _export_discovered_contacts(self, backup, win): 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 def _export_path_history(self, backup, win): 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 def _export_local_settings(self, backup, win): local = {} scope = win.storage.get_default_scope() if scope: local["default_scope"] = scope if local: backup["local_settings"] = local _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: settings_cmds = self._import_device_settings(win, backup) for i, cmd in enumerate(settings_cmds): GLib.timeout_add(i * 300, lambda c=cmd: c() or False) settings_delay = len(settings_cmds) * 300 device_cmds = [] if "channels" in sections: cmds, count = self._import_channels(win, backup) device_cmds.extend(cmds) imported_channels = count if "contacts" in sections: cmds, count = self._import_contacts(win, backup) device_cmds.extend(cmds) imported_contacts = count for i, cmd in enumerate(device_cmds): GLib.timeout_add(settings_delay + i * 300, lambda c=cmd: c() or False) if win.storage: if "messages" in sections: self._import_messages(win, backup) if "regions" in sections: self._import_regions(win, backup) if "discovered_contacts" in sections: self._import_discovered_contacts(win, backup) if "path_history" in sections: self._import_path_history(win, backup) if "local_settings" in sections: self._import_local_settings(win, backup) 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 def _import_device_settings(self, win, backup): cmds = [] di = win._device_info name = backup.get("name", "") if name: cmds.append(lambda: win.set_advert_name(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: # The command takes freq in kHz and bw in Hz; DeviceInfo stores # freq in MHz and bw in kHz (matching SELF_INFO and the UI). dev_cr = cr - 4 if getattr(di, "cr_old_encoding", False) else cr cmds.append(lambda: win.set_radio_params(freq, bw, sf, dev_cr)) di.radio_freq = freq / 1000 di.radio_bw = bw / 1000 di.radio_sf = sf di.radio_cr = cr if tx: cmds.append(lambda: win.set_tx_power(tx)) di.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: cmds.append(lambda: win.set_advert_latlon(lat, lon)) di.latitude = lat di.longitude = lon except (ValueError, TypeError): pass si = win._self_info other = backup.get("other_settings", {}) if other: manual = other.get("manual_add_contacts") if manual is not None: cmds.append(lambda v=int(manual): win.set_manual_add_contacts(v)) loc_policy = other.get("advert_location_policy") if loc_policy is not None: cmds.append(lambda p=int(loc_policy): win.set_advert_loc_policy(p)) base = other.get("telemetry_mode_base") loc = other.get("telemetry_mode_location") env = other.get("telemetry_mode_environment") if base is not None or loc is not None or env is not None: b = int(base) if base is not None else si.get("telemetry_mode_base", 0) lo = int(loc) if loc is not None else si.get("telemetry_mode_loc", 0) e = int(env) if env is not None else si.get("telemetry_mode_env", 0) cmds.append(lambda b=b, lo=lo, e=e: win.set_telemetry_mode(b, lo, e)) multi_acks = other.get("multi_acks") if multi_acks is not None: cmds.append(lambda v=int(multi_acks): win.set_multi_acks(v)) path_hash_mode = other.get("path_hash_mode") if path_hash_mode is not None: di.path_hash_mode = int(path_hash_mode) cmds.append(lambda m=int(path_hash_mode): win.set_path_hash_mode(m)) auto = backup.get("auto_add_settings", {}) if auto: max_hops = auto.get("auto_add_max_hops", auto.get("max_hops", 0)) 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=max_hops, ) cmds.append(lambda: win.send_frame(frame)) win._settings_view.update_device_info(di) return cmds def _import_channels(self, win, backup): cmds = [] imported = 0 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) existing = next( (c for c in win.channels if not c.is_empty and c.psk_hex == secret_hex), None, ) if existing is not None: self._apply_channel_region(win, existing.index, ch_data, existing) 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: cmds.append( lambda s=slot, n=ch_name, p=psk: win.send_frame( build_set_channel(s, n, p) ) ) imported += 1 used.add(slot) self._apply_channel_region(win, slot, ch_data) else: log.warning("No free channel slot for '%s' during import; skipped", ch_name) 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) return cmds, imported @staticmethod def _apply_channel_region(win, slot: int, ch_data: dict, channel=None): """Restore the app-side flood scope (region) for a channel slot. Meshy stores region names without the leading ``#`` (the official backup format includes it), so normalize to the local convention. """ region = (ch_data.get("region_scope_name") or "").strip().lstrip("#") if not region: return if channel is not None: channel.region = region else: for c in win.channels: if c.index == slot: c.region = region break if win.storage: win.storage.add_region(region) win.storage.set_channel_region(slot, region) win._pending_channel_regions[slot] = region def _import_contacts(self, win, backup): cmds = [] imported = 0 contacts = backup.get("contacts", []) contacts_by_key = {c.public_key_hex: c for c in win.contacts} custom_names = {} device_hash_mode = getattr(getattr(win, "_device_info", None), "path_hash_mode", 0) default_hash_size = max(device_hash_mode, 0) + 1 if isinstance(device_hash_mode, int) else 1 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 "custom_name" in c_data: custom_names[pub_key_hex] = (c_data.get("custom_name") or "").strip() 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") last_advert = c_data.get("last_advert") or None path_len, path_bytes = self._parse_contact_path(c_data, default_hash_size) cmds.append( lambda pk=pub_key, ct=contact_type, fl=flags, n=c_name, la=lat, lo=lon, lm=last_mod, ladv=last_advert, pl=path_len, pb=path_bytes: win.send_frame( build_add_update_contact( pub_key=pk, contact_type=ct, flags=fl, path_len=pl, path=pb, name=n, lat=la if abs(la) > 1e-6 else None, lon=lo if abs(lo) > 1e-6 else None, last_modified=lm, last_advert=ladv, ) ) ) imported += 1 except (ValueError, TypeError): pass self._apply_custom_names(win, custom_names) return cmds, imported @staticmethod def _parse_contact_path(c_data: dict, default_hash_size: int) -> tuple[int, bytes]: """Return ``(path_len_byte, path_bytes)`` for a contact backup entry. Handles the official ``out_path_list`` (comma-separated hex hops), MeshCoreOne's contiguous ``out_path`` and an optional ``path_hash_mode``. 0xFF means unknown/flood, 0 means direct. """ raw = c_data.get("out_path_list", _MISSING) if raw is _MISSING: raw = c_data.get("out_path", _MISSING) if raw is _MISSING or raw is None: return (0xFF, b"") if isinstance(raw, list): raw = ",".join(str(h) for h in raw) text = str(raw).strip() if not text: return (0, b"") mode = c_data.get("path_hash_mode") mode_hash = int(mode) + 1 if mode is not None else None if "," in text: hops = [h.strip() for h in text.split(",") if h.strip()] try: hop_bytes = [bytes.fromhex(h) for h in hops] except ValueError: return (0xFF, b"") sizes = {len(b) for b in hop_bytes} if len(sizes) != 1 or 0 in sizes: return (0xFF, b"") hash_size = next(iter(sizes)) path = b"".join(hop_bytes) else: hash_size = mode_hash or default_hash_size try: path = bytes.fromhex(text) except ValueError: return (0xFF, b"") if hash_size <= 0 or len(path) % hash_size != 0: return (0xFF, b"") hop_count = len(path) // hash_size if hop_count <= 0 or len(path) > MAX_PATH_SIZE: return (0xFF, b"") return (encode_path_len(hop_count, hash_size), path) @staticmethod def _apply_custom_names(win, custom_names: dict[str, str]): """Restore locally-stored custom names (not stored on the device).""" if not custom_names: return for c in win.contacts: name = custom_names.pop(c.public_key_hex, None) if name is not None: c.custom_name = name if win.storage: win.storage.save_contact(c) # Remaining names belong to contacts not yet present locally; apply # them when the device contact list is (re)fetched. if custom_names: win._pending_custom_names.update(custom_names) def _import_messages(self, win, backup): 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) def _import_regions(self, win, backup): for region_name in backup.get("regions", []): win.storage.add_region(region_name) def _import_discovered_contacts(self, win, backup): 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) def _import_path_history(self, win, backup): 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"), ) def _import_local_settings(self, win, backup): local = backup.get("local_settings", {}) if "default_scope" in local: win.storage.set_default_scope(local["default_scope"]) # The device's own value is read back on connect, so a restored # scope only sticks if it is written to the device now. if win.is_connected: win.send_frame(build_set_default_flood_scope(local["default_scope"])) win._settings_view.restore_default_scope() _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 from meshy.companion_setup import should_offer if should_offer(win) or getattr(win, "_onboarding_dialog_open", False): # A freshly flashed companion has no data worth snapshots, and an # empty snapshot here would clutter the restore list before setup. # Once setup has been offered (or skipped) backups resume normally. log.info("Skipping auto-backup while companion setup is pending") return False try: from gi.repository import Gio settings = Gio.Settings.new(SCHEMA_ID) sections = set(settings.get_strv("auto-backup-sections")) sections.discard("device_identity") backup = self._backup.export_backup(win, sections=sections or None) device_id = win._device_info.public_key or win._transport.connected_address or "unknown" safe_id = self._safe_address(device_id) date_str = datetime.now().strftime("%Y-%m-%d") filename = f"auto_{safe_id}_{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) transport_addr = win._transport.connected_address if transport_addr and device_id != transport_addr: self._prune(safe_id, self._safe_address(transport_addr)) else: self._prune(safe_id) 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, device_pubkey: str | None = None, ) -> list[dict]: backups_dir = self._backups_dir() patterns = [] if device_pubkey: patterns.append(f"auto_{self._safe_address(device_pubkey)}_*.json") if device_address: patterns.append(f"auto_{self._safe_address(device_address)}_*.json") if not patterns: patterns.append("auto_*.json") seen = set() files = [] for pat in patterns: for f in glob.glob(os.path.join(backups_dir, pat)): if f not in seen: seen.add(f) files.append(f) files.sort(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_addresses: str): seen = set() files = [] for sa in safe_addresses: for f in glob.glob(os.path.join(self._backups_dir(), f"auto_{sa}_*.json")): if f not in seen: seen.add(f) files.append(f) files.sort() cutoff = (datetime.now() - timedelta(days=_MAX_AUTO_BACKUPS)).date() while len(files) > _MAX_AUTO_BACKUPS: oldest = files[0] try: date_str = os.path.basename(oldest).rsplit("_", 1)[-1].replace(".json", "") file_date = datetime.strptime(date_str, "%Y-%m-%d").date() except ValueError: break if file_date >= cutoff: break 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.py000066400000000000000000000037731526212641700141260ustar00rootroot00000000000000# 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.py000066400000000000000000000014111526212641700145770ustar00rootroot00000000000000# 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/chat_entry.py000066400000000000000000000006261526212641700155160ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Thin Adw.Bin wrapper with css_name="entry" for native entry styling.""" import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw class ChatEntry(Adw.Bin): __gtype_name__ = "MeshyChatEntry" ChatEntry.set_css_name("entry") meshy/src/cli_completion.py000066400000000000000000000173361526212641700163640ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Pure CLI command completion for remote MeshCore nodes. Mirrors the behaviour of MeshCoreOne's ``CLICompletionEngine`` but targets the remote-node vocabulary (repeaters, room servers, sensors) used by Meshy. The command and parameter sets come from the MeshCore CLI reference and the firmware dispatch in ``CommonCLI.cpp`` / ``SensorMesh.cpp`` / ``MyMesh.cpp``. GTK-free so it can be unit tested without a display. """ from __future__ import annotations _COMMON_COMMANDS = [ "advert", "advert.zerohop", "board", "clkreboot", "clock", "clear", "get", "gps", "log", "password", "poweroff", "reboot", "region", "sensor", "set", "setperm", "shutdown", "start", "tempradio", "time", "ver", ] _REPEATER_CLASS_COMMANDS = [ "eth.status", "powersaving", ] _REPEATER_ONLY_COMMANDS = [ "discover.neighbors", "neighbor.remove", "neighbors", ] _SENSOR_COMMANDS = [ "io", ] # Companion radio vocabulary (examples/companion_radio/MyMesh.cpp handleCommand, # CommonRadioPrefs and board hooks). Nodes that do not define the WiFi interface # or a given board hook simply reply "Unknown command" for those entries. _COMPANION_COMMANDS = [ "board", "get", "poweroff", "reboot", "set", "shutdown", "ver", ] # Parameters accepted by both `get` and `set` on a companion. _COMPANION_PARAMS = [ "af", "agc.reset.interval", "cad", "direct.txdelay", "dutycycle", "fan", "int.thresh", "multi.acks", "name", "path.hash.mode", "radio", "radio.fem.rxgain", "radio.fem.txgain", "radio.rxgain", "rxdelay", "tx", "txdelay", "tz.offset", "wifi.enabled", "wifi.pwd", "wifi.ssid", ] _COMPANION_GET_ONLY = ["freq", "wifi.ip", "wifi.status"] _COMPANION_SET_ONLY = ["fan.hi", "fan.lo", "pin", "wifi.clear"] _COMPANION_GET_PARAMS = sorted(_COMPANION_PARAMS + _COMPANION_GET_ONLY) _COMPANION_SET_PARAMS = sorted(_COMPANION_PARAMS + _COMPANION_SET_ONLY) _COMPANION_SET_ENUMS = { "cad": ["off", "on"], "radio.rxgain": ["off", "on"], "radio.fem.rxgain": ["off", "on"], "radio.fem.txgain": ["off", "on"], "wifi.enabled": ["0", "1"], } _SUBCOMMANDS = { "clock": ["sync"], "clear": ["stats"], "start": ["ota"], "log": ["start", "stop", "erase"], "powersaving": ["on", "off"], "sensor": ["get", "list", "set"], "region": [ "allowf", "def", "default", "denyf", "get", "home", "list", "load", "put", "remove", "save", ], "gps": ["advert", "off", "on", "setloc", "sync"], } _GET_SET_PARAMS = [ "adc.multiplier", "advert.interval", "af", "agc.reset.interval", "allow.read.only", "bootloader.ver", "bridge.baud", "bridge.channel", "bridge.delay", "bridge.enabled", "bridge.secret", "bridge.source", "bridge.type", "cad", "direct.txdelay", "dutycycle", "flood.advert.interval", "flood.max", "flood.max.advert", "flood.max.unscoped", "freq", "guest.password", "int.thresh", "lat", "lon", "loop.detect", "multi.acks", "name", "owner.info", "path.hash.mode", "prv.key", "public.key", "pwrmgt.bootmv", "pwrmgt.bootreason", "pwrmgt.source", "pwrmgt.support", "radio", "radio.fem.rxgain", "radio.fem.txgain", "radio.rxgain", "repeat", "role", "rxdelay", "tx", "txdelay", ] _SERIAL_ONLY_GET_PARAMS = {"prv.key"} _SERIAL_ONLY_SET_PARAMS = {"freq"} _SET_VALUE_ENUMS = { "repeat": ["off", "on"], "allow.read.only": ["off", "on"], "bridge.enabled": ["off", "on"], "bridge.source": ["logRx", "logTx"], "cad": ["off", "on"], "radio.rxgain": ["off", "on"], "radio.fem.rxgain": ["off", "on"], "radio.fem.txgain": ["off", "on"], "path.hash.mode": ["0", "1", "2"], "loop.detect": ["minimal", "moderate", "off", "strict"], "multi.acks": ["0", "1"], } _GPS_ADVERT_VALUES = ["none", "prefs", "share"] _REGION_LIST_VALUES = ["allowed", "denied"] _SUBCOMMAND_VALUES = { "gps": {"advert": _GPS_ADVERT_VALUES}, "region": {"list": _REGION_LIST_VALUES}, } def available_commands(node_type: str | None = None) -> list[str]: """Return the sorted top-level commands valid for a node type.""" node = (node_type or "").lower() if node == "companion": return sorted(set(_COMPANION_COMMANDS)) commands = list(_COMMON_COMMANDS) if node == "sensor": commands += _SENSOR_COMMANDS elif node == "room": commands += _REPEATER_CLASS_COMMANDS else: commands += _REPEATER_CLASS_COMMANDS + _REPEATER_ONLY_COMMANDS return sorted(set(commands)) def complete(text: str, node_type: str | None = None) -> list[str]: """Return completion suggestions for the given partial CLI input.""" trimmed = text.strip() words = trimmed.split() has_trailing_space = bool(text) and text[-1].isspace() if not words: return available_commands(node_type) command = words[0].lower() if len(words) == 1 and not has_trailing_space: return [c for c in available_commands(node_type) if c.startswith(command)] if has_trailing_space: arg_position = len(words) prefix = "" else: arg_position = len(words) - 1 prefix = words[-1].lower() return _complete_arguments(command, words, arg_position, prefix, node_type) def apply_completion(text: str, suggestion: str) -> str: """Replace the token being edited with *suggestion* and append a space.""" if not text.strip(): return suggestion + " " if text[-1].isspace(): return text + suggestion + " " words = text.split() if len(words) <= 1: return suggestion + " " return " ".join([*words[:-1], suggestion]) + " " def _complete_arguments( command: str, words: list[str], arg_position: int, prefix: str, node_type: str | None = None ) -> list[str]: if (node_type or "").lower() == "companion": if command == "get": if arg_position == 1: return _matching(_COMPANION_GET_PARAMS, prefix) return [] if command == "set": if arg_position == 1: return _matching(_COMPANION_SET_PARAMS, prefix) if arg_position == 2 and len(words) >= 2: return _matching( _COMPANION_SET_ENUMS.get(words[1].lower(), []), _value_prefix(words) ) return [] return [] if command in _SUBCOMMANDS: if arg_position == 1: return _matching(_SUBCOMMANDS[command], prefix) if arg_position == 2 and len(words) >= 2: values = _SUBCOMMAND_VALUES.get(command, {}).get(words[1].lower(), []) return _matching(values, _value_prefix(words)) return [] if command == "get": if arg_position != 1: return [] params = (p for p in _GET_SET_PARAMS if p not in _SERIAL_ONLY_GET_PARAMS) return _matching(params, prefix) if command == "set": if arg_position == 1: params = (p for p in _GET_SET_PARAMS if p not in _SERIAL_ONLY_SET_PARAMS) return _matching(params, prefix) if arg_position == 2 and len(words) >= 2: return _matching(_SET_VALUE_ENUMS.get(words[1].lower(), []), _value_prefix(words)) return [] return [] def _value_prefix(words: list[str]) -> str: return words[2].lower() if len(words) > 2 else "" def _matching(options, prefix: str) -> list[str]: return sorted(o for o in options if o.lower().startswith(prefix)) meshy/src/companion_setup.py000066400000000000000000000244361526212641700165660ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """First-time setup for a freshly flashed companion. The firmware has no explicit "configured" flag, so a companion is treated as new when its node name is still the auto-generated default: the uppercase hex of the first four bytes of its own public key (``MyMesh::begin()``). Once a name is persisted that default is replaced and the companion is left alone. """ import logging import os import re log = logging.getLogger(__name__) # Stored in the per-device settings table so a skipped companion is not # offered setup again on every connect. _ONBOARDING_SETTING = "onboarding_offered" # Testing aid: set to "1" to always offer the dialog for the connected # companion, ignoring both the name heuristic and the "already offered" flag. _FORCE_ENV = "MESHY_FORCE_NEW_COMPANION" # Auto-generated default names: the firmware's public-key prefix (4 bytes) or # a device/MAC identifier (6 bytes), both as uppercase hex. Boards built with # ``ADVERT_NAME="@@MAC"`` (e.g. Wio Tracker L1, T1000-E) replace the name with # the Bluetooth MAC at BLE init, or leave the literal marker when BLE is off. _DEFAULT_NAME_RE = re.compile(r"^[0-9A-F]{8}$|^[0-9A-F]{12}$") _MAC_MARKER = "@@MAC" def default_name_for(self_info: dict | None) -> str: """The firmware's auto-generated node name (first 4 pubkey bytes as hex).""" pubkey = ((self_info or {}).get("public_key") or "").strip() return pubkey[:8].upper() if len(pubkey) >= 8 else "" def looks_like_default_name(name: str) -> bool: """True for auto-generated identifiers such as ``3208B444`` or ``C2FDD39BD7DB``.""" return bool(_DEFAULT_NAME_RE.match(name.strip().upper())) def _mac_from_address(address: str | None) -> str: if not isinstance(address, str): return "" return address.replace(":", "").replace("-", "").upper() def is_unconfigured(self_info: dict | None, connected_address: str | None = None) -> bool: """Return True if *self_info* still carries an auto-generated name.""" if not self_info: return False name = (self_info.get("name") or "").strip() if not name: return False if name == _MAC_MARKER: return True if name.upper() == default_name_for(self_info): return True mac = _mac_from_address(connected_address) if mac and name.upper() == mac: return True return looks_like_default_name(name) def _connected_address(win) -> str | None: transport = getattr(win, "_transport", None) address = getattr(transport, "connected_address", None) return address if isinstance(address, str) else None def _already_offered(win) -> bool: storage = win.storage if storage is None: return False try: return storage.get_setting(_ONBOARDING_SETTING) == "1" except Exception: log.debug("Could not read onboarding flag", exc_info=True) return False def should_offer(win) -> bool: """Whether first-time setup should be offered for the connected companion.""" if not getattr(win, "is_connected", False): return False if os.environ.get(_FORCE_ENV) == "1": return True if not is_unconfigured(win.self_info, _connected_address(win)): return False return not _already_offered(win) def maybe_offer(win): """Offer first-time setup once per companion, after the initial sync.""" if getattr(win, "_onboarding_dialog_open", False): return if not should_offer(win): info = win.self_info or {} log.debug( "New-companion setup not offered " "(connected=%s name=%r expected=%r address=%r offered=%s force=%s)", getattr(win, "is_connected", False), (info.get("name") or "").strip(), default_name_for(info), _connected_address(win), _already_offered(win), os.environ.get(_FORCE_ENV) == "1", ) return log.info("Offering new-companion setup") # Mark as offered only after the dialog was actually built and shown, so a # failure to load the UI does not silently suppress the prompt forever. if show_new_companion_dialog(win) and win.storage is not None: try: win.storage.set_setting(_ONBOARDING_SETTING, "1") except Exception: log.debug("Could not persist onboarding flag", exc_info=True) def matching_preset_index(info) -> int: """Index into the preset combo (0 = Custom) matching the device radio params.""" from meshy.views.settings_view import RADIO_PRESETS try: freq = float(getattr(info, "radio_freq", 0) or 0) bw_khz = float(getattr(info, "radio_bw", 0) or 0) sf = int(getattr(info, "radio_sf", 0) or 0) cr = int(getattr(info, "radio_cr", 0) or 0) except (TypeError, ValueError): return 0 if not freq: return 0 for i, (_name, p_freq, p_bw, p_sf, p_cr, _tx, _ph) in enumerate(RADIO_PRESETS): if ( abs(freq - p_freq) < 0.001 and abs(bw_khz - p_bw / 1000) < 0.01 and sf == p_sf and cr == p_cr ): return i + 1 # +1 for "Custom" at index 0 return 0 def show_new_companion_dialog(win) -> bool: import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk from meshy.views import _ from meshy.views.settings_view import RADIO_PRESETS builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/new-companion-dialog.ui" ) dialog = builder.get_object("new_companion_dialog") stack = builder.get_object("stack") back_btn = builder.get_object("back_btn") forward_btn = builder.get_object("forward_btn") name_entry = builder.get_object("name_entry") preset_row = builder.get_object("preset_row") checks = { "new": builder.get_object("check_new"), "file": builder.get_object("check_file"), "auto": builder.get_object("check_auto"), } # Radio behaviour: all choices belong to the first button's group. checks["file"].set_group(checks["new"]) checks["auto"].set_group(checks["new"]) name_entry.set_text((win.self_info.get("name") or "").strip()) preset_model = Gtk.StringList() preset_model.append(_("Custom")) for name, *_rest in RADIO_PRESETS: preset_model.append(name) preset_row.set_model(preset_model) preset_row.set_selected(matching_preset_index(win.device_info)) def _selected() -> str: for key, check in checks.items(): if check.get_active(): return key return "new" def _sync_step(*_args): if stack.get_visible_child_name() == "setup": dialog.set_title(_("Set Up Companion")) back_btn.set_label(_("_Back")) forward_btn.set_label(_("_Finish")) forward_btn.set_sensitive(bool(name_entry.get_text().strip())) else: dialog.set_title(_("New Companion")) back_btn.set_label(_("_Cancel")) forward_btn.set_label(_("_Forward")) forward_btn.set_sensitive(True) def _on_back(*_args): if stack.get_visible_child_name() == "setup": stack.set_visible_child_name("choice") _sync_step() else: dialog.close() def _on_forward(*_args): if stack.get_visible_child_name() == "setup": name = name_entry.get_text().strip() if not name: return preset_index = preset_row.get_selected() dialog.close() apply_companion_setup(win, name, preset_index) return choice = _selected() if choice == "new": stack.set_visible_child_name("setup") _sync_step() elif choice == "file": # Keep this dialog open underneath; dismiss the picker to come back. win._settings_view._on_import_backup( parent=dialog, back_label=_("_Back"), on_restore_started=dialog.close, ) else: win._settings_view._on_restore_auto_backup( parent=dialog, back_label=_("_Back"), on_restore_started=dialog.close, ) back_btn.connect("clicked", _on_back) forward_btn.connect("clicked", _on_forward) name_entry.connect("changed", _sync_step) _sync_step() win._onboarding_dialog_open = True dialog.connect("closed", lambda _d: setattr(win, "_onboarding_dialog_open", False)) dialog.present(win) return True def apply_companion_setup(win, name: str, preset_index: int): """Send the chosen node name and radio preset to the companion.""" from gi.repository import GLib from meshy.views import _ from meshy.views.settings_view import RADIO_PRESETS # The dialog is shown right after SELF_INFO; defer the writes until the # initial sync is done so they don't race with it. if win._conn_ctrl.is_syncing(): win._conn_ctrl.run_after_sync(lambda: apply_companion_setup(win, name, preset_index)) return win.set_advert_name(name) win._device_info.name = name cmds = [] if 1 <= preset_index <= len(RADIO_PRESETS): _name, freq_mhz, bw_hz, sf, cr, _tx, ph = RADIO_PRESETS[preset_index - 1] dev_cr = cr - 4 if win._device_info.cr_old_encoding else cr cmds.append(lambda: win.set_radio_params(int(freq_mhz * 1000), bw_hz, sf, dev_cr)) if ph is not None and getattr(win._device_info, "fw_ver", 0) >= 10: cmds.append(lambda m=ph - 1: win.set_path_hash_mode(m)) win._device_info.radio_freq = freq_mhz win._device_info.radio_bw = bw_hz / 1000 win._device_info.radio_sf = sf win._device_info.radio_cr = cr for i, cmd in enumerate(cmds): GLib.timeout_add(300 * (i + 1), lambda c=cmd: c() or False) delay = 300 * (len(cmds) + 1) GLib.timeout_add( delay, lambda: win._settings_view.update_device_info(win._device_info) or False ) GLib.timeout_add( delay + 50, lambda: win._device_view.update_device_info(win._device_info) or False ) from meshy.protocol import build_app_start GLib.timeout_add(delay + 100, lambda: win.send_frame(build_app_start()) or False) win.show_toast(_("New companion set up")) meshy/src/connection_controller.py000066400000000000000000000743621526212641700177700ustar00rootroot00000000000000# 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 os import time from gi.repository import Adw, Gio, GLib, Gtk from meshy.ble import BleDevice from meshy.constants import SCHEMA_ID from meshy.gtk_utils import TimeoutManager 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.sleep_monitor import create_sleep_monitor from meshy.storage import Storage from meshy.transport_base import ConnectionState 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_channel_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._pending_contacts: list = [] 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 self._suspended: bool = False # Callbacks queued while the initial sync is running; run at the end # of finish_msg_sync(). Used to defer confirm actions (setup/restore) # that must not race with contacts/channels sync. self._post_sync_callbacks: list = [] self._sleep_monitor = create_sleep_monitor() if self._sleep_monitor: self._sleep_monitor.on_will_sleep = self._on_system_will_sleep self._sleep_monitor.on_did_wake = self._on_system_did_wake self._sleep_monitor.start() 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(SCHEMA_ID) 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): builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/scan-dialog-content.ui" ) dialog = builder.get_object("scan_dialog") self._scan_listbox = builder.get_object("scan_listbox") self._scan_dialog = dialog self._win._ble.start_scan() def _on_closed(d): self._win._ble.stop_scan() self._scan_dialog = None dialog.connect("closed", _on_closed) 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("meshy-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() self._win._map_nodes_manager.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._post_sync_callbacks.clear() self.sync_msg_count = 0 self.sync_channel_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(SCHEMA_ID) settings.set_string("last-device-address", "") settings.set_string("last-device-name", "") settings.set_string("last-device-pubkey", "") 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(SCHEMA_ID) 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( SCHEMA_ID ).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 GLib.timeout_add(50, self._send_contacts_with_timeout) GLib.timeout_add(100, self._win._read_default_scope) def open_device_storage(self, pubkey: str): if not pubkey: return pubkey_path = Storage.db_path_for_device(pubkey) if not os.path.exists(pubkey_path): transport = self.pending_transport_type or "ble" if transport == "ble": device_addr = self._win._transport.connected_address if device_addr: old_path = Storage.db_path_for_device(device_addr) if os.path.exists(old_path): os.makedirs(os.path.dirname(pubkey_path), exist_ok=True) os.rename(old_path, pubkey_path) for suffix in ("-wal", "-shm"): src = old_path + suffix if os.path.exists(src): os.rename(src, pubkey_path + suffix) if self._win._storage: self._win._storage.close() self._win._storage = Storage(device_address=pubkey) # Remember the transport address so "delete stored data" can find this # public-key-keyed DB later (it is given the address, not the pubkey). transport = self._win._transport address = getattr(transport, "connected_address", None) if transport else None if isinstance(address, str) and address: self._win._storage.set_setting("device_address", address) self._reload_from_storage() 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() 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 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 _reload_from_storage(self): 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() 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 # Flush any contacts received so far before retry/skip self._flush_pending_contacts() 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 _flush_pending_contacts(self): """Flush accumulated contacts to DB. Called on timeout or error.""" pending = self._pending_contacts self._pending_contacts = [] if pending and self._win._storage: with self._win._storage.batch(): for contact_dict in pending: self._win._frame_handler.add_or_update_contact(contact_dict) log.info("Flushed %d pending contacts to DB on timeout", len(pending)) def _should_auto_reconnect(self) -> bool: if self.manual_disconnect: self.manual_disconnect = False return False if self._suspended: 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(SCHEMA_ID) settings.set_string("last-device-address", "") settings.set_string("last-device-name", "") settings.set_string("last-device-pubkey", "") 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 stop_sleep_monitor(self): if self._sleep_monitor: self._sleep_monitor.stop() def cancel_reconnect(self): self._timeouts.cancel("reconnect") def _on_system_will_sleep(self): self._suspended = True if self._win._transport and self._win._transport.is_connected: log.info("System suspending, disconnecting transport") self._win._transport.disconnect() def _on_system_did_wake(self): self._suspended = False if not self.last_connected_address: return if self._win._transport and self._win._transport.state != ConnectionState.DISCONNECTED: return log.info("System resumed, reconnecting") self.reconnect_attempts = 0 self._schedule_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.sync_channel_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 parts = [] if self.sync_msg_count > 0: parts.append( gettext.dngettext( "meshy", "{} message", "{} messages", self.sync_msg_count, ).format(self.sync_msg_count) ) if self.sync_channel_msg_count > 0: parts.append( gettext.dngettext( "meshy", "{} channel message", "{} channel messages", self.sync_channel_msg_count, ).format(self.sync_channel_msg_count) ) if parts: summary = _(" and ").join(parts) self._win._send_notification( "Meshy", _("You received {} while disconnected.").format(summary), "sync-summary", ) self.sync_msg_count = 0 self.sync_channel_msg_count = 0 settings = Gio.Settings.new(SCHEMA_ID) if settings.get_boolean("auto-backup"): self._win._auto_backup.schedule(self._win) if settings.get_boolean("online-nodes-enabled") and settings.get_boolean( "online-nodes-auto-update" ): self._win._map_nodes_manager.schedule(self._win) self._win.refresh_stats() callbacks, self._post_sync_callbacks = self._post_sync_callbacks, [] for callback in callbacks: callback() def is_syncing(self) -> bool: """True while the initial contacts/channels/messages sync is running.""" return self.syncing or self.msg_syncing def run_after_sync(self, callback): """Run *callback* now, or once the initial sync finishes.""" if not self.is_syncing(): callback() return self._post_sync_callbacks.append(callback) def send_get_contacts(self, incremental: bool = False): self._win._frame_handler.send_get_contacts(incremental=incremental) meshy/src/constants.py000066400000000000000000000013661526212641700153740ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared application-wide constants. Keeping these in one place avoids the hardcoded string/number duplication that had accumulated across the codebase (GSettings schema id, QR scanner timings). """ # GSettings schema id. Previously hardcoded in ~31 places as either # ``Gio.Settings.new("page.codeberg.sesivany.Meshy")`` or # ``Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")``. SCHEMA_ID = "page.codeberg.sesivany.Meshy" # QR scanner timings (milliseconds). Shared by the Linux (GStreamer) and macOS # (AVFoundation) scanners so both platforms behave identically. QR_SCAN_HINT_DELAY_MS = 8000 QR_SCAN_TIMEOUT_MS = 30000 meshy/src/frame_handler.py000066400000000000000000002167021526212641700161510ustar00rootroot00000000000000# 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.constants import SCHEMA_ID from meshy.gtk_utils import TimeoutManager from meshy.models import ( Channel, ChannelMessage, Contact, ContactType, Message, MessageStatus, NotificationLevel, ) from meshy.protocol import ( PUSH_CODE_ADVERT, PUSH_CODE_CONTACT_DELETED, PUSH_CODE_CONTACTS_FULL, 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_CLI_REPLY, 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, _parse_snr, build_get_allowed_repeat_freq, build_get_contact_by_key, build_get_contacts, build_set_device_time, compute_scope_key, 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_cli_reply, 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.time_format import format_trip_time log = logging.getLogger(__name__) # Advert timestamps further in the future than this are treated as bogus # (uncalibrated remote RTC) and clamped to "now" instead of polluting last_seen. _FUTURE_ADVERT_TOLERANCE_S = 300 # The device emits PUSH_CODE_CONTACTS_FULL for every advert it cannot store # while the table is full, so rate-limit the toast. _CONTACTS_FULL_TOAST_INTERVAL_S = 30 class FrameHandler: """Handles incoming protocol frames from the companion device.""" def __init__(self, win): self._win = win self._timeouts = TimeoutManager() self._ok_callbacks = [] self._blocked_senders: set[str] = set() self._last_contacts_full_toast = 0.0 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): for _on_ok, _on_error, timeout_id, _on_sent 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: self._handle_ok(data) return False if code == RESP_CODE_ERR: self._handle_err(data) return False if code == RESP_CODE_SELF_INFO: self._handle_self_info(data) elif code == RESP_CODE_CUSTOM_VARS: self._handle_custom_vars(data) elif code == RESP_CODE_DEVICE_INFO: self._handle_device_info(data) elif code == RESP_CODE_ALLOWED_REPEAT_FREQ: self._handle_allowed_repeat_freq(data) elif code == RESP_CODE_BATT_AND_STORAGE: self._handle_batt_and_storage(data) elif code == RESP_CODE_STATS: self._handle_stats(data) elif code == RESP_CODE_CONTACTS_START: self._handle_contacts_start(data) elif code == RESP_CODE_CONTACT: self._handle_contact(data) elif code == RESP_CODE_END_OF_CONTACTS: self._handle_end_of_contacts(data) elif code in (RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CONTACT_MSG_RECV_V3): self._handle_contact_msg_recv(data) elif code in (RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3): self._handle_channel_msg_recv(data) elif code == RESP_CODE_CHANNEL_DATA_RECV: self._handle_channel_data_recv(data) elif code == RESP_CODE_NO_MORE_MESSAGES: self._handle_no_more_messages(data) elif code == RESP_CODE_SENT: self._handle_sent(data) elif code == PUSH_CODE_SEND_CONFIRMED: self._handle_send_confirmed(data) elif code == PUSH_CODE_MSG_WAITING: self._handle_msg_waiting(data) elif code in (PUSH_CODE_ADVERT, PUSH_CODE_NEW_ADVERT): self._handle_advert(data) elif code == PUSH_CODE_PATH_UPDATED: self._handle_path_updated(data) elif code == RESP_CODE_CHANNEL_INFO: self._handle_channel_info(data) elif code == RESP_CODE_CURR_TIME: self._handle_curr_time(data) elif code == RESP_CODE_AUTO_ADD_CONFIG: self._handle_auto_add_config(data) elif code == RESP_CODE_DEFAULT_FLOOD_SCOPE: self._handle_default_flood_scope(data) elif code == RESP_CODE_EXPORT_CONTACT: self._handle_export_contact(data) elif code == RESP_CODE_PRIVATE_KEY: self._handle_private_key(data) elif code == RESP_CODE_DISABLED: self._handle_disabled(data) elif code == RESP_CODE_CLI_REPLY: self._handle_cli_reply(data) elif code == RESP_CODE_BINARY_RESPONSE: self._handle_binary_response(data) elif code == PUSH_CODE_TELEMETRY_RESPONSE: self._handle_telemetry_response(data) elif code == RESP_CODE_CONTROL_DATA: self._handle_control_data(data) elif code == RESP_CODE_ADVERT_PATH: self._handle_advert_path(data) elif code == PUSH_CODE_PATH_DISCOVERY_RESPONSE: self._handle_path_discovery_response(data) elif code == PUSH_CODE_LOG_DATA: self._handle_log_data(data) elif code == PUSH_CODE_TRACE_DATA: self._handle_trace_data(data) elif code == PUSH_CODE_LOGIN_SUCCESS: self._handle_login_success(data) elif code == PUSH_CODE_STATUS_RESPONSE: self._handle_status_response(data) elif code == PUSH_CODE_LOGIN_FAILED: self._handle_login_failed(data) elif code == PUSH_CODE_CONTACT_DELETED: self._handle_contact_deleted(data) elif code == PUSH_CODE_CONTACTS_FULL: self._handle_contacts_full(data) return False # Don't repeat idle callback def _handle_ok(self, data: bytes): if self._ok_callbacks: on_ok, _on_error, timeout_id, _on_sent = self._ok_callbacks.pop(0) GLib.source_remove(timeout_id) if on_ok is not None: on_ok() return def _handle_custom_vars(self, data: bytes): 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") def _handle_device_info(self, data: bytes): 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) self._win._settings_view.update_auto_add_capability(self._win._device_info) if info.get("fw_ver", 0) >= 9: self._win.send_frame(build_get_allowed_repeat_freq()) def _handle_allowed_repeat_freq(self, data: bytes): ranges = parse_allowed_repeat_freq(data) self._win._device_info.allowed_repeat_freqs = ranges log.info("Allowed repeat freq ranges: %s", ranges) def _handle_batt_and_storage(self, data: bytes): 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() def _handle_stats(self, data: bytes): 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) def _handle_contacts_start(self, data: bytes): self._win._conn_ctrl.contacts_iter_busy = True self._win._conn_ctrl._pending_contacts = [] 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}" ) def _handle_contact(self, data: bytes): contact = parse_contact_frame(data) if not contact: 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: # Accumulate in memory; DB write deferred to _handle_end_of_contacts self._win._conn_ctrl._pending_contacts.append(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 contact: # Single contact update outside of bulk sync — save immediately self.add_or_update_contact(contact) def _handle_contact_msg_recv(self, data: bytes): self._win._msg_ctrl.last_rx_time = time.monotonic() msg_data = parse_contact_message(data) if msg_data: self.handle_incoming_message(msg_data) # Always continue the sync loop — a parse failure or handler # exception must not stall the entire message sync. self._win._conn_ctrl.msg_sync_retries = 0 self._win._send_sync_next_message() def _handle_channel_msg_recv(self, data: bytes): 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() def _handle_channel_data_recv(self, data: bytes): 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']}" ) def _handle_no_more_messages(self, data: bytes): self._win._finish_msg_sync() def _handle_msg_waiting(self, data: bytes): if self._win._conn_ctrl.syncing: # Initial sync (contacts, then channels) is still running and pulls # the queue itself once the channels are loaded. Pulling now would # deliver channel messages before their channel is known, and # handle_incoming_channel_message() drops those -- after they have # already left the device's queue. log.debug("Message waiting during initial sync; fetched after channel sync") return self._win._conn_ctrl.msg_sync_retries = 0 self._win._send_sync_next_message() def _handle_advert(self, data: bytes): 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) if self._win._advert_listener: self._win._advert_listener(contact) 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) self.add_or_update_discovered(contact) def _handle_path_updated(self, data: bytes): if len(data) >= 33: pub_key_hex = data[1:33].hex() self.refresh_contact(pub_key_hex) else: self.schedule_contacts_refetch() def _handle_auto_add_config(self, data: bytes): config = parse_auto_add_config(data) if config: self._win._settings_view.update_auto_add_config(config) def _handle_default_flood_scope(self, data: bytes): result = parse_default_flood_scope(data) if result is not None: scope_name = result.get("scope_name", "") log.info(f"Default flood scope: {scope_name if scope_name else '(none)'}") key = result.get("scope_key", "") if scope_name and key and compute_scope_key(scope_name).hex() != key: # Set by another app with a key Meshy would not derive from the # name (e.g. a channel-name or raw-key scope). Adopting it would # let Meshy write it back with the wrong key, so leave it alone. log.warning( f"Default flood scope {scope_name!r} has a non-hashtag key; not adopted" ) return # The device's persistent value wins over the local copy. self._win._settings_view.adopt_default_scope(scope_name) def _handle_export_contact(self, data: bytes): 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 self._win.copy_to_clipboard("meshcore://" + data[1:].hex()) def _handle_private_key(self, data: bytes): callback = self._win._private_key_callback self._win._private_key_callback = None if callback and len(data) >= 65: callback(data[1:65]) def _handle_disabled(self, data: bytes): callback = self._win._private_key_callback self._win._private_key_callback = None if callback: callback(None) def _handle_cli_reply(self, data: bytes): callback = self._win._local_cli_callback self._win._local_cli_callback = None if callback: callback(parse_cli_reply(data) or "") def _handle_telemetry_response(self, data: bytes): 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) def _handle_control_data(self, data: bytes): result = parse_discover_response(data) if result and self._win._discover_callback: self._win._discover_callback(result) def _handle_advert_path(self, data: bytes): 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) def _handle_path_discovery_response(self, data: bytes): if self._win._path_discovery_callback: self._win._path_discovery_callback(data) def _handle_log_data(self, data: bytes): self.handle_log_data(data) def _handle_trace_data(self, data: bytes): result = parse_trace_response(data) if result and self._win._trace_callback: self._win._trace_callback(result) def _handle_status_response(self, data: bytes): 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") def _handle_err(self, data: bytes): err = parse_error_response(data) if self._ok_callbacks: _on_ok, on_error, timeout_id, on_sent = self._ok_callbacks.pop(0) GLib.source_remove(timeout_id) on_error(err) if on_sent is None: return # A SENT-or-ERROR entry (e.g. an anonymous request) only claims the # slot; the error itself still needs the global handling below. 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 if err: log.warning(f'Device error: {err["message"]} (code=0x{err["code"]:02x})') if err["code"] == 0x03: if self._win._region_callbacks or self._win._owner_callbacks: self._win._table_full_error = True for cb in list(self._win._region_callbacks.values()): cb(None) self._win._region_callbacks.clear() for cb in list(self._win._owner_callbacks.values()): cb(None) self._win._owner_callbacks.clear() else: self._win._show_contacts_full_dialog() elif not self._win._region_callbacks and not self._win._owner_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) def _handle_self_info(self, data: bytes): 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 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._settings_view.update_auto_add_from_self_info( info.get("manual_add_contacts", False) ) self._win._update_companion_label() self._win._save_tcp_companion_if_needed() self._win._conn_ctrl.open_device_storage(info.get("public_key", "")) # Offer first-time setup as soon as SELF_INFO is known, without # waiting for the contacts/channels/messages sync to finish. The # confirm action itself is deferred via run_after_sync(). from meshy.companion_setup import maybe_offer maybe_offer(self._win) def _handle_end_of_contacts(self, data: bytes): self._win._conn_ctrl.contacts_iter_busy = False self._win._conn_ctrl._cancel_contacts_sync_timeout() # Flush all accumulated contacts to DB in a single batch pending = self._win._conn_ctrl._pending_contacts self._win._conn_ctrl._pending_contacts = [] if pending: with self._win._storage.batch(): for contact_dict in pending: self.add_or_update_contact(contact_dict) # 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() 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) def _handle_sent(self, data: bytes): 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 entry["type"] == "telemetry_chat": pass # absorb SENT; response comes via PUSH_CODE_TELEMETRY_RESPONSE elif "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: 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:"): self._resolve_sent_pending() callback_key = entry[7:] 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 isinstance(entry, str) and entry.startswith("owner:"): self._resolve_sent_pending() callback_key = entry[6:] if "expected_ack" in result and callback_key in self._win._owner_callbacks: ack_hex = result["expected_ack"].hex() self._win._owner_callbacks[ack_hex] = self._win._owner_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: 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: timeout_ms = result.get("suggested_timeout", 15000) if self._win._trace_timeout_callback: self._win._trace_timeout_callback(timeout_ms) def _resolve_sent_pending(self): """Resolve the pending MSG_SENT-or-ERROR entry for a sent command.""" if self._ok_callbacks and self._ok_callbacks[0][3] is not None: _on_ok, _on_error, timeout_id, on_sent = self._ok_callbacks.pop(0) GLib.source_remove(timeout_id) on_sent() def _handle_send_confirmed(self, data: bytes): 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) 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) 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) self.schedule_contacts_refetch() elif ack_key in self._win._msg_ctrl.grace_acks: msg_id = self._win._msg_ctrl.grace_acks.pop(ack_key) 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) 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) def _handle_channel_info(self, data: bytes): 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 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._win._channels_view._unread_counts.pop(channel.index, None) self._win._storage.zero_channel_unread(channel.index) self.refresh_channels() self._win.update_nav_unread_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) def _handle_curr_time(self, data: bytes): 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)) def _handle_binary_response(self, data: bytes): 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 owner_cb = self._win._owner_callbacks.pop(tag_hex, None) if owner_cb: owner_text = data[10:].decode("utf-8", "ignore").strip("\x00") owner_cb(owner_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) def _handle_login_success(self, data: bytes): log.info(f"Received LOGIN_SUCCESS response, len={len(data)}, hex={data.hex()}") if len(data) < 8: log.warning(f"LOGIN_SUCCESS too short: {len(data)} bytes, expected >= 8") return 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] 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") def _handle_login_failed(self, data: bytes): log.info(f"Received LOGIN_FAILED response, len={len(data)}, hex={data.hex()}") if len(data) < 8: log.warning(f"LOGIN_FAILED too short: {len(data)} bytes, expected >= 8") return 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") def _handle_contact_deleted(self, data: bytes): 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() def _handle_contacts_full(self, data: bytes): """The device could not store a newly heard contact. Sent while the contact table is full and the firmware cannot make room (no "overwrite oldest", or every contact is a favourite). """ now = time.monotonic() if now - self._last_contacts_full_toast < _CONTACTS_FULL_TOAST_INTERVAL_S: return self._last_contacts_full_toast = now log.info("Firmware reported a full contact table") self._win.show_toast(_("Contact list is full — new contacts are not being added")) 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, None)) def expect_sent_or_error(self, on_sent, on_error, timeout_ms=5000): """Register a callback for a command answered by MSG_SENT or ERROR. Some commands do not reply with OK on success: an anonymous request is acknowledged with RESP_CODE_SENT but can still fail with ERR. Claiming a slot in the OK/ERROR queue here keeps it aligned, so that ERR is not charged to the next ``expect_ok`` entry, and a MSG_SENT resolves it instead (see ``_resolve_sent_pending``). """ timeout_id = GLib.timeout_add(timeout_ms, self._on_expect_timeout) self._ok_callbacks.append((None, on_error, timeout_id, on_sent)) def _on_expect_timeout(self): if self._ok_callbacks: _on_ok, on_error, _timeout_id, _on_sent = 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=self._sanitize_advert_ts(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 _sanitize_advert_ts(self, ts: int) -> datetime: """Clamp a remote advert timestamp to now when it is far in the future.""" now = int(time.time()) if ts > now + _FUTURE_ADVERT_TOLERANCE_S: return datetime.fromtimestamp(now) return datetime.fromtimestamp(ts) 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 contact.unread_count = existing.unread_count contact.custom_name = existing.custom_name if existing.last_message_at and not contact.last_message_at: contact.last_message_at = existing.last_message_at if ( abs(contact.latitude or 0) < 1e-6 and abs(contact.longitude or 0) < 1e-6 and existing.latitude is not None ): contact.latitude = existing.latitude contact.longitude = existing.longitude existing.__dict__.update(contact.__dict__) contact = existing else: self._win._contacts.append(contact) self._win._contacts_by_key[key] = contact pending_custom = getattr(self._win, "_pending_custom_names", None) if isinstance(pending_custom, dict): custom = pending_custom.pop(key, None) if custom: contact.custom_name = custom 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, device_lastmod=contact.device_lastmod.timestamp() if contact.device_lastmod else None, ) 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.notification_level = c.notification_level channel.region = c.region channel.retention_days = c.retention_days channel.unread_count = c.unread_count 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 # Region restored from a backup, applied once the device channel arrives pending_region = self._win._pending_channel_regions.pop(channel.index, None) if pending_region is not None: channel.region = pending_region 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. In collapsed mode the content panel may be hidden (sidebar showing) even though a chat is loaded — the chat is not actually visible to the user in that case. """ if not self._win._chat_view_inst: return False sv = self._win._split_view if sv.get_collapsed() and not sv.get_show_content(): 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. In collapsed mode the content panel may be hidden (sidebar showing) even though a channel chat is loaded — the chat is not actually visible to the user in that case. """ if not self._win._channels_view_inst: return False sv = self._win._split_view if sv.get_collapsed() and not sv.get_show_content(): 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 = msg_data["sender_timestamp"] 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.display_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.display_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.display_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._storage.increment_contact_unread(contact.public_key_hex) 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.display_name, notif_body, f"contact-{contact.public_key_hex}", repliable=True, ) 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", "")) sender_ts = msg_data["sender_timestamp"] # 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) ] matched = self._win._channels_view.on_repeat_received( channel_idx, sender_ts, msg_text, path_hops, snr, ) if matched is not None: return if self._win._storage.has_recent_outgoing_channel_message( channel_idx, msg_text, sender_ts ): 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), ) # 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: log.debug( "In-memory dedup: ch=%d sender=%s ts=%s text=%r", channel_idx, sender_name, sender_ts, msg_text[:30], ) return # Don't save or notify for duplicate copies if self._win._storage.has_duplicate_channel_message( channel_idx, sender_ts, msg_text, sender_name ): log.debug( "DB dedup: ch=%d sender=%s ts=%s text=%r", channel_idx, sender_name, sender_ts, msg_text[:30], ) return 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._storage.increment_channel_unread(channel_msg.channel_index) self._win._channels_view.mark_unread(channel_msg.channel_index, True) if self._win._conn_ctrl.msg_syncing: self._win._conn_ctrl.sync_channel_msg_count += 1 elif channel: # Send notification based on channel settings level = channel.notification_level if level == NotificationLevel.DEFAULT: settings = Gio.Settings(schema_id=SCHEMA_ID) 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: 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}", repliable=True, ) def restore_unread_counts(self): """Restore unread message counts from DB counters on startup.""" if not self._win._storage: return for contact in self._win._contacts: if contact.type in (ContactType.REPEATER, ContactType.SENSOR): continue if contact.unread_count > 0: self._win._contacts_view._unread_counts[contact.public_key_hex] = ( contact.unread_count ) 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.update_contacts(self._win._contacts) for channel in self._win._channels: if channel.is_empty: continue if channel.unread_count > 0: self._win._channels_view._unread_counts[channel.index] = channel.unread_count 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.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, resolve_region_for_log_packet, ) from meshy.models import RxLogEntry if len(data) < 4: return snr = _parse_snr(data[1:2]) 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", "") timestamp = decrypted.get("timestamp", 0) 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 # The LOG_DATA frame carries the SNR at which this packet reached us. region = "" if result.get("transport_from"): region = resolve_region_for_log_packet(result, self._win.known_region_names()) path_info = { "snr": snr, "path_len": len(path_hops), "path_bytes": path_bytes_hex, "region": region, } 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, snr, ) 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/geo.py000066400000000000000000000065771526212641700141430ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Geographic distance math and locale-aware distance formatting (no GTK).""" import gettext import math import os from meshy.i18n import format_number 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)) # Territories that use imperial/US customary units for distance. GB measures # road distances in miles even though most other quantities are metric. _IMPERIAL_TERRITORIES = {"US", "LR", "MM", "GB"} _KM_PER_MILE = 1.609344 _FEET_PER_KM = 3280.84 def _territory_from_locale(value: str | None) -> str | None: """Extract the upper-case territory code from a locale like ``en_US.UTF-8``.""" if not value: return None value = value.strip() if not value or value in ("C", "POSIX"): return None value = value.split(".")[0].split("@")[0] for separator in ("_", "-"): if separator in value: parts = value.split(separator) if len(parts) >= 2 and len(parts[1]) == 2: return parts[1].upper() return None return None def uses_metric_units() -> bool: """Whether the current locale uses metric distance units. Follows ``LC_MEASUREMENT`` (falling back to ``LC_ALL``/``LANG``) and defaults to metric when no territory can be determined. """ for var in ("LC_MEASUREMENT", "LC_ALL", "LANG"): territory = _territory_from_locale(os.environ.get(var)) if territory: return territory not in _IMPERIAL_TERRITORIES return True def format_distance(km: float) -> str: """Format a distance in kilometres using the locale's measurement units. Below one kilometre (or one mile) the value is shown in metres (or feet), otherwise in kilometres (or miles) with one decimal place. """ if uses_metric_units(): if km < 1.0: return gettext.dgettext("meshy", "{} m").format(format_number(km * 1000.0, 0)) return gettext.dgettext("meshy", "{} km").format(format_number(km, 1)) miles = km / _KM_PER_MILE if miles < 1.0: return gettext.dgettext("meshy", "{} ft").format(format_number(km * _FEET_PER_KM, 0)) return gettext.dgettext("meshy", "{} mi").format(format_number(miles, 1)) def route_distance(points) -> tuple[float, bool]: """Sum the great-circle distance over a route in kilometres. *points* is a sequence of ``(lat, lon)`` pairs; a ``None`` coordinate marks an unknown position. Unknown points are skipped and the surrounding known points are connected directly, making the result a lower bound of the real path length. Returns ``(distance_km, partial)`` where *partial* is True when at least one point was unknown. """ points = list(points) known = [(p[0], p[1]) for p in points if p[0] is not None and p[1] is not None] partial = len(known) < len(points) total = sum( haversine_distance(a[0], a[1], b[0], b[1]) for a, b in zip(known, known[1:], strict=False) ) return total, partial meshy/src/gtk_utils.py000066400000000000000000000030211526212641700153530ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """GTK/GLib-dependent helpers (widget and timer utilities, Pango markup).""" import re from gi.repository import GLib _URL_RE = re.compile(r'(https?://[^\s<>"\')\]]+)') def linkify(text: str) -> str: """Escape *text* and turn URLs into clickable ```` Pango markup.""" 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) 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) meshy/src/host_integration.py000066400000000000000000000013271526212641700167350ustar00rootroot00000000000000# 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/i18n.py000066400000000000000000000017001526212641700141270ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """i18n and locale-aware number formatting helpers (no GTK dependency).""" import gettext import locale def ngettext(singular, plural, n): return gettext.dngettext("meshy", singular, plural, n) 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 format_localized(template: str, value: float) -> str: """Fill a translated ``%``-format template with a locale-aware number. Unlike ``template % value``, this honours the current locale's decimal separator (e.g. a comma in Czech for ``"Score: %.2f"``). """ return locale.format_string(template, value) def parse_number(text: str) -> float: try: return locale.atof(text) except ValueError: return float(text) meshy/src/location.py000066400000000000000000000025661526212641700151730ustar00rootroot00000000000000# 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.py000066400000000000000000000005421526212641700142770ustar00rootroot00000000000000# 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/map_nodes.py000066400000000000000000000153421526212641700153240ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import gettext import logging import re import threading from datetime import datetime from urllib.request import Request, urlopen from gi.repository import Gio, GLib from meshy.constants import SCHEMA_ID log = logging.getLogger(__name__) def _(msg): return gettext.dgettext("meshy", msg) _API_URL = "https://map.meshcore.io/api/v1/nodes?binary=1&short=1" _UA = "Meshy/1.0 (https://codeberg.org/sesivany/meshy)" _DAY_MS = 24 * 60 * 60 * 1000 _MAX_NAME_LEN = 64 _MAX_COORD = 200 _CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]") def _matches_preset(params, device_info): if not isinstance(params, dict): return False try: api_freq_hz = int(round(params.get("freq", 0) * 1_000_000)) api_bw_hz = int(round(params.get("bw", 0) * 1_000)) dev_freq_hz = int(round(device_info.radio_freq * 1_000_000)) dev_bw_hz = int(round(device_info.radio_bw * 1_000)) return ( api_freq_hz == dev_freq_hz and api_bw_hz == dev_bw_hz and params.get("sf", 0) == device_info.radio_sf and params.get("cr", 0) == device_info.radio_cr ) except (TypeError, ValueError): return False def _ts_seconds(value): """Return unix seconds from a msgpack Timestamp or numeric value, or None.""" if value is None or isinstance(value, bool): return None if isinstance(value, int | float): return int(value) seconds = getattr(value, "seconds", None) return seconds if type(seconds) is int else None def _sanitize_name(name): """Validate a node name: must be a string without control characters.""" if not isinstance(name, str): return None return _CONTROL_CHAR_RE.sub("", name)[:_MAX_NAME_LEN] def _parse_node(node, device_info, cutoff, Contact, ContactType): """Parse a single node dict into a Contact, or None when invalid. Each field is strictly type-checked because the data comes from an untrusted remote database. A malformed record returns None instead of raising, so one bad entry can't abort the whole import. """ if not isinstance(node, dict): return None if not _matches_preset(node.get("p"), device_info): return None updated = _ts_seconds(node.get("ud")) if updated is None or updated < cutoff: return None pub_key = node.get("pk") if not isinstance(pub_key, bytes | bytearray) or len(pub_key) != 32: return None name = _sanitize_name(node.get("n", "")) if name is None: return None node_type = node.get("t") if node_type is not None and (isinstance(node_type, bool) or not isinstance(node_type, int)): return None if node_type not in ( ContactType.CHAT, ContactType.REPEATER, ContactType.ROOM, ContactType.SENSOR, ): node_type = ContactType.CHAT latitude = node.get("lat") longitude = node.get("lon") for coord in (latitude, longitude): if coord is None: continue if type(coord) not in (int, float) or not (-_MAX_COORD <= coord <= _MAX_COORD): return None last_seen = None last_advert = _ts_seconds(node.get("la")) if last_advert is not None: try: last_seen = datetime.fromtimestamp(last_advert) except (OverflowError, OSError, ValueError): last_seen = None return Contact( public_key=pub_key if isinstance(pub_key, bytes) else bytes(pub_key), name=name, type=node_type, latitude=latitude, longitude=longitude, last_seen=last_seen, received_at=datetime.now(), ) def _parse_nodes(data, device_info): from meshy.models import Contact, ContactType if not isinstance(data, list): return [] cutoff = datetime.now().timestamp() - 90 * 24 * 60 * 60 contacts = [] for node in data: try: contact = _parse_node(node, device_info, cutoff, Contact, ContactType) except Exception: log.debug("Skipping malformed node from online database", exc_info=True) continue if contact is not None: contacts.append(contact) return contacts def fetch_map_nodes(device_info, on_success, on_error): def _worker(): try: import msgpack req = Request(_API_URL, headers={"User-Agent": _UA}) with urlopen(req, timeout=60) as resp: raw = resp.read() nodes = msgpack.unpackb(raw, raw=False) contacts = _parse_nodes(nodes, device_info) GLib.idle_add(on_success, contacts) except Exception as e: log.error("Failed to fetch map nodes: %s", e) GLib.idle_add(on_error, str(e)) threading.Thread(target=_worker, daemon=True).start() def is_metered(): try: monitor = Gio.NetworkMonitor.get_default() return monitor.get_network_metered() except Exception: return False class MapNodesManager: def __init__(self): self._timer_id = None def run(self, win, force=False): if not win.is_connected or not win.storage: return if is_metered(): log.info("Skipping online node update on metered connection") return if not force: settings = Gio.Settings.new(SCHEMA_ID) last = settings.get_int64("online-nodes-last-update") import time if time.time() - last < 24 * 60 * 60: log.info("Skipping online node update, last update less than 24h ago") return def _on_success(contacts): if not win.is_connected or not win.storage: return win.storage.save_map_contacts(contacts) win.reload_discovered() import time settings = Gio.Settings.new(SCHEMA_ID) settings.set_int64("online-nodes-last-update", int(time.time())) count = win.storage.count_map_contacts() log.info("Imported %d nodes from online database", count) win.show_toast(_("{count} nodes imported from online database").format(count=count)) def _on_error(error): log.error("Online node import failed: %s", error) fetch_map_nodes(win.device_info, _on_success, _on_error) def schedule(self, win, force=False): self.cancel() self.run(win, force=force) def _daily_update(): self.run(win) return True self._timer_id = GLib.timeout_add(_DAY_MS, _daily_update) def cancel(self): if self._timer_id is not None: GLib.source_remove(self._timer_id) self._timer_id = None meshy/src/mesh_crypto.py000066400000000000000000000211241526212641700157060ustar00rootroot00000000000000# 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 from meshy.protocol import parse_advert_appdata 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 derive_scope_key(name: str) -> bytes | None: """Derive the 16-byte scope key for a public region name. Mirrors firmware ``TransportKeyStore::getAutoKeyFor`` (SHA-256 of the name) with the ``RegionMap`` rule that prepends ``#`` when missing. Returns None for empty / whitespace names and ``$``-prefixed (private) regions, whose keys are not derivable from the name. """ name = (name or "").strip() if not name or name.startswith("$"): return None if name in ("0", "None", "*"): return None if not name.startswith("#"): name = "#" + name return hashlib.sha256(name.encode("utf-8")).digest()[:16] def calc_transport_code(scope_key: bytes, payload_type: int, payload: bytes) -> int: """Compute the 2-byte transport code for a scope key and packet payload. Mirrors firmware ``TransportKey::calcTransportCode``: HMAC-SHA256 over ``[payload_type] || payload`` keyed with the 16-byte scope key; the first two MAC bytes are read little-endian, and the reserved values 0x0000 / 0xFFFF are rewritten to 0x0001 / 0xFFFE. """ msg = bytes([payload_type & 0x0F]) + payload mac = hmac.new(scope_key, msg, hashlib.sha256).digest() code = mac[0] | (mac[1] << 8) if code == 0: return 1 if code == 0xFFFF: return 0xFFFE return code def resolve_region_name( transport_code: int, payload_type: int, payload: bytes, region_names: list[str] ) -> str: """Reverse-resolve a packet's transport code to a region name. Returns the matching name, or names joined with ``" / "`` when several known regions collide, or an empty string when nothing matches. """ matches = [] for name in region_names: key = derive_scope_key(name) if key is None: continue if calc_transport_code(key, payload_type, payload) == transport_code: matches.append(name.strip()) unique = sorted(set(matches)) if not unique: return "" return " / ".join(unique) def resolve_region_for_log_packet(result: dict, region_names: list[str]) -> str: """Resolve the flood region name for a packet decoded by ``decode_log_packet``. Returns an empty string when the packet carries no transport code, the code is malformed, or no known region matches. """ transport_hex = result.get("transport_from") or "" payload = result.get("payload") if not transport_hex or payload is None: return "" try: transport_code = int.from_bytes(bytes.fromhex(transport_hex), "little") except ValueError: return "" return resolve_region_name(transport_code, result.get("payload_type", 0), payload, region_names) 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, "payload": payload, } 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] + [feat1(2) if flag 0x20] + [feat2(2) if flag 0x40] + [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(" 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 RUN_CLI_COMMAND = 66 # v14+ local CLI on the connected companion # 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 CLI_REPLY = 29 # v14+ reply to RUN_CLI_COMMAND # 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 CONTACTS_FULL = 0x90 meshy/src/meshy.in000066400000000000000000000026361526212641700144640ustar00rootroot00000000000000#!@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.build000066400000000000000000000057341526212641700151530ustar00rootroot00000000000000# 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', 'constants.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', 'i18n.py', 'geo.py', 'time_format.py', 'text_utils.py', 'shared_uri.py', 'gtk_utils.py', 'backup.py', 'companion_setup.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', 'cli_completion.py', 'map_nodes.py', 'chat_entry.py', 'sleep_monitor.py', ] if get_option('qr_scanner') meshy_sources += ['qr_scanner.py', 'qr_decode.py'] endif views_sources = [ 'views/__init__.py', 'views/autocomplete.py', 'views/dialogs.py', 'views/map_tiles.py', 'views/pickers.py', 'views/chat_helpers.py', 'views/chat_mixin.py', 'views/chat_view.py', 'views/label_with_widgets.py', 'views/contact_detail_dialog.py', 'views/cli_dialog.py', 'views/cli_console.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/sleep_monitor_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/linux/sleep_monitor_logind.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', 'platforms/macos/sleep_monitor_nsworkspace.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.py000066400000000000000000000474551526212641700172600ustar00rootroot00000000000000# 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.time_format 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, scope_override: str | None = None ): if scope_override == "unscoped": self._win.send_frame(build_set_flood_scope(unscoped=True)) self._win.send_frame(frame) GLib.timeout_add(300, self._win._reset_session_scope) return 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, scope_override: str | None = None ): timestamp = int(datetime.now().timestamp()) self._send_channel_frame( channel_index, build_send_channel_text_msg(channel_index, text, timestamp=timestamp), scope_override=scope_override, ) 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.py000066400000000000000000000306051526212641700146410ustar00rootroot00000000000000# 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 parse_firmware_version(ver: str) -> tuple[int, int, int]: """Parse a firmware version string such as 'v1.14.0' into a tuple.""" text = (ver or "").strip().lstrip("vV").split("-")[0] try: parts = [int(x) for x in text.split(".")] except ValueError: return (0, 0, 0) while len(parts) < 3: parts.append(0) return (parts[0], parts[1], parts[2]) 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 "meshy-battery-level-100-symbolic" if percent >= 80: return "meshy-battery-level-80-symbolic" if percent >= 70: return "meshy-battery-level-70-symbolic" if percent >= 60: return "meshy-battery-level-60-symbolic" if percent >= 40: return "meshy-battery-level-50-symbolic" if percent >= 30: return "meshy-battery-level-30-symbolic" if percent >= 20: return "meshy-battery-level-20-symbolic" return "meshy-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 = ( "meshy-strength-bars-1-symbolic", "meshy-strength-bars-2-symbolic", "meshy-strength-bars-3-symbolic", "meshy-strength-bars-4-symbolic", "meshy-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 CONTACT_FLAG_REMOTE_CLI = 0x10 # allow this contact to run remote CLI (fw >= 1.18) @dataclass class Contact: _global_smart_default: ClassVar[bool] = False public_key: bytes name: str custom_name: str = "" # user-set alias; falls back to name when empty 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 unread_count: int = 0 @property def public_key_hex(self) -> str: return self.public_key.hex() @property def display_name(self) -> str: return self.custom_name or self.name @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 is_remote_cli_allowed(self) -> bool: return bool(self.flags & CONTACT_FLAG_REMOTE_CLI) @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 # Canonical: lowercase name. Also accept pre-normalization legacy hash. expected = hashlib.sha256(self.name.lower().encode()).digest()[:16] if self.psk == expected: return True if self.name != self.name.lower(): legacy = hashlib.sha256(self.name.encode()).digest()[:16] return self.psk == legacy return False @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 spreading_factor(self) -> int: """Radio spreading factor, or 12 (the SNR-threshold default) if unknown.""" return self.radio_sf or 12 @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) @property def supports_auto_add_config(self) -> bool: """Granular auto-add config (CMD 58/59) was added in firmware v1.12.0. FIRMWARE_VER_CODE stayed 8 from v1.10.0 through v1.12.0, so the version string disambiguates that window; ``fw_ver >= 9`` (v1.13+) is authoritative and also covers builds whose version string does not parse (e.g. nightlies). """ return self.fw_ver >= 9 or parse_firmware_version(self.ver) >= (1, 12, 0) @property def supports_auto_add_max_hops(self) -> bool: """Auto-add hop limit was added in firmware v1.14.0.""" return self.fw_ver >= 10 or parse_firmware_version(self.ver) >= (1, 14, 0) @property def supports_cli(self) -> bool: """Remote/local CLI was added in firmware v1.18. ``TXT_TYPE_CLI_COMMAND`` (remote) and ``CMD_RUN_CLI_COMMAND`` (local) both require FIRMWARE_VER_CODE 14. That code is the authoritative marker; the version string is a fallback for firmwares without it. """ return self.fw_ver >= 14 or parse_firmware_version(self.ver) >= (1, 18, 0) @property def supports_default_flood_scope(self) -> bool: """The persisted default flood scope (CMD 63/64) was added in v1.15.0.""" return self.fw_ver >= 11 or parse_firmware_version(self.ver) >= (1, 15, 0) 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.py000066400000000000000000000020231526212641700160350ustar00rootroot00000000000000# 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 via launcher IPC proxy """ 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, repliable=False): pass def is_supported(self): return False __all__ = ["NotificationService"] meshy/src/path_history.py000066400000000000000000000114011526212641700160640ustar00rootroot00000000000000# 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, device_lastmod: float | None = None, ): existing = [ r for r in self._storage.get_path_history(contact_key_hex) if r["path_bytes"] == path_bytes ] if existing: return if device_lastmod is not None: prev = self._storage.get_last_seed_lastmod(contact_key_hex) if prev is not None and prev == device_lastmod: return self._storage.upsert_path_record( contact_key_hex, path_bytes, path_hash_size, hop_count, success=True, initial_weight=self.FIRMWARE_DISCOVERY_WEIGHT, ) if device_lastmod is not None: self._storage.update_last_seed_lastmod(contact_key_hex, device_lastmod) 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/000077500000000000000000000000001526212641700150075ustar00rootroot00000000000000meshy/src/platforms/__init__.py000066400000000000000000000012001526212641700171110ustar00rootroot00000000000000# 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.py000066400000000000000000000065121526212641700171210ustar00rootroot00000000000000# 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/000077500000000000000000000000001526212641700161465ustar00rootroot00000000000000meshy/src/platforms/linux/__init__.py000066400000000000000000000002431526212641700202560ustar00rootroot00000000000000# 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.py000066400000000000000000001422231526212641700204670ustar00rootroot00000000000000# 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 _dedup_duplicate_rx(self, data: bytes, now: float) -> bool: """Return True if `data` is a BlueZ re-delivery of a recent notification. The dedup history is reset on every write (see `_do_write`), so two legitimate responses with identical bytes (e.g. consecutive 1-byte OK frames) are both delivered. """ data_hash = hash(data) for ts, dh in self._recent_rx: if dh == data_hash and now - ts < 0.5: return True self._recent_rx.append((now, data_hash)) return False 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: if self._dedup_duplicate_rx(data, time.monotonic()): return # duplicate within 500ms, skip 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 # A fresh response may legitimately be byte-identical to the previous # one (e.g. consecutive 1-byte OK frames). Only dedup notifications # received since this write, so those are not mistaken for BlueZ # re-delivering the previous notification. self._recent_rx.clear() 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.py000066400000000000000000000025471526212641700220430ustar00rootroot00000000000000# 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.py000066400000000000000000000044421526212641700220500ustar00rootroot00000000000000# 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, repliable: bool = False ) -> None: """Send a desktop notification using Gio.Notification. Args: title: Notification title body: Notification body text notification_id: Unique identifier for this notification repliable: Ignored on Linux (inline reply not supported) """ 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 def withdraw_notification(self, notification_id: str) -> None: """Withdraw a notification previously sent with the same ID. Under Flatpak ``Gio.Application.withdraw_notification`` maps to the portal's ``org.freedesktop.portal.Notification.RemoveNotification``. Args: notification_id: Unique identifier used when sending """ try: self._app.withdraw_notification(notification_id) log.debug(f"Withdrew notification: {notification_id}") except Exception as e: log.debug(f"Failed to withdraw notification {notification_id}: {e}") meshy/src/platforms/linux/sleep_monitor_logind.py000066400000000000000000000042731526212641700227410ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Sleep/wake monitor using logind PrepareForSleep signal (Linux). Works with both systemd-logind and elogind (standalone fork used on non-systemd distributions like Void, Artix, or Alpine). """ import logging from gi.repository import Gio, GLib from meshy.platforms.sleep_monitor_base import SleepMonitorBase log = logging.getLogger(__name__) LOGIN1_BUS_NAME = "org.freedesktop.login1" LOGIN1_MANAGER_IFACE = "org.freedesktop.login1.Manager" LOGIN1_OBJECT_PATH = "/org/freedesktop/login1" class SleepMonitorLogind(SleepMonitorBase): """Monitor sleep/wake via org.freedesktop.login1.Manager.PrepareForSleep.""" def __init__(self): self._bus: Gio.DBusConnection | None = None self._signal_id: int | None = None self.on_will_sleep = None self.on_did_wake = None def start(self) -> None: try: self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) except GLib.Error as e: log.warning("Cannot connect to system bus for sleep monitoring: %s", e.message) return self._signal_id = self._bus.signal_subscribe( LOGIN1_BUS_NAME, LOGIN1_MANAGER_IFACE, "PrepareForSleep", LOGIN1_OBJECT_PATH, None, Gio.DBusSignalFlags.NONE, self._on_prepare_sleep, ) log.info("Sleep monitor: listening for PrepareForSleep on logind") def _on_prepare_sleep(self, connection, sender, path, iface, signal, params): going_to_sleep = params.get_child_value(0).get_boolean() if going_to_sleep: log.info("System going to sleep") if self.on_will_sleep: GLib.idle_add(lambda: self.on_will_sleep() or False) else: log.info("System woke up") if self.on_did_wake: GLib.idle_add(lambda: self.on_did_wake() or False) def stop(self) -> None: if self._signal_id is not None and self._bus is not None: self._bus.signal_unsubscribe(self._signal_id) self._signal_id = None self._bus = None meshy/src/platforms/macos/000077500000000000000000000000001526212641700161115ustar00rootroot00000000000000meshy/src/platforms/macos/__init__.py000066400000000000000000000002431526212641700202210ustar00rootroot00000000000000# 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.py000066400000000000000000000043461526212641700214730ustar00rootroot00000000000000# 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, NSWorkspace, ) app = NSApplication.sharedApplication() app.setActivationPolicy_(NSApplicationActivationPolicyRegular) import os pkgdata = os.environ.get("MESHY_PKGDATADIR", "") if pkgdata: bundle_path = os.path.normpath(os.path.join(pkgdata, "..", "..", "..", "..")) if os.path.isdir(bundle_path) and bundle_path.endswith(".app"): icon = NSWorkspace.sharedWorkspace().iconForFile_(bundle_path) if icon: app.setApplicationIconImage_(icon) 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 def raise_macos_app(): """Bring the application to the foreground. Used when a notification is clicked: the launcher process owns the notification, so the Python process must explicitly pull itself to front. Safe to call unconditionally; does nothing if AppKit is unavailable. """ try: from AppKit import NSApplication NSApplication.sharedApplication().activateIgnoringOtherApps_(True) return True except Exception: return False meshy/src/platforms/macos/ble_corebluetooth.py000066400000000000000000000672161526212641700221770ustar00rootroot00000000000000# 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.constants import SCHEMA_ID from meshy.platforms.ble_base import ( NUS_RX_CHAR_UUID, NUS_SERVICE_UUID, NUS_TX_CHAR_UUID, BleDevice, ) 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 CoreBluetooth import ( CBUUID, CBCentralManager, CBCharacteristicWriteWithResponse, CBManagerStatePoweredOff, CBManagerStatePoweredOn, CBManagerStateUnauthorized, CBManagerStateUnknown, CBManagerStateUnsupported, ) from Foundation import NSObject 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: import os import sys from Foundation import NSURL, NSBundle # 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=SCHEMA_ID) 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 paired device. Mirrors the Linux BlueZ ``RemoveDevice`` behaviour: the device is dropped so it disappears from the paired list and reappears under "Pair with a new companion" for a fresh pair. macOS exposes no public API to delete the system-level BLE bond, so the persistent paired list (GSettings ``ble-paired-devices``) is this platform's pairing registry and the source of truth for :meth:`get_paired_devices`. Removing from it works regardless of the underlying bond state (full, half, or already gone); the next connect re-establishes pairing as needed. Args: device_path: The peripheral UUID string. Returns: True if the device was found and removed from the paired list. """ # Disconnect first if this is the active connection, otherwise # get_paired_devices() would re-list it as a connected peripheral. if ( self._connected_peripheral is not None and self._connected_peripheral.identifier().UUIDString() == device_path ): self.disconnect() removed = False try: from gi.repository import Gio, GLib settings = Gio.Settings(schema_id=SCHEMA_ID) saved_uuids = list(settings.get_value("ble-paired-devices")) if device_path in saved_uuids: saved_uuids.remove(device_path) settings.set_value("ble-paired-devices", GLib.Variant("as", saved_uuids)) removed = True log.info(f"Removed paired device: {device_path}") else: log.debug(f"Device not in paired list: {device_path}") except Exception as e: log.error(f"Failed to remove paired device: {e}") # Forget any cached peripheral so the device is rediscovered fresh. self._discovered_devices.pop(device_path, None) return removed # ─── 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=SCHEMA_ID) # 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.py000066400000000000000000000560031526212641700224740ustar00rootroot00000000000000# 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 from meshy.constants import QR_SCAN_HINT_DELAY_MS, QR_SCAN_TIMEOUT_MS 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 = QR_SCAN_HINT_DELAY_MS _QR_SCAN_TIMEOUT_MS = QR_SCAN_TIMEOUT_MS _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.py000066400000000000000000000123711526212641700230400ustar00rootroot00000000000000# 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 CoreLocation import ( CLLocationManager, kCLAuthorizationStatusAuthorizedAlways, kCLAuthorizationStatusAuthorizedWhenInUse, kCLAuthorizationStatusDenied, kCLAuthorizationStatusNotDetermined, kCLAuthorizationStatusRestricted, ) from Foundation import NSObject 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.py000066400000000000000000000046201526212641700207760ustar00rootroot00000000000000# 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 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.py000066400000000000000000000223741526212641700225270ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Native macOS notification service using UNUserNotificationCenter via launcher IPC.""" import json import logging import os import threading import uuid from ..notification_base import NotificationService log = logging.getLogger(__name__) class MacOSNotificationService(NotificationService): """Native macOS notification service via launcher IPC. The launcher (CFBundleExecutable) owns UNUserNotificationCenter access. Python sends JSON commands over a pipe (MESHY_NOTIFY_WR_FD) and reads responses from another pipe (MESHY_NOTIFY_RD_FD). Falls back to direct PyObjC calls when running outside a bundle. """ def __init__(self, application): """Initialize macOS notification service. Args: application: GTK application instance (unused, kept for API parity) """ self._authorized = False self._wr_fd = None self._rd_file = None self._lock = threading.Lock() self._response_ready = threading.Event() self._response = None self._fallback = None self._dead = False self._on_activate = None self._on_reply = None wr_fd_str = os.environ.get("MESHY_NOTIFY_WR_FD") rd_fd_str = os.environ.get("MESHY_NOTIFY_RD_FD") if wr_fd_str and rd_fd_str: self._init_pipe(int(wr_fd_str), int(rd_fd_str)) else: log.info("Notification pipe not available, using PyObjC fallback") self._init_fallback() def _init_pipe(self, wr_fd, rd_fd): """Set up pipe IPC with the launcher and request notification authorization.""" try: self._wr_fd = wr_fd self._rd_file = os.fdopen(rd_fd, "r") reader = threading.Thread(target=self._read_loop, daemon=True) reader.start() resp = self._send_command({"action": "auth"}) if resp and resp.get("ok"): self._authorized = resp.get("granted", False) if self._authorized: log.info("Notification authorization granted") else: log.warning("Notification authorization denied by user") else: err = resp.get("error", "unknown") if resp else "no response" log.error("Notification authorization failed: %s", err) except Exception as e: log.error("Failed to set up notification pipe: %s", e) self._close_pipe() def _close_pipe(self): """Close pipe file descriptors and mark service as dead.""" if self._rd_file: try: self._rd_file.close() except OSError: pass self._rd_file = None if self._wr_fd is not None: try: os.close(self._wr_fd) except OSError: pass self._wr_fd = None self._dead = True def _read_loop(self): """Read JSON events from the launcher pipe and dispatch callbacks.""" try: for line in self._rd_file: line = line.strip() if not line: continue try: msg = json.loads(line) except json.JSONDecodeError: continue event_type = msg.get("event") if event_type == "reply" and self._on_reply: notification_id = msg.get("thread") or msg.get("id", "") text = msg.get("text", "") from gi.repository import GLib GLib.idle_add(self._on_reply, notification_id, text) elif event_type == "activate" and self._on_activate: notification_id = msg.get("thread") or msg.get("id", "") from gi.repository import GLib GLib.idle_add(self._raise_and_activate, notification_id) elif "ok" in msg: self._response = msg self._response_ready.set() except Exception as e: log.debug("Notification read loop ended: %s", e) self._dead = True def _raise_and_activate(self, notification_id): """Bring the app to the foreground, then run the activate callback. The launcher process owns the notification, so the Python process must pull itself to front before navigating to the conversation. """ from .app_activation import raise_macos_app raise_macos_app() if self._on_activate: self._on_activate(notification_id) def _send_command(self, cmd): """Send a command and wait for response. Used only for auth at init.""" if self._wr_fd is None: return None with self._lock: self._response_ready.clear() self._response = None try: data = (json.dumps(cmd) + "\n").encode("utf-8") os.write(self._wr_fd, data) except OSError as e: log.error("Failed to write notification command: %s", e) self._close_pipe() return None if self._response_ready.wait(timeout=5.0): return self._response return None def _fire_command(self, cmd): """Write a command without waiting for response. Non-blocking.""" if self._dead or self._wr_fd is None: return try: data = (json.dumps(cmd) + "\n").encode("utf-8") os.write(self._wr_fd, data) except OSError: self._close_pipe() def _init_fallback(self): """Initialize direct PyObjC notification access when running outside a bundle.""" try: from objc import nil as objc_nil import UserNotifications as UN center = UN.UNUserNotificationCenter.currentNotificationCenter() def on_auth(granted, error): if error: log.error("Notification auth error: %s", error) else: self._authorized = granted options = ( UN.UNAuthorizationOptionAlert | UN.UNAuthorizationOptionSound | UN.UNAuthorizationOptionBadge ) center.requestAuthorizationWithOptions_completionHandler_( options, on_auth ) self._fallback = (UN, center, objc_nil) except Exception as e: log.info("Notifications not available: %s", e) def send_notification( self, title: str, body: str, notification_id: str, repliable: bool = False ) -> None: """Send a native macOS notification. Args: title: Notification title body: Notification body text notification_id: Unique identifier, used as thread ID for grouping repliable: If True, attach inline reply action (MESSAGE category) """ if not self.is_supported(): return # Use unique ID so multiple messages from the same contact/channel # stack in Notification Center instead of replacing each other. # Keep notification_id as thread identifier for grouping. unique_id = f"{notification_id}-{uuid.uuid4().hex[:8]}" if self._wr_fd is not None and not self._dead: cmd = { "action": "send", "title": title, "body": body, "id": unique_id, "thread": notification_id, } if repliable: cmd["category"] = "MESSAGE" self._fire_command(cmd) return if self._fallback: UN, center, objc_nil = self._fallback try: content = UN.UNMutableNotificationContent.alloc().init() content.setTitle_(title) content.setBody_(body) content.setSound_(UN.UNNotificationSound.defaultSound()) content.setThreadIdentifier_(notification_id) request = UN.UNNotificationRequest.requestWithIdentifier_content_trigger_( unique_id, content, objc_nil ) center.addNotificationRequest_withCompletionHandler_( request, lambda e: None ) except Exception as e: log.error("Notification exception: %s", e) def set_on_activate(self, callback): """Set callback for when user clicks a notification. Args: callback: Function(notification_id: str) called on activation """ self._on_activate = callback def set_on_reply(self, callback): """Set callback for when user replies to a notification. Args: callback: Function(notification_id: str, text: str) called on reply """ self._on_reply = callback def is_supported(self) -> bool: """Check if macOS notifications are supported. Returns: True if launcher pipe is alive or PyObjC fallback is available """ if self._wr_fd is not None and not self._dead: return True return self._fallback is not None meshy/src/platforms/macos/sleep_monitor_nsworkspace.py000066400000000000000000000037471526212641700237740ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Sleep/wake monitor using NSWorkspace notifications (macOS).""" import logging from gi.repository import GLib from meshy.platforms.sleep_monitor_base import SleepMonitorBase log = logging.getLogger(__name__) class SleepMonitorNSWorkspace(SleepMonitorBase): """Monitor sleep/wake via NSWorkspace notifications on macOS.""" def __init__(self): self._observers = [] self.on_will_sleep = None self.on_did_wake = None def start(self) -> None: try: from AppKit import NSWorkspace except ImportError: log.warning("PyObjC not available, sleep monitoring disabled") return workspace = NSWorkspace.sharedWorkspace() center = workspace.notificationCenter() will_sleep_obs = center.addObserverForName_object_queue_usingBlock_( "NSWorkspaceWillSleepNotification", workspace, None, lambda notification: self._on_will_sleep(), ) self._observers.append((center, will_sleep_obs)) did_wake_obs = center.addObserverForName_object_queue_usingBlock_( "NSWorkspaceDidWakeNotification", workspace, None, lambda notification: self._on_did_wake(), ) self._observers.append((center, did_wake_obs)) log.info("Sleep monitor: listening for NSWorkspace sleep/wake notifications") def _on_will_sleep(self): log.info("System going to sleep") if self.on_will_sleep: GLib.idle_add(lambda: self.on_will_sleep() or False) def _on_did_wake(self): log.info("System woke up") if self.on_did_wake: GLib.idle_add(lambda: self.on_did_wake() or False) def stop(self) -> None: for center, observer in self._observers: center.removeObserver_(observer) self._observers.clear() meshy/src/platforms/notification_base.py000066400000000000000000000034031526212641700210410ustar00rootroot00000000000000# 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, repliable: bool = False ) -> None: """Send a desktop notification. Args: title: Notification title body: Notification body text notification_id: Unique identifier for this notification repliable: If True, show inline reply action (macOS only) """ @abstractmethod def is_supported(self) -> bool: """Check if notifications are supported on this platform. Returns: True if notifications are available and can be sent """ def withdraw_notification(self, notification_id: str) -> None: # noqa: B027 """Withdraw a previously sent notification. Args: notification_id: Identifier used when the notification was sent The default implementation does nothing; platforms that support withdrawing notifications override it. """ def set_on_activate(self, callback) -> None: # noqa: B027 """Set callback for when user clicks a notification. Args: callback: Function(notification_id: str) called on activation """ def set_on_reply(self, callback) -> None: # noqa: B027 """Set callback for when user replies to a notification. Args: callback: Function(notification_id: str, text: str) called on reply """ meshy/src/platforms/sleep_monitor_base.py000066400000000000000000000014521526212641700212340ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Base interface for system sleep/wake monitoring.""" from abc import ABC, abstractmethod from collections.abc import Callable class SleepMonitorBase(ABC): """Abstract base for platform-specific sleep/wake monitors. Implementations detect system suspend/resume events and invoke the registered callbacks on the main thread. """ on_will_sleep: Callable[[], None] | None = None on_did_wake: Callable[[], None] | None = None @abstractmethod def start(self) -> None: """Begin monitoring system sleep/wake events.""" ... @abstractmethod def stop(self) -> None: """Stop monitoring and release resources.""" ... meshy/src/protocol.py000066400000000000000000001625371526212641700152310ustar00rootroot00000000000000# 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 os import struct from datetime import datetime from meshy.meshcore_packets import CommandType, ControlType, PacketType from meshy.shared_uri import normalize_region_scope 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_SHARE_CONTACT = CommandType.SHARE_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 CMD_RUN_CLI_COMMAND = CommandType.RUN_CLI_COMMAND.value # 66, v14+ # 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_CLI_REPLY = PacketType.CLI_REPLY.value # 29, v14+ 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 PUSH_CODE_CONTACTS_FULL = PacketType.CONTACTS_FULL.value # 0x90 # Text types TXT_TYPE_PLAIN = 0 TXT_TYPE_CLI_DATA = 1 # CLI command (repeater/room/sensor) or CLI reply TXT_TYPE_SIGNED = 2 TXT_TYPE_CLI_COMMAND = 3 # explicit remote CLI command (companion, fw >= 1.18) # 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 _truncate_utf8(text: str, max_bytes: int) -> bytes: """Truncate a string to at most max_bytes of valid UTF-8.""" raw = text.encode("utf-8")[:max_bytes] return raw.decode("utf-8", errors="ignore").encode("utf-8") 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) # Composed length ("name: text" for channels, plain text for DMs) at or above # which the companion's 0x88 RX-log push may no longer fit MAX_FRAME_SIZE on # some firmwares (172 before 1.16, 176 after) and is silently dropped, losing # heard-repeat/path metadata on receivers. UI warning only; sending is unaffected. METADATA_WARN_COMPOSED_BYTES = 139 # 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 AUTO_ADD_ALL_TYPES = AUTO_ADD_CHAT | AUTO_ADD_REPEATER | AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR # 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 decode_path_len_byte(plen: int) -> tuple[int, int]: """Decode a path length byte into raw ``(hop_count, hash_size)``. Upper 2 bits hold ``hash_size - 1`` and the low 6 bits the hop count. This is the *raw* decode used when the path byte determines a byte offset into a frame; it performs no validity clamping. Use :func:`_parse_path_length_byte` when you need the sanitized/logical path length for storage. """ hash_size = ((plen >> 6) & 0x03) + 1 hop_count = plen & 0x3F return (hop_count, hash_size) def _parse_path_length_byte(plen: int) -> tuple[int, int]: if plen == 255: return (-1, 1) hop_count, hash_size = decode_path_len_byte(plen) # hash_size 4 is reserved (firmware 1.17+ rejects it) if hash_size == 4 or hop_count * hash_size > 64: return (-1, 1) return (hop_count, hash_size) def encode_path_len(hop_count: int, hash_size: int = 1) -> int: """Encode a path length byte the way the firmware expects. Upper 2 bits hold ``hash_size - 1`` and the low 6 bits the hop count. A negative *hop_count* means "unknown/flood" and yields 0xFF. """ if hop_count < 0: return 0xFF hash_mode = max(hash_size - 1, 0) & 0x03 return (hop_count & 0x3F) | (hash_mode << 6) 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] def compute_scope_key(scope: str) -> bytes: """Public accessor for the region/flood scope key (SHA-256 prefix, 16 bytes).""" return _build_scope_key(scope) # ─── 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(" int: """Pick the over-the-air text type for a CLI command. Companion chats need the explicit ``TXT_TYPE_CLI_COMMAND`` (fw >= 1.18); repeaters, room servers and sensors still use ``TXT_TYPE_CLI_DATA``. """ if contact_type == ADV_TYPE_CHAT and supports_cli: return TXT_TYPE_CLI_COMMAND return TXT_TYPE_CLI_DATA def build_send_cli_command( pub_key: bytes, command: str, attempt: int = 0, timestamp: int | None = None, txt_type: int = TXT_TYPE_CLI_DATA, ) -> bytes: if timestamp is None: timestamp = _now_ts() frame = bytes([CMD_SEND_TXT_MSG, txt_type, min(attempt, 255)]) frame += struct.pack(" bytes: """Build a local CLI command frame for the connected companion (fw >= 1.18). The firmware reads the command to the end of the frame and null-terminates it itself, so no trailing NUL is added. """ return bytes([CMD_RUN_CLI_COMMAND]) + command.encode("utf-8") def build_send_self_advert(flood: bool = False) -> 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]) + _truncate_utf8(name, 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, last_advert: 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 = _truncate_utf8(name, 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_share_contact(pub_key: bytes) -> bytes: """Broadcast a contact's stored advert zero-hop ([CMD][pub_key_32]).""" return bytes([CMD_SHARE_CONTACT]) + 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 hashtag_channel_psk(name: str) -> bytes: """Derive a 16-byte PSK from a hashtag channel name (e.g. '#test'). SHA-256 of the lowercased UTF-8 name, truncated to 16 bytes. Matches MeshCoreOne's ChannelService.hashSecret(). """ return hashlib.sha256(name.lower().encode("utf-8")).digest()[:16] def parse_channel_url_params( params: dict[str, list[str]], ) -> tuple[str, bytes, str | None, None] | tuple[None, None, None, str]: """Parse name/secret/region from a meshcore://channel/add query string. Returns (name, secret, region, None) on success or (None, None, None, error_key) on failure. *region* is a normalized region name from the optional ``region_scope`` parameter, or ``None``. Error keys: 'invalid_secret', 'invalid_secret_length', 'invalid_link'. """ ch_name = params.get("name", [""])[0] secret_hex = params.get("secret", [""])[0].strip() region = normalize_region_scope(params.get("region_scope", [""])[0]) if secret_hex: try: secret = bytes.fromhex(secret_hex) except ValueError: return None, None, None, "invalid_secret" if len(secret) != 16: return None, None, None, "invalid_secret_length" if ch_name.startswith("#"): ch_name = ch_name.lower() return ch_name, secret, region, None if ch_name.startswith("#"): ch_name = ch_name.lower() return ch_name, hashtag_channel_psk(ch_name), region, None return None, None, None, "invalid_link" # ── Advert frame & meshcore:// URI parsing ────────────────────────── def parse_advert_appdata( buf: bytes, offset: int, flags: int ) -> tuple[float | None, float | None, str]: """Parse the trailing app-data of an advert (after the flags byte). Shared by :func:`parse_advert_frame` and :func:`meshy.mesh_crypto.parse_advert_payload`. *offset* points at the first byte after the flags byte. Returns ``(lat, lon, name)`` where lat/lon are ``None`` when absent or at the null island, and name is ``""`` when absent. Layout (each optional, gated by a flag bit): - 0x10: lat(4, LE signed /1e6) + lon(4) - 0x20: feat1(2) - 0x40: feat2(2) - 0x80: name (null-terminated UTF-8, runs to end of buffer) """ lat, lon = None, None if flags & 0x10 and len(buf) >= offset + 8: lat = int.from_bytes(buf[offset : offset + 4], "little", signed=True) / 1e6 lon = int.from_bytes(buf[offset + 4 : offset + 8], "little", signed=True) / 1e6 if abs(lat) < 1e-6 and abs(lon) < 1e-6: lat, lon = None, None offset += 8 if flags & 0x20: offset += 2 # feat1 if flags & 0x40: offset += 2 # feat2 name = "" if flags & 0x80 and len(buf) > offset: end = buf.index(0, offset) if 0 in buf[offset:] else len(buf) name = buf[offset:end].decode("utf-8", errors="replace") return lat, lon, name def parse_advert_frame(frame: bytes) -> dict | None: """Parse a raw advert frame into a dict with name, type, pub_key_hex. Returns None if the frame cannot be parsed. Frame layout: header(1) + path_len(1) + path(var) + pubkey(32) + timestamp(4) + signature(64) + appdata(flags + ...) """ try: i = 1 # skip header byte path_len_byte = frame[i] i += 1 hop_count, hash_size = decode_path_len_byte(path_len_byte) i += hop_count * hash_size pub_key = frame[i : i + 32] if len(pub_key) != 32: return None pub_key_hex = pub_key.hex() i += 32 + 4 + 64 # pubkey + timestamp + signature if i >= len(frame): return {"pub_key_hex": pub_key_hex, "name": "", "type": 0} flags = frame[i] i += 1 adv_type = flags & 0x0F _lat, _lon, name = parse_advert_appdata(frame, i, flags) return {"pub_key_hex": pub_key_hex, "name": name, "type": adv_type} except (IndexError, ValueError): return None def parse_meshcore_uri(uri: str) -> dict | None: """Parse a meshcore:// URI into a structured result dict. Handles three formats: - ``meshcore://contact/add?name=...&public_key=...&type=...`` (structured contact) - ``meshcore://channel/add?name=...&secret=...`` (structured channel) - ``meshcore://`` (raw advert frame) Also accepts bare 64-char hex strings (raw public key, no ``meshcore://`` prefix). Returns a dict with ``"kind"`` key (``"contact"``, ``"channel"``, or ``None`` on error) plus kind-specific fields: Contact result:: {"kind": "contact", "pub_key_hex": str, "name": str, "type": int, "raw_frame": bytes | None} Channel result:: {"kind": "channel", "name": str, "secret": bytes, "region_scope": str | None} Error result:: {"kind": None, "error": str} """ import re from urllib.parse import parse_qs, urlparse text = uri.strip() # ── Bare hex public key (no meshcore:// prefix) ───────────── if not text.lower().startswith("meshcore://"): cleaned = text.replace(" ", "").replace(":", "") try: pub_key = bytes.fromhex(cleaned) if len(pub_key) == 32: return { "kind": "contact", "pub_key_hex": cleaned.lower(), "name": "", "type": 1, "raw_frame": None, } except ValueError: pass return {"kind": None, "error": "not_meshcore_uri"} # ── Raw hex frame: meshcore:// ───────────────────────── hex_data = text[len("meshcore://") :].strip("/") if re.fullmatch(r"[0-9a-fA-F]+", hex_data): try: frame = bytes.fromhex(hex_data) except ValueError: return {"kind": None, "error": "invalid_hex"} parsed_frame = parse_advert_frame(frame) if parsed_frame: return { "kind": "contact", "pub_key_hex": parsed_frame["pub_key_hex"], "name": parsed_frame["name"], "type": parsed_frame["type"] or 1, "raw_frame": frame, } return {"kind": None, "error": "invalid_advert_frame"} # ── Structured URL ────────────────────────────────────────── parsed = urlparse(text) params = parse_qs(parsed.query) full_path = f"{parsed.netloc}{parsed.path}".rstrip("/") # Channel if full_path == "channel/add": ch_name, secret, region, error = parse_channel_url_params(params) if error: return {"kind": None, "error": error} return {"kind": "channel", "name": ch_name, "secret": secret, "region_scope": region} # Contact 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: return {"kind": None, "error": "invalid_public_key"} if len(pub_key) != 32: return {"kind": None, "error": "invalid_public_key_length"} return { "kind": "contact", "pub_key_hex": pub_key_hex.lower(), "name": name, "type": contact_type, "raw_frame": None, } return {"kind": None, "error": "unrecognized_link"} def build_set_channel(channel_index: int, name: str, psk: bytes) -> bytes: name_bytes = _truncate_utf8(name, 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 = _truncate_utf8(scope, 31).ljust(31, b"\x00") return bytes([cmd]) + scope_name + scope_key def build_get_default_flood_scope() -> bytes: """Read the persistent default flood scope (cmd 64); answered with DEFAULT_FLOOD_SCOPE.""" return bytes([CommandType.GET_DEFAULT_FLOOD_SCOPE.value]) 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(" 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, hash_size = decode_path_len_byte(path_len_byte) 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. Format: [CMD_SEND_BINARY_REQ][pubkey:32][req_type][reserved:4][random:4] The firmware sendRequest() multi-byte overload copies req_data verbatim after the 4-byte tag. The responder's onContactRequest() reads data[1] as an inverse permissions mask (~0x00 = 0xFF = allow all). Four random bytes follow to help make the packet hash unique (matching the layout produced by the single-byte sendRequest overload used by CMD_SEND_TELEMETRY_REQ). """ frame = bytes([CMD_SEND_BINARY_REQ]) frame += repeater_pub_key frame += bytes([REQ_TYPE_TELEMETRY]) frame += bytes(4) # reserved (data[1..4] on responder; 0x00 → perm_mask 0xFF) frame += os.urandom(4) # random blob for unique packet hash 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 ANON_REQ_OWNER = 0x02 def _build_anon_req( pub_key: bytes, req_type: int, path_length: int, path: bytes, path_hash_size: int = 1 ) -> bytes: """Build a CMD_SEND_ANON_REQ frame for the given *req_type*. Format: [CMD_SEND_ANON_REQ][pub_key_32][req_type][reply_path_header][path_reversed_by_hop] reply_path_header encodes hop count in the low 6 bits and hash mode (hash_size - 1) in the top 2 bits, matching the firmware's unpacking: hop_count = byte & 0x3F hash_size = (byte >> 6) + 1 """ cmd = CommandType.SEND_ANON_REQ.value frame = bytes([cmd]) frame += pub_key[:32] frame += bytes([req_type]) hash_mode = max(path_hash_size - 1, 0) hop_count = min(path_length, 63) path_bytes = path[: hop_count * path_hash_size] reversed_path = b"".join( path_bytes[i * path_hash_size : (i + 1) * path_hash_size] for i in range(hop_count - 1, -1, -1) ) frame += bytes([hop_count | (hash_mode << 6)]) frame += reversed_path return frame def build_anon_req_regions( pub_key: bytes, path_length: int, path: bytes, path_hash_size: int = 1 ) -> bytes: """Build an anonymous request for regions from a repeater.""" return _build_anon_req(pub_key, ANON_REQ_REGIONS, path_length, path, path_hash_size) def build_anon_req_owner( pub_key: bytes, path_length: int, path: bytes, path_hash_size: int = 1 ) -> bytes: """Build an anonymous request for owner info from a repeater. Format identical to regions request but with ANON_REQ_OWNER type. Response: [sender_timestamp(4)][device_clock(4)][node_name\\nowner_info as UTF-8] """ return _build_anon_req(pub_key, ANON_REQ_OWNER, path_length, path, path_hash_size) 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_cli_reply(data: bytes) -> str | None: """Parse a local CLI reply (``RESP_CODE_CLI_REPLY``): code byte + text.""" if not data or data[0] != RESP_CODE_CLI_REPLY: return None return data[1:].decode("utf-8", "replace").rstrip("\x00") 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_decode.py000066400000000000000000000046711526212641700153070ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """QR code decoding helpers with no GTK/GStreamer dependencies. zbar only recognises dark modules on a light background. Codes displayed by apps using a dark theme are inverted (light modules on a dark background) and therefore fail to decode. :func:`decode_qr_codes` retries a frame with inverted - and, for low-contrast cameras pointed at a screen, thresholded - pixel data until a code is found. """ import logging try: from pyzbar import pyzbar as _pyzbar except ImportError: _pyzbar = None log = logging.getLogger(__name__) def _invert_table(): """Translation table mapping every byte to its inverted value.""" return bytes(255 - value for value in range(256)) def _invert_threshold_table(threshold): """Translation table turning a dark-image frame into light-on-dark. Pixels brighter than *threshold* become black (0), the rest become white (255), i.e. an inverted binarisation suitable for zbar. """ return bytes(0 if value > threshold else 255 for value in range(256)) # Tried in order. Each entry is (name, pixel translation table or None). _VARIANTS = ( ("original", None), ("inverted", _invert_table()), ("inverted/threshold-160", _invert_threshold_table(160)), ("inverted/threshold-200", _invert_threshold_table(200)), ) def decode_qr_codes(pixels, width, height): """Return the QR payloads found in 8-bit grayscale *pixels*. *pixels* must be a buffer of ``width * height`` bytes. The frame is retried with inverted and thresholded variants so that codes shown on dark-themed screens are recognised. Returns a list of UTF-8 strings (empty when nothing was decoded or zbar is unavailable). """ if _pyzbar is None: return [] for name, table in _VARIANTS: data = pixels if table is None else pixels.translate(table) try: results = _pyzbar.decode( (data, width, height), symbols=[_pyzbar.ZBarSymbol.QRCODE], ) except Exception as e: # pragma: no cover - defensive log.debug(f"QR decode error ({name}): {e}") continue if results: if table is not None: log.debug(f"QR code decoded using {name} pixels") return [qr.data.decode("utf-8", errors="ignore").strip() for qr in results] return [] meshy/src/qr_scanner.py000066400000000000000000000462071526212641700155160ustar00rootroot00000000000000# 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 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 from meshy.constants import QR_SCAN_HINT_DELAY_MS, QR_SCAN_TIMEOUT_MS from meshy.qr_decode import decode_qr_codes 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 self._device_provider = None self._cameras = [] self._current_camera_idx = 0 self._src_element = 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): """Enumerate cameras via pipewiredeviceprovider and start scanning.""" provider = Gst.DeviceProviderFactory.get_by_name("pipewiredeviceprovider") if provider is None: log.warning("pipewiredeviceprovider not available, using direct pipewiresrc") self._start_pipeline(pw_fd, src_element=None) return provider.set_property("fd", pw_fd) provider.start() self._cameras = [d for d in provider.get_devices() if d.has_classes("Video/Source")] if not self._cameras: provider.stop() log.warning("No Video/Source device found, using direct pipewiresrc") self._start_pipeline(pw_fd, src_element=None) return self._device_provider = provider # Prefer back camera for QR scanning (front is bad for scanning) self._current_camera_idx = 0 for i, cam in enumerate(self._cameras): props = cam.get_properties() if props: loc = props.get_value("api.libcamera.location") if isinstance(loc, str) and loc.lower() in ("back", "1"): self._current_camera_idx = i break camera = self._cameras[self._current_camera_idx] src = camera.create_element(None) src.set_property("client-name", "Meshy QR Scanner") self._src_element = src log.info(f"Camera: {camera.get_display_name()} ({len(self._cameras)} available)") self._start_pipeline(pw_fd, src_element=src) def _start_pipeline(self, pw_fd, src_element=None): """Build and start the GStreamer pipeline. If *src_element* is provided (from pipewiredeviceprovider), builds a programmatic pipeline with decodebin3 for MJPEG support. Otherwise falls back to a simple parse_launch pipeline. """ 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) # Show camera switch button if multiple cameras available switch_btn = builder.get_object("switch_camera_btn") if len(self._cameras) > 1: switch_btn.set_visible(True) switch_btn.connect("clicked", self._on_switch_camera) if src_element is not None: self._build_pipeline_decodebin(src_element) else: self._build_pipeline_simple(pw_fd) if self._pipeline is None: return # error already shown # 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(QR_SCAN_HINT_DELAY_MS, self._on_scan_hint) self._timeout_timer_id = GLib.timeout_add(QR_SCAN_TIMEOUT_MS, self._on_scan_timeout) def _on_switch_camera(self, *_args): """Switch to the next camera in the list.""" if len(self._cameras) < 2 or not self._pipeline or not self._src_element: return if getattr(self, "_switching", False): return # switch already in progress self._switching = True self._current_camera_idx = (self._current_camera_idx + 1) % len(self._cameras) camera = self._cameras[self._current_camera_idx] log.info(f"Switching to camera: {camera.get_display_name()}") # Async state change: NULL → reconfigure → PLAYING # set_state(NULL) can block on slow hardware, so watch for # the state-changed message on the bus to continue. self._pending_camera = camera self._pipeline.set_state(Gst.State.NULL) # Use get_state with a short timeout to avoid blocking the main loop GLib.timeout_add(100, self._complete_camera_switch) def _complete_camera_switch(self): """Finish camera switch after pipeline reaches NULL.""" if not self._pipeline or not self._pending_camera: self._switching = False return False ret, state, _pending = self._pipeline.get_state(50 * Gst.MSECOND) if ret == Gst.StateChangeReturn.ASYNC: # Not yet NULL, try again return True camera = self._pending_camera self._pending_camera = None try: camera.reconfigure_element(self._src_element) except GLib.Error as e: log.error(f"Failed to reconfigure camera: {e.message}") self._pipeline.set_state(Gst.State.PLAYING) self._switching = False return False def _build_pipeline_decodebin(self, src): """Pipeline with decodebin3 for raw + MJPEG camera support. Uses the same approach as GNOME Snapshot/Aperture: src → capsfilter(raw formats + JPEG) → decodebin3 →(pad-added)→ capsfilter(video/x-raw) → videoconvert → tee tee → queue → videoconvert → gtk4paintablesink (preview) tee → queue → videoconvert → GRAY8 → appsink (QR decode) The pre-decode capsfilter restricts negotiation to specific raw formats and JPEG, excluding DMA-BUF which causes negotiation failures with decodebin3 on many V4L2 cameras. """ self._pipeline = Gst.Pipeline.new("qr-pipeline") # Pre-decode capsfilter: specific raw formats + JPEG (like Snapshot) src_filter = Gst.ElementFactory.make("capsfilter", "src_filter") src_filter.set_property( "caps", Gst.Caps.from_string( "video/x-raw,format={I420,BGRx,RGBx,xBGR,xRGB,YUY2,YUYV,NV12,NV21,UYVY}; " "image/jpeg" ), ) decode = Gst.ElementFactory.make("decodebin3", "decode") # Post-decode: ensure raw video raw_filter = Gst.ElementFactory.make("capsfilter", "raw_filter") raw_filter.set_property("caps", Gst.Caps.from_string("video/x-raw")) convert1 = Gst.ElementFactory.make("videoconvert", "convert1") tee = Gst.ElementFactory.make("tee", "t") # Preview branch q_preview = Gst.ElementFactory.make("queue", "q_preview") convert_preview = Gst.ElementFactory.make("videoconvert", "convert_preview") sink = Gst.ElementFactory.make("gtk4paintablesink", "sink") # QR decode branch q_qr = Gst.ElementFactory.make("queue", "q_qr") q_qr.set_property("max-size-buffers", 1) q_qr.set_property("leaky", 2) # downstream convert_qr = Gst.ElementFactory.make("videoconvert", "convert_qr") gray_filter = Gst.ElementFactory.make("capsfilter", "gray_filter") gray_filter.set_property("caps", Gst.Caps.from_string("video/x-raw,format=GRAY8")) appsink = Gst.ElementFactory.make("appsink", "qrsink") appsink.set_property("emit-signals", True) appsink.set_property("max-buffers", 1) appsink.set_property("drop", True) elements = ( src, src_filter, decode, raw_filter, convert1, tee, q_preview, convert_preview, sink, q_qr, convert_qr, gray_filter, appsink, ) for el in elements: if el is None: log.error("Missing GStreamer plugin") self._pipeline = None self._show_error(_("Missing GStreamer plugin.")) return self._pipeline.add(el) # Static links src.link(src_filter) src_filter.link(decode) # raw_filter → convert1 → tee linked after decodebin3 pad-added raw_filter.link(convert1) convert1.link(tee) tee.link(q_preview) q_preview.link(convert_preview) convert_preview.link(sink) tee.link(q_qr) q_qr.link(convert_qr) convert_qr.link(gray_filter) gray_filter.link(appsink) # Dynamic link: decodebin3 → raw_filter def _on_pad_added(_element, pad): sink_pad = raw_filter.get_static_pad("sink") if not sink_pad.is_linked(): pad.link(sink_pad) decode.connect("pad-added", _on_pad_added) self._picture.set_paintable(sink.get_property("paintable")) appsink.connect("new-sample", self._on_new_sample) def _build_pipeline_simple(self, pw_fd): """Fallback parse_launch pipeline (no pipewiredeviceprovider).""" 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 ! " "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._pipeline = None self._show_error(_("Camera pipeline error: {}").format(e.message)) return sink = self._pipeline.get_by_name("sink") self._picture.set_paintable(sink.get_property("paintable")) appsink = self._pipeline.get_by_name("qrsink") appsink.connect("new-sample", self._on_new_sample) 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 pixels = bytes(mapinfo.data) buf.unmap(mapinfo) for data in decode_qr_codes(pixels, width, height): 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 if self._device_provider: self._device_provider.stop() self._device_provider = 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.py000066400000000000000000000035261526212641700167110ustar00rootroot00000000000000# 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/shared_uri.py000066400000000000000000000066641526212641700155130ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Parsing/formatting of shared contact/location/channel snippets in chat text. These are MeshCore protocol-level text conventions (no GTK dependency). """ import re from urllib.parse import parse_qs, quote, urlparse _SHARED_CONTACT_RE = re.compile(r"^<([0-9a-fA-F]{64}):(\d+):(.+)>$") _SHARED_LOCATION_RE = re.compile(r"^(-?\d+\.\d+),\s*(-?\d+\.\d+)$") _SHARED_CHANNEL_RE = re.compile(r"^meshcore://channel/add\?", re.IGNORECASE) # Firmware flood-scope name field is 31 bytes with zero padding and accepts # 0 < strlen(name) < 31, so the effective cap is 30 UTF-8 bytes. _MAX_REGION_SCOPE_BYTES = 30 _EMPTY_REGION_SCOPE_VALUES = ("*", "0", "None") def normalize_region_scope(value: str | None) -> str | None: """Normalize a shared ``region_scope`` into Meshy's region-name convention. Trims surrounding whitespace, drops a leading ``#`` (Meshy stores region names without it and re-adds it when hashing the scope key), caps to the firmware flood-scope byte limit, and maps empty / wildcard values to ``None`` (no region). """ if not value: return None name = value.strip() if name.startswith("#"): name = name[1:].strip() if not name or name in _EMPTY_REGION_SCOPE_VALUES: return None encoded = name.encode("utf-8") if len(encoded) > _MAX_REGION_SCOPE_BYTES: name = encoded[:_MAX_REGION_SCOPE_BYTES].decode("utf-8", errors="ignore").strip() return name or None 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 parse_shared_channel(text: str) -> tuple[str, str, str | None] | None: """Parse a meshcore://channel/add?... URL from a chat message. Returns (name, secret_hex, region) on success, or None if not a valid channel link. For hashtag channels the secret may be empty string. *region* is a normalized region name (from the optional ``region_scope`` parameter) or ``None`` when absent. """ text = text.strip() if not _SHARED_CHANNEL_RE.match(text): return None try: parsed = urlparse(text) params = parse_qs(parsed.query, keep_blank_values=True) except Exception: return None ch_name = params.get("name", [""])[0] if not ch_name: return None secret_hex = params.get("secret", [""])[0].strip() if secret_hex: try: s = bytes.fromhex(secret_hex) except ValueError: return None if len(s) != 16: return None region = normalize_region_scope(params.get("region_scope", [""])[0]) return (ch_name, secret_hex, region) def format_shared_channel(name: str, secret_hex: str, region: str | None = None) -> str: """Format a channel as a meshcore://channel/add?... URL for sharing.""" url = f"meshcore://channel/add?name={quote(name)}" if secret_hex: url += f"&secret={secret_hex}" if region: url += f"®ion_scope={quote(region)}" return url meshy/src/sleep_monitor.py000066400000000000000000000015151526212641700162330ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Sleep/wake monitor factory for cross-platform support.""" from meshy.platforms import is_linux, is_macos from meshy.platforms.sleep_monitor_base import SleepMonitorBase __all__ = ["SleepMonitorBase", "create_sleep_monitor"] def create_sleep_monitor() -> SleepMonitorBase | None: """Create a platform-appropriate sleep/wake monitor. Returns None if the current platform is not supported. """ if is_linux(): from meshy.platforms.linux.sleep_monitor_logind import SleepMonitorLogind return SleepMonitorLogind() elif is_macos(): from meshy.platforms.macos.sleep_monitor_nsworkspace import SleepMonitorNSWorkspace return SleepMonitorNSWorkspace() return None meshy/src/smaz.py000066400000000000000000000111031526212641700143200ustar00rootroot00000000000000# 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.py000066400000000000000000001510501526212641700150200ustar00rootroot00000000000000# 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 glob import hashlib import logging import os import sqlite3 import time from datetime import datetime from typing import NamedTuple from meshy.models import Channel, ChannelMessage, Contact, Message, MessageStatus, NotificationLevel log = logging.getLogger(__name__) class ChannelPathStats(NamedTuple): """Aggregated path observations for one channel message.""" count: int min_path_len: int region: str def _data_dir() -> str: return os.path.join( os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")), "meshy" ) class Storage: @staticmethod def db_path_for_device(identifier: str) -> str: safe_id = identifier.replace(":", "_").replace("/", "_") return os.path.join(_data_dir(), f"device_{safe_id}.db") @staticmethod def delete_device_db(identifier: str): """Delete the DB for *identifier*. The identifier is usually a transport address (BLE MAC, host:port, serial port), but once connected the DB is re-keyed to the public key. Delete any public-key DB that recorded this address too, otherwise "delete stored data" silently leaves it behind. """ targets = {Storage.db_path_for_device(identifier)} for candidate in glob.glob(os.path.join(_data_dir(), "device_*.db")): if candidate in targets: continue try: conn = sqlite3.connect(candidate) try: row = conn.execute( "SELECT value FROM settings WHERE key = 'device_address'" ).fetchone() finally: conn.close() except sqlite3.Error: continue if row and row[0] == identifier: targets.add(candidate) for path in targets: 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: os.makedirs(_data_dir(), exist_ok=True) db_path = os.path.join(_data_dir(), "meshy.db") self._db_path = db_path 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, custom_name TEXT NOT NULL DEFAULT '', 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 '', region 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.""" # The settings table is used below to gate one-time data migrations, so # ensure it exists before any of them run. self._db.execute(""" CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ) """) 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(channel_message_paths)") cmp_columns = {row[1] for row in cursor.fetchall()} if "region" not in cmp_columns: self._db.execute( "ALTER TABLE channel_message_paths ADD COLUMN region TEXT NOT NULL DEFAULT ''" ) 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 "last_seed_lastmod" not in ct_columns: self._db.execute("ALTER TABLE contacts ADD COLUMN last_seed_lastmod REAL") if "custom_name" not in ct_columns: self._db.execute("ALTER TABLE contacts ADD COLUMN custom_name TEXT NOT NULL DEFAULT ''") if "smart_route_selection" not in ct_columns: # Fresh column already defaults to -1, so no data conversion is # needed; flag it as done so the one-time conversion below never # runs on this DB (it would clobber explicit 0 = "not smart"). self._db.execute( "ALTER TABLE contacts ADD COLUMN smart_route_selection INTEGER NOT NULL DEFAULT -1" ) self._mark_migration_done("smart_route_selection_default") elif not self._migration_done("smart_route_selection_default"): # One-time conversion of the old default (0) to the new "use global # default" sentinel (-1). Gated by a flag so it does not run on every # startup — otherwise it would clobber contacts the user explicitly # set to 0 ("not smart") back to the global default. self._db.execute( "UPDATE contacts SET smart_route_selection = -1 WHERE smart_route_selection = 0" ) self._mark_migration_done("smart_route_selection_default") if "unread_count" not in ct_columns: self._db.execute( "ALTER TABLE contacts ADD COLUMN unread_count INTEGER NOT NULL DEFAULT 0" ) self._db.execute(""" UPDATE contacts SET unread_count = ( SELECT COUNT(*) FROM messages WHERE messages.contact_key_hex = contacts.public_key_hex AND messages.is_outgoing = 0 AND messages.timestamp > COALESCE(contacts.last_read_at, 0) ) WHERE is_active = 1 """) if "unread_count_backfilled" not in ch_columns: self._db.execute( "ALTER TABLE channels ADD COLUMN unread_count_backfilled INTEGER DEFAULT 0" ) self._db.execute(""" UPDATE channels SET unread_count = ( SELECT COUNT(*) FROM channel_messages WHERE channel_messages.channel_index = channels.idx AND channel_messages.is_outgoing = 0 AND channel_messages.timestamp > COALESCE(channels.last_read_at, 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") if "source" not in dc_columns: self._db.execute( "ALTER TABLE discovered_contacts ADD COLUMN source TEXT NOT NULL DEFAULT 'advert'" ) 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 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 _migration_done(self, name: str) -> bool: """Whether a one-time data migration flagged *name* has already run.""" row = self._db.execute( "SELECT 1 FROM settings WHERE key = ?", (f"_migration:{name}",) ).fetchone() return row is not None def _mark_migration_done(self, name: str): """Record that a one-time data migration flagged *name* has run.""" self._db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, '1')", (f"_migration:{name}",), ) 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 < ? AND source != 'map'", (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): if self._batch_depth > 0: log.warning( "Storage closed with batch_depth=%d, forcing commit", self._batch_depth, ) self._batch_depth = 0 try: self._db.commit() except Exception: pass self._db.close() # ─── Contacts ────────────────────────────────────────────── def save_contact(self, contact: Contact): self._db.execute( """ INSERT INTO contacts (public_key_hex, name, custom_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, unread_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(public_key_hex) DO UPDATE SET name = excluded.name, custom_name = excluded.custom_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, last_seed_lastmod = contacts.last_seed_lastmod """, ( contact.public_key_hex, contact.name, contact.custom_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), contact.unread_count, ), ) 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 get_last_seed_lastmod(self, public_key_hex: str) -> float | None: row = self._db.execute( "SELECT last_seed_lastmod FROM contacts WHERE public_key_hex = ?", (public_key_hex,), ).fetchone() return row["last_seed_lastmod"] if row else None def update_last_seed_lastmod(self, public_key_hex: str, lastmod: float): self._db.execute( "UPDATE contacts SET last_seed_lastmod = ? WHERE public_key_hex = ?", (lastmod, public_key_hex), ) self._commit() 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._db.execute("DELETE FROM path_history 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"], custom_name=row["custom_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"]), unread_count=row["unread_count"], ) 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, source = 'advert' """, ( 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 save_map_contacts(self, contacts: list): with self.batch(): self._db.execute("DELETE FROM discovered_contacts WHERE source = 'map'") self._db.executemany( """ INSERT OR IGNORE INTO discovered_contacts (public_key_hex, name, type, flags, path_length, path, path_hash_size, latitude, longitude, last_seen, received_at, source) VALUES (?, ?, ?, ?, -1, X'', 1, ?, ?, ?, ?, 'map') """, [ ( c.public_key_hex, c.name, c.type, c.flags, c.latitude, c.longitude, c.last_seen.timestamp() if c.last_seen else None, c.received_at.timestamp() if c.received_at else None, ) for c in contacts ], ) def delete_map_contacts(self): self._db.execute("DELETE FROM discovered_contacts WHERE source = 'map'") self._commit() def count_map_contacts(self) -> int: row = self._db.execute( "SELECT COUNT(*) as cnt FROM discovered_contacts WHERE source = 'map'" ).fetchone() return row["cnt"] if row else 0 def get_max_message_timestamp(self, contact_key_hex: str) -> float | None: """Return the newest message timestamp for a contact.""" row = self._db.execute( "SELECT MAX(timestamp) as ts FROM messages WHERE contact_key_hex = ?", (contact_key_hex,), ).fetchone() return row["ts"] if row and row["ts"] else None 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 # ─── Counter-based unread tracking ───────────────────────── def increment_channel_unread(self, index: int): self._db.execute( "UPDATE channels SET unread_count = unread_count + 1 WHERE idx = ?", (index,), ) self._commit() def zero_channel_unread(self, index: int): self._db.execute("UPDATE channels SET unread_count = 0 WHERE idx = ?", (index,)) self._commit() def increment_contact_unread(self, public_key_hex: str): self._db.execute( "UPDATE contacts SET unread_count = unread_count + 1 WHERE public_key_hex = ?", (public_key_hex,), ) self._commit() def zero_contact_unread(self, public_key_hex: str): self._db.execute( "UPDATE contacts SET unread_count = 0 WHERE public_key_hex = ?", (public_key_hex,), ) self._commit() def has_duplicate_channel_message( self, channel_index: int, timestamp: float, text: str, sender_name: str = "", ) -> bool: row = self._db.execute( """ SELECT 1 FROM channel_messages WHERE channel_index = ? AND text = ? AND sender_name = ? AND abs(timestamp - ?) < 1 AND is_outgoing = 0 LIMIT 1 """, (channel_index, text, sender_name, timestamp), ).fetchone() return row is not None # ─── 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, 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._db.execute("UPDATE channels SET unread_count = 0 WHERE idx = ?", (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_setting(self, key: str, default: str = "") -> str: row = self._db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() return row["value"] if row else default def set_setting(self, key: str, value: str): self._db.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) 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() def has_recent_outgoing_channel_message( self, channel_index: int, text: str, timestamp: float, window: int = 60 ) -> bool: row = self._db.execute( """ SELECT 1 FROM channel_messages WHERE channel_index = ? AND is_outgoing = 1 AND text = ? AND abs(timestamp - ?) < ? LIMIT 1 """, (channel_index, text, timestamp, window), ).fetchone() return row is not None # ─── 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, region) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( channel_index, sender_timestamp, text_hash, path_info.get("snr"), path_info.get("path_len", -1), path_info.get("path_bytes", ""), path_info.get("region", ""), ), ) self._commit() def get_channel_message_path_stats(self, channel_index: int, messages: list) -> dict: """Return {(sender_timestamp, text_hash): ChannelPathStats} for a batch.""" if not messages: return {} pairs = [(int(m.timestamp.timestamp()), self._msg_text_hash(m.text)) for m in messages] result = {} for ts, th in pairs: row = self._db.execute( """SELECT COUNT(*) AS cnt, MIN(path_len) AS min_pl, MAX(region) AS region FROM channel_message_paths WHERE channel_index = ? AND sender_timestamp = ? AND msg_text_hash = ?""", (channel_index, ts, th), ).fetchone() if row and row["cnt"] > 0: result[(ts, th)] = ChannelPathStats(row["cnt"], row["min_pl"], row["region"] or "") return result 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 = round(row["route_weight"] + weight_increment, 4) 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() @property def db_path(self) -> str: return self._db_path meshy/src/tcp.py000066400000000000000000000106601526212641700141430ustar00rootroot00000000000000# 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/text_utils.py000066400000000000000000000042551526212641700155640ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Pure text helpers (no GTK dependency).""" import unicodedata def split_message_text(text: str) -> list[tuple[str, str]]: """Split message text into ``("text", …)`` and ``("mention", name)`` segments. Everything that is not an ``@[name]`` mention is returned as a ``"text"`` segment (including URLs — those are handled by :func:`meshy.gtk_utils.linkify`). A mention is terminated by the ``]`` that closes its opening ``[``, so names may themselves contain balanced ``[``/``]`` pairs (e.g. ``@[[GNSM] Comp 0x01]``). """ segments: list[tuple[str, str]] = [] i = 0 n = len(text) while i < n: idx = text.find("@[", i) if idx == -1: segments.append(("text", text[i:])) break end = -1 depth = 0 for j in range(idx + 1, n): c = text[j] if c == "[": depth += 1 elif c == "]": depth -= 1 if depth == 0: end = j break if end == -1 or end == idx + 2: # Unterminated or empty mention — keep it as plain text. segments.append(("text", text[i:])) break if idx > i: segments.append(("text", text[i:idx])) segments.append(("mention", text[idx + 2 : end])) i = end + 1 return segments 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/theme_chooser_dialog.py000066400000000000000000000071521526212641700175220ustar00rootroot00000000000000# 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.py000066400000000000000000000155001526212641700161470ustar00rootroot00000000000000# 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 from meshy.constants import SCHEMA_ID 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=SCHEMA_ID) @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/time_format.py000066400000000000000000000024541526212641700156650ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Duration / relative-time formatting helpers (no GTK dependency).""" import gettext import locale from meshy.i18n import ngettext def _(msg): return gettext.dgettext("meshy", msg) def format_trip_time(ms: int | None) -> str: if not ms: return "" return locale.format_string("%.1f", ms / 1000) + "\u202f" + "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) meshy/src/transport_base.py000066400000000000000000000201441526212641700164010ustar00rootroot00000000000000# 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.py000066400000000000000000000114571526212641700155120ustar00rootroot00000000000000# 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.py000066400000000000000000000034331526212641700145150ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Backward-compatibility re-exports. The helpers that used to live here were split into focused modules: - :mod:`meshy.i18n` - ngettext, number formatting - :mod:`meshy.geo` - distance math and locale-aware distance formatting - :mod:`meshy.time_format`- duration / relative-time formatting - :mod:`meshy.text_utils` - pure text helpers (mentions, emoji) - :mod:`meshy.shared_uri` - shared contact/location/channel snippet parsing - :mod:`meshy.gtk_utils` - GTK/GLib helpers (linkify, TimeoutManager, clear_children) New code should import from those modules directly. This module re-exports the pure (non-GTK) helpers so existing imports keep working; the GTK helpers (``linkify``, ``TimeoutManager``, ``clear_children``) must be imported from :mod:`meshy.gtk_utils` to keep this module free of a GTK dependency. """ from meshy.geo import ( format_distance, haversine_distance, route_distance, uses_metric_units, ) from meshy.i18n import format_number, ngettext, parse_number from meshy.shared_uri import ( format_shared_channel, parse_shared_channel, parse_shared_contact, parse_shared_location, ) from meshy.text_utils import extract_emoji, split_message_text from meshy.time_format import format_duration, format_time_ago, format_trip_time __all__ = [ "extract_emoji", "format_distance", "format_duration", "format_number", "format_shared_channel", "format_time_ago", "format_trip_time", "haversine_distance", "ngettext", "parse_number", "parse_shared_channel", "parse_shared_contact", "parse_shared_location", "route_distance", "split_message_text", "uses_metric_units", ] meshy/src/views/000077500000000000000000000000001526212641700141355ustar00rootroot00000000000000meshy/src/views/__init__.py000066400000000000000000000122751526212641700162550ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared view utilities. The bulk of what used to live here was split into focused modules: - :mod:`meshy.views.map_tiles` - Shumate tile/style plumbing and map widgets - :mod:`meshy.views.pickers` - reusable contact-picker list infrastructure - :mod:`meshy.views.dialogs` - shared alert / login / confirm-import dialogs This module keeps a few tiny widget helpers and re-exports the moved names so existing ``from meshy.views import ...`` imports keep working. """ import gettext from datetime import datetime, timedelta import gi gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") from gi.repository import Adw, GLib, Gtk from meshy.models import snr_to_label # Re-exports (backward compatibility). from meshy.views.dialogs import ( # noqa: F401 show_alert, show_confirm_channel_dialog, show_confirm_contact_dialog, show_login_dialog, ) from meshy.views.map_tiles import ( # noqa: F401 apply_custom_source, create_shumate_map, deregister_map_widget, open_map_picker, ) from meshy.views.pickers import ( # noqa: F401 _CONTACT_TYPE_ICONS, setup_contact_picker_filter_sort, show_contact_picker, ) def _(msg): return gettext.dgettext("meshy", msg) def snr_quality_label(snr: float, sf: int = 12) -> str: """Translated signal-quality label ("Good", "Fair", …) for an SNR.""" return _(snr_to_label(snr, sf)) def mark_property_rows(widget): """Add the ``property`` style class to every descendant action row. Read-only property rows render the (small, dimmed) title as the label and the emphasized subtitle as the value, e.g. "Battery" above "90%". """ child = widget.get_first_child() while child is not None: if isinstance(child, Adw.ActionRow): child.add_css_class("property") mark_property_rows(child) child = child.get_next_sibling() 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")) 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) def build_telemetry_groups(entries): """Build Adw.PreferencesGroup widgets from parsed Cayenne LPP entries. Returns (groups, gps_entry) where *groups* is a list of ``Adw.PreferencesGroup`` (one per channel) and *gps_entry* is the first GPS entry found (or ``None``). """ from meshy.i18n import format_number from meshy.models import battery_percent_from_mv from meshy.protocol import LPP_GPS, LPP_VOLTAGE channels: dict[int, list[dict]] = {} for entry in entries: channels.setdefault(entry["channel"], []).append(entry) groups = [] 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 = gps_entry or 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) groups.append(group) return groups, gps_entry meshy/src/views/autocomplete.py000066400000000000000000000100231526212641700172040ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared popover-backed autocomplete list for entries and text views. Both the CLI command completion and the chat @mention completion need a non-modal popover holding a selectable list. This wraps that scaffold so the callers only supply items and an activation callback. """ import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk from meshy.gtk_utils import clear_children class AutocompletePopover: """A `Gtk.Popover` with a single-column `Gtk.ListBox` of string items.""" def __init__( self, parent, *, max_height=240, position=Gtk.PositionType.TOP, halign=Gtk.Align.START, ): self._on_activate = None self._values = [] self._listbox = Gtk.ListBox() self._listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) self._listbox.set_can_focus(False) self._listbox.connect("row-activated", self._on_row_activated) scrolled = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, max_content_height=max_height, propagate_natural_height=True, ) scrolled.set_child(self._listbox) self._popover = Gtk.Popover() self._popover.set_parent(parent) self._popover.set_autohide(False) self._popover.set_has_arrow(False) self._popover.set_position(position) self._popover.set_halign(halign) self._popover.set_can_focus(False) self._popover.set_child(scrolled) @property def popover(self): return self._popover def set_on_activate(self, callback): self._on_activate = callback def set_items(self, values, *, css_class=None, margin_x=0, margin_y=0): clear_children(self._listbox) self._values = list(values) for value in self._values: label = Gtk.Label( label=value, xalign=0, margin_start=margin_x, margin_end=margin_x, margin_top=margin_y, margin_bottom=margin_y, ) if css_class: label.add_css_class(css_class) row = Gtk.ListBoxRow(child=label, activatable=True) # Keep suggestions out of the focus chain: the popover is a child # of the entry, so focusing a row would pull focus out of the entry # without emitting focus-out (triggers a GtkText warning). row.set_can_focus(False) self._listbox.append(row) self._listbox.select_row(None) def show(self): self._popover.popup() def hide(self): if self.is_visible(): self._popover.popdown() def is_visible(self): return self._popover.get_visible() def selected_value(self): row = self._listbox.get_selected_row() if row is None: return None index = row.get_index() return self._values[index] if 0 <= index < len(self._values) else None def first_value(self): return self._values[0] if self._values else None def move_selection(self, delta, *, wrap=True): count = len(self._values) if not count: return current = self._listbox.get_selected_row() if current is None: if delta > 0: self._listbox.select_row(self._listbox.get_row_at_index(0)) elif wrap: self._listbox.select_row(self._listbox.get_row_at_index(count - 1)) return index = current.get_index() + delta if wrap: index %= count elif index < 0 or index >= count: return self._listbox.select_row(self._listbox.get_row_at_index(index)) def _on_row_activated(self, listbox, row): if self._on_activate is not None: index = row.get_index() if 0 <= index < len(self._values): self._on_activate(self._values[index]) meshy/src/views/channels_view.py000066400000000000000000003221331526212641700173400ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Channels list and chat view.""" import gettext from meshy.protocol import hashtag_channel_psk 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 from meshy.chat_entry import ChatEntry # noqa: F401 – registers GType from meshy.constants import SCHEMA_ID from meshy.geo import format_distance, route_distance from meshy.gtk_utils import clear_children from meshy.i18n import format_number from meshy.models import Channel, ChannelMessage, NotificationLevel, snr_to_icon from meshy.protocol import METADATA_WARN_COMPOSED_BYTES, max_channel_text_bytes from meshy.shared_uri import ( format_shared_channel, parse_shared_channel, parse_shared_contact, parse_shared_location, ) from meshy.text_utils import extract_emoji from meshy.time_format import format_time_ago from meshy.views import _add_hover_swap, _format_date_text, show_alert from meshy.views.autocomplete import AutocompletePopover from meshy.views.chat_helpers import ( CHANNEL_TYPE_ICONS, CONTACT_TYPE_ICONS, ensure_loc_map, open_location_external, share_channel_dialog, share_contact_dialog, share_location, share_location_map, show_location_map, ) from meshy.views.chat_mixin import ChatScrollMixin from meshy.views.label_with_widgets import ( apply_message_text, buffer_byte_len, extract_buffer_text, insert_mention_pill, ) _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._min_hops_val = -1 self._region_val = "" @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 min_hops(self): return self._min_hops_val @min_hops.setter def min_hops(self, value): self._min_hops_val = value @GObject.Property(type=str) def region(self): return self._region_val @region.setter def region(self, value): self._region_val = value or "" @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") _char_warn_icon = Gtk.Template.Child("char_warn_icon") _placeholder_label = Gtk.Template.Child("placeholder_label") _emoji_btn = Gtk.Template.Child("emoji_btn") _share_btn = Gtk.Template.Child("share_btn") _text_entry = Gtk.Template.Child("text_entry") _text_scroll = Gtk.Template.Child("text_scroll") _chat_entry = Gtk.Template.Child("chat_entry") _send_btn_simple = Gtk.Template.Child("send_btn_simple") _send_btn_split = Gtk.Template.Child("send_btn_split") def do_grab_focus(self): if self._text_entry.get_editable(): return self._text_entry.grab_focus() return False 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_complete = None # Pango attributes for char counter attrs = Pango.AttrList() attrs.insert(Pango.attr_scale_new(0.85)) attrs.insert(Pango.attr_font_features_new("tnum=1")) 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 = self._build_incoming_menu(with_paths=True) self._incoming_menu_no_paths = self._build_incoming_menu(with_paths=False) 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) double_tap = Gtk.GestureClick(button=1) double_tap.set_touch_only(True) double_tap.connect("pressed", self._on_list_double_tap) self._list_view.add_controller(double_tap) 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.get_buffer().connect("changed", self._on_text_changed) key_ctrl = Gtk.EventControllerKey() key_ctrl.connect("key-pressed", self._on_entry_key_pressed) self._text_entry.add_controller(key_ctrl) self._send_btn_simple.connect("clicked", self._on_send) self._send_btn_split.connect("clicked", self._on_send) # Send scope override menu (dropdown on split button) self._send_scope_menu = Gio.Menu() self._send_scope_menu.append(_("Send"), "send.default") self._send_scope_menu.append(_("Send Unscoped"), "send.unscoped") send_group = Gio.SimpleActionGroup() for name, override in [("default", None), ("unscoped", "unscoped")]: action = Gio.SimpleAction.new(name, None) action.connect("activate", lambda _a, _p, ov=override: self._on_send_with_scope(ov)) send_group.add_action(action) self._send_btn_split.insert_action_group("send", send_group) # Extra context menu on text entry extra_menu = Gio.Menu() extra_menu.append(_("Share My Contact"), "entry.share-my-contact") extra_menu.append(_("Share Contact"), "entry.share-contact") extra_menu.append(_("Share Channel"), "entry.share-channel") 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_my_a = Gio.SimpleAction.new("share-my-contact", None) share_my_a.connect("activate", self._on_share_my_contact) entry_group.add_action(share_my_a) share_a = Gio.SimpleAction.new("share-contact", None) share_a.connect("activate", self._on_share_contact) entry_group.add_action(share_a) share_ch_a = Gio.SimpleAction.new("share-channel", None) share_ch_a.connect("activate", self._on_share_channel) entry_group.add_action(share_ch_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) self._share_btn.set_menu_model(extra_menu) self._share_btn.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._region_lbl = builder.get_object("region_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._sch_header_lbl = builder.get_object("sch_header_lbl") outer._sch_icon = builder.get_object("sch_icon") outer._sch_name_lbl = builder.get_object("sch_name_lbl") outer._sch_type_lbl = builder.get_object("sch_type_lbl") outer._sch_join_btn = builder.get_object("sch_join_btn") outer._sch_join_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 / shared channel sc = parse_shared_contact(msg.text) sch = parse_shared_channel(msg.text) if not sc else None 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, "meshy-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, ) elif sch: ch_name, secret_hex, ch_region = sch outer._content_stack.set_visible_child_name("channel") if msg.is_outgoing: outer._sch_header_lbl.set_label(_("You shared a channel:")) else: sender = msg.sender_name or "?" outer._sch_header_lbl.set_label( _("{name} shared a channel:").format(name=sender) ) # Determine channel type for icon if secret_hex == Channel.PUBLIC_CHANNEL_PSK: ch_type = "Public" elif ch_name.startswith("#") and ( not secret_hex or secret_hex == hashtag_channel_psk(ch_name).hex() ): ch_type = "Hashtag" else: ch_type = "Private" outer._sch_icon.set_from_icon_name( CHANNEL_TYPE_ICONS.get(ch_type, "meshy-system-users-symbolic") ) outer._sch_name_lbl.set_label(ch_name) if ch_region: outer._sch_type_lbl.set_label(_("Region: {region}").format(region=ch_region)) else: outer._sch_type_lbl.set_label(ch_type) if msg.is_outgoing: outer._sch_join_btn.set_visible(False) else: outer._sch_join_btn.set_visible(True) is_joined = self._is_channel_joined(ch_name, secret_hex) outer._sch_join_btn.set_sensitive(not is_joined) outer._sch_join_btn.set_label( _("Already joined") if is_joined else _("Join Channel") ) if outer._sch_join_handler_id is not None: outer._sch_join_btn.disconnect(outer._sch_join_handler_id) outer._sch_join_handler_id = outer._sch_join_btn.connect( "clicked", self._on_shared_channel_join, ch_name, secret_hex, ch_region, ) 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") apply_message_text( outer._text_lbl, msg.text, self._window.device_info.name or "", ) # 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) outer._region_lbl.set_visible(False) 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) self._update_region_label_widget(outer, item) h1 = item.connect( "notify::min-hops", lambda obj, pspec, w=outer: self._update_hops_label_widget(w, obj), ) h2 = item.connect( "notify::region", lambda obj, pspec, w=outer: self._update_region_label_widget(w, obj), ) outer._handler_ids = [h1, h2] 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._sch_join_handler_id is not None: outer._sch_join_btn.disconnect(outer._sch_join_handler_id) outer._sch_join_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): if self._window.add_contact(bytes.fromhex(pub_key_hex), contact_type, name): btn.set_sensitive(False) btn.set_label(_("Already added")) # ─── Shared channel helpers ───────────────────────────── def _is_channel_joined(self, ch_name, secret_hex): """Check if a channel with the given name is already joined.""" for ch in self._window.channels: if ch.is_empty: continue if secret_hex: if ch.psk_hex == secret_hex: return True elif ch_name.startswith("#"): expected_psk = hashtag_channel_psk(ch_name) if ch.psk == expected_psk: return True return False def _on_shared_channel_join(self, btn, ch_name, secret_hex, region=None): if secret_hex: secret = bytes.fromhex(secret_hex) elif ch_name.startswith("#"): secret = hashtag_channel_psk(ch_name) else: return self._window.add_channel_from_qr(ch_name, secret, region) btn.set_sensitive(False) btn.set_label(_("Already joined")) # ─── 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.min_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_region_label_widget(outer, item): region = item.region if region: outer._region_lbl.set_label("\u00b7 " + region) outer._region_lbl.set_visible(True) else: outer._region_lbl.set_label("") outer._region_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 _build_incoming_menu(self, with_paths: bool) -> Gio.Menu: menu = Gio.Menu() menu.append(_("Reply"), "msg.reply") menu.append(_("Copy Text"), "msg.copy") if with_paths: menu.append(_("View Message Paths"), "msg.paths") menu.append(_("Delete"), "msg.delete") return 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: key = self._make_msg_key( item.message.channel_index, item.message.timestamp, item.message.text ) menu = ( self._incoming_menu if self._get_message_paths(key, item.message) else self._incoming_menu_no_paths ) self._context_popover.set_menu_model(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, msg) 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_my_contact(self, action, param): if not self._current_channel: return dev = self._window.device_info if not dev or not dev.public_key: return channel_idx = self._current_channel.index text = f"<{dev.public_key}:1:{dev.name}>" self._window.send_channel_message(channel_idx, text) 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.display_name}>" self._window.send_channel_message(channel_idx, text) share_contact_dialog(self._window, self._window.contacts, _send) def _on_share_channel(self, action, param): if not self._current_channel: return channel_idx = self._current_channel.index channels = [ch for ch in self._window.channels if not ch.is_empty] def _send(ch): text = format_shared_channel(ch.name, ch.psk_hex, ch.region) self._window.send_channel_message(channel_idx, text) share_channel_dialog(self._window, channels, _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 _ensure_mention_popover(self): if self._mention_complete is None: self._mention_complete = AutocompletePopover(self._text_entry, max_height=280) self._mention_complete.set_on_activate(self._on_mention_selected) return self._mention_complete def _hide_mention_popover(self): if self._mention_complete is not None: self._mention_complete.hide() 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: self._hide_mention_popover() return mention = self._ensure_mention_popover() try: buf = self._text_entry.get_buffer() iter_at = buf.get_iter_at_offset(at_char_idx) iter_rect = self._text_entry.get_iter_location(iter_at) cursor_x, cursor_y = self._text_entry.buffer_to_window_coords( Gtk.TextWindowType.WIDGET, iter_rect.x, iter_rect.y ) cursor_h = iter_rect.height except Exception: cursor_x = 0 cursor_y = 0 cursor_h = self._text_entry.get_height() rect = Gdk.Rectangle() rect.x = cursor_x rect.y = cursor_y rect.width = 1 rect.height = cursor_h mention.popover.set_pointing_to(rect) mention.set_items(filtered[:20], margin_x=8, margin_y=4) mention.show() def _check_mention_trigger(self, text: str, cursor_pos: int): text_before = text[:cursor_pos] at_idx = text_before.rfind("@") if at_idx == -1: self._hide_mention_popover() return if at_idx > 0 and text_before[at_idx - 1] not in (" ", "\t"): self._hide_mention_popover() return after_at = text_before[at_idx + 1 :] if "[" in after_at: self._hide_mention_popover() return self._update_mention_popover(after_at, at_idx) def _on_mention_selected(self, name): buf = self._text_entry.get_buffer() cursor_pos = buf.get_iter_at_mark(buf.get_insert()).get_offset() text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False) text_before = text[:cursor_pos] at_idx = text_before.rfind("@") if at_idx == -1: return # Delete the "@prefix" typed so far. start = buf.get_iter_at_offset(at_idx) end = buf.get_iter_at_offset(cursor_pos) buf.delete(start, end) # Place cursor at the deletion point and insert the pill. buf.place_cursor(buf.get_iter_at_offset(at_idx)) insert_mention_pill(self._text_entry, name) self._hide_mention_popover() def _on_entry_key_pressed(self, ctrl, keyval, keycode, state): if self._mention_complete and self._mention_complete.is_visible(): if keyval == Gdk.KEY_Escape: self._hide_mention_popover() return True if keyval == Gdk.KEY_Down: self._mention_complete.move_selection(1, wrap=False) return True if keyval == Gdk.KEY_Up: self._mention_complete.move_selection(-1, wrap=False) return True if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_Tab): selected = self._mention_complete.selected_value() if selected is not None: self._on_mention_selected(selected) return True if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): if not (state & Gdk.ModifierType.SHIFT_MASK): self._on_send() 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 clear(self): """Release all message items from the store to free memory.""" self._store.remove_all() 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() def set_channel(self, channel: Channel): if self._mention_complete: self._hide_mention_popover() if self._current_channel is not None: if self._is_sticky: self._scroll_positions[self._current_channel.index] = None else: adj = self._scroll.get_vadjustment() self._scroll_positions[self._current_channel.index] = adj.get_value() self._current_channel = channel self._update_send_menu() 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 self._is_sticky = True 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) path_stats = self._load_path_stats(channel.index, 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, path_stats)) 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 _load_path_stats(self, channel_index, messages): """Return lightweight {(ts, text_hash): ChannelPathStats} stats.""" if not self._window.storage: return {} return self._window.storage.get_channel_message_path_stats(channel_index, messages) 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 path_stats = self._load_path_stats(self._current_channel.index, 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, path_stats)) # 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) @staticmethod def _make_dedup_key(channel_index, timestamp, sender_name, text): """Create a key for deduplication that includes sender_name.""" ts = int(timestamp.timestamp()) if hasattr(timestamp, "timestamp") else int(timestamp) return (channel_index, ts, sender_name, text) def _create_message_item( self, msg: ChannelMessage, path_stats: dict | None = None ) -> 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 # Use live in-memory repeats if available, otherwise use DB stats if key in self._repeats: item._repeat_count_val = len(self._repeats[key]) elif path_stats: from meshy.storage import Storage stats_key = ( int(msg.timestamp.timestamp()), Storage._msg_text_hash(msg.text), ) stats = path_stats.get(stats_key) if stats: item._repeat_count_val = stats.count else: self._incoming_items[key] = item # Use live in-memory paths if available, otherwise use DB stats if key in self._incoming_paths: paths = self._incoming_paths[key] lens = [p.get("path_len", -1) for p in paths] lens = [v for v in lens if v >= 0] item._min_hops_val = min(lens) if lens else -1 item._region_val = next((p.get("region") for p in paths if p.get("region")), "") elif path_stats: from meshy.storage import Storage stats_key = ( int(msg.timestamp.timestamp()), Storage._msg_text_hash(msg.text), ) stats = path_stats.get(stats_key) if stats: if stats.min_path_len >= 0: item._min_hops_val = stats.min_path_len item._region_val = stats.region 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 _get_message_paths(self, key, msg): """Return the captured paths for a message, preferring storage. Storage holds the full history, while the in-memory list may only contain paths received since the messages were loaded. """ paths = self._incoming_paths.get(key, []) if self._window.storage: db_paths = self._window.storage.get_channel_message_paths( msg.channel_index, int(msg.timestamp.timestamp()), msg.text, ) if db_paths: paths = db_paths return paths def _show_message_paths(self, key, msg): """Show dialog with message path variants and repeater hops.""" paths = self._get_message_paths(key, msg) if not paths: 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=ngettext( "Message from {sender} received via {count} path:", "Message from {sender} received via {count} paths:", len(paths), ).format(sender=msg.sender_name or _("unknown"), count=len(paths)), 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", "") hops = [] hop_contacts = [] 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 = ngettext("%d hop", "%d hops", num_hops) % num_hops hop_contacts = self._window.resolve_hop_prefixes(hops) elif path_len == 255 or path_len == -1: hops_str = _("flood") elif path_len == 0: hops_str = _("direct") else: hops_str = ngettext("%d hop", "%d hops", path_len) % path_len distance_str = self._path_distance_str(hop_contacts) snr_str = _("SNR: {} dB").format(format_number(snr)) if snr is not None else "" title_parts = [_("Path {}: {}").format(i, hops_str)] if distance_str: title_parts.append(distance_str) if snr_str: title_parts.append(snr_str) path_group = Adw.PreferencesGroup( title=" \u00b7 ".join(title_parts), ) if hops: for hop_idx, prefix in enumerate(hops): contact = hop_contacts[hop_idx] name = contact.display_name if contact else _("Unknown repeater") subtitle = _("Hop {} \u00b7 prefix {}").format(hop_idx + 1, prefix) hop_row = Adw.ActionRow( title=name, subtitle=subtitle, ) hop_row.add_prefix(Gtk.Image.new_from_icon_name("meshy-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="meshy-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("meshy-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 _path_distance_str(self, hop_contacts) -> str: """Total route distance for a path, including the final leg to us. Unknown repeaters are bridged (see :func:`route_distance`), so when any of them is missing the total is a lower bound and gets a ``≥`` prefix. """ points = [ (c.latitude, c.longitude) if c and c.has_location else (None, None) for c in hop_contacts ] device = self._window.device_info self_lat = getattr(device, "latitude", None) self_lon = getattr(device, "longitude", None) if self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6): points.append((self_lat, self_lon)) total, partial = route_distance(points) if total <= 0: return "" return ("≥ " if partial else "") + format_distance(total) def _show_channel_path_on_map(self, hop_contacts, hop_prefixes=None): """Show a channel message path on the map.""" from meshy.views.map_view import MapView hops = MapView.build_hop_list( self._window.device_info, hop_contacts, hop_prefixes=hop_prefixes, skip_far_companion=False, append_self=True, ) if hops is None: return MapView.open_hop_path_dialog( self._window, hops, self._window.device_info, title=_("Message Path"), labels="distance", on_detail=self._window.show_contact_detail, ) def _reply_to_message(self, msg): """Insert a mention pill for the sender into the text entry.""" buf = self._text_entry.get_buffer() buf.set_text("") if msg.sender_name: insert_mention_pill(self._text_entry, msg.sender_name) 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, msg): repeats = self._repeats.get(key, []) # Prefer storage: it holds the full repeat history, while the # in-memory list may only contain repeats heard since the messages # were loaded. if self._window.storage: paths = self._window.storage.get_channel_message_paths( msg.channel_index, int(msg.timestamp.timestamp()), msg.text, ) if paths: repeats = [ { "path_hops": self._split_path_hops( p.get("path_bytes", ""), p.get("path_len", 0), ), "path_len": p.get("path_len", 0), "snr": p.get("snr"), } for p in paths ] if not repeats: show_alert( self._window, _("Heard Repeats"), _("No repeats heard yet."), ) return dialog = Adw.Dialog(title=_("Heard Repeats"), content_width=360) header = Adw.HeaderBar() listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) listbox.add_css_class("boxed-list") for rep in repeats: 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.display_name if contact else prefix) path_str = " \u2192 ".join(hop_names) else: path_len = rep.get("path_len", 0) path_str = ( ngettext("%d hop", "%d hops", path_len) % path_len if path_len > 0 else _("direct") ) row = Adw.ActionRow(title=path_str) snr = rep.get("snr") if snr is not None: dev = self._window.device_info sf = dev.spreading_factor if dev else 12 row.add_prefix(Gtk.Image.new_from_icon_name(snr_to_icon(snr, sf))) row.set_subtitle(_("SNR: {} dB").format(format_number(snr))) listbox.append(row) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) content.append(header) clamp = Adw.Clamp( maximum_size=360, margin_start=12, margin_end=12, margin_top=12, margin_bottom=12 ) clamp.set_child(listbox) content.append(clamp) dialog.set_child(content) 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 removed_index = -1 for i in range(self._store.get_n_items()): if self._store.get_item(i) is item: removed_index = i self._store.remove(i) break self._remove_orphaned_dividers(removed_index) # 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 _get_channel_warn_at(self) -> int: """Text byte count where composed 'name: ' + text reaches the metadata-risk boundary.""" sender_name = self._window.device_info.name or "" return max(0, METADATA_WARN_COMPOSED_BYTES - len(sender_name.encode("utf-8")) - 2) _MAX_INPUT_HEIGHT = 120 def _update_input_height(self): buf = self._text_entry.get_buffer() y, h = self._text_entry.get_line_yrange(buf.get_end_iter()) content = y + h + self._text_entry.get_top_margin() + self._text_entry.get_bottom_margin() self._text_scroll.set_size_request(-1, min(content, self._MAX_INPUT_HEIGHT)) if content > self._MAX_INPUT_HEIGHT: self._text_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) else: self._text_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) GLib.idle_add(self._reset_input_scroll) def _reset_input_scroll(self): self._text_scroll.get_vadjustment().set_value(0) return GLib.SOURCE_REMOVE def _on_text_changed(self, buffer): text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False) used = buffer_byte_len(self._text_entry) limit = self._get_channel_limit() warn_at = self._get_channel_warn_at() self._placeholder_label.set_visible(used == 0) if used == 0: self._char_label.set_label("") self._send_btn_simple.set_sensitive(True) self._send_btn_split.set_sensitive(True) self._char_label.remove_css_class("error") self._chat_entry.remove_css_class("error") self._set_metadata_warning(False, False) else: self._char_label.set_label(f"{used}/{limit}") over = used > limit self._send_btn_simple.set_sensitive(not over) self._send_btn_split.set_sensitive(not over) if over: self._char_label.add_css_class("error") self._chat_entry.add_css_class("error") else: self._char_label.remove_css_class("error") self._chat_entry.remove_css_class("error") self._set_metadata_warning(used >= warn_at, over) self._update_input_height() cursor_pos = buffer.get_iter_at_mark(buffer.get_insert()).get_offset() self._check_mention_trigger(text, cursor_pos) def _on_send(self, *args): self._on_send_with_scope(None) def _on_send_with_scope(self, scope_override: str | None): if not self._current_channel: return buf = self._text_entry.get_buffer() text = extract_buffer_text(self._text_entry).strip() if not text: return if len(text.encode("utf-8")) > self._get_channel_limit(): return buf.set_text("") self._text_scroll.set_size_request(-1, -1) self._window.send_channel_message( self._current_channel.index, text, scope_override=scope_override ) def _channel_has_scope(self) -> bool: if self._current_channel and self._current_channel.region: return True return bool(self._window._settings_view.default_scope) def _update_send_menu(self): has_scope = self._channel_has_scope() self._send_btn_simple.set_visible(not has_scope) self._send_btn_split.set_visible(has_scope) if has_scope: self._send_btn_split.set_menu_model(self._send_scope_menu) # ─── 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_path_info(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 >= 0 and (item.min_hops < 0 or new_len < item.min_hops): item.props.min_hops = new_len if not item.region and path_info.get("region"): item.props.region = path_info["region"] 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.""" dedup_key = self._make_dedup_key( msg.channel_index, msg.timestamp, msg.sender_name, msg.text ) msg_key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text) if dedup_key in self._seen_channel_msgs: if path_info: self._incoming_paths.setdefault(msg_key, []).append(path_info) self._update_item_path_info(msg_key, path_info) return True self._seen_channel_msgs.add(dedup_key) if path_info: self._incoming_paths.setdefault(msg_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_path_info(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, snr=None): """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, "snr": snr, } # 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 = item.repeat_count + 1 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_counts: dict[int, int] = {} self._unread_dots: dict[int, Gtk.Label] = {} settings = Gio.Settings(schema_id=SCHEMA_ID) saved_filter = settings.get_string("channel-filter") self._current_filter = ( saved_filter if saved_filter in (self.FILTER_ALL, self.FILTER_PUBLIC, self.FILTER_HASHTAG, self.FILTER_PRIVATE) else self.FILTER_ALL ) 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) action = Gio.SimpleAction.new("import-clipboard", None) action.connect("activate", self._on_import_clipboard) action_group.add_action(action) import meshy as _meshy_pkg if _meshy_pkg.QR_SCANNER_ENABLED: action = Gio.SimpleAction.new("scan-qr", None) action.connect("activate", self._on_scan_qr) 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( ngettext( "{visible}/{total} channel", "{visible}/{total} channels", total, ).format(visible=visible, total=total) ) else: self._status_label.set_label( ngettext("{total} channel", "{total} channels", total).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) settings = Gio.Settings(schema_id=SCHEMA_ID) settings.set_string("channel-filter", self._current_filter) 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=SCHEMA_ID) 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) self._chat_widget.grab_focus() def select_channel(self, channel_index: int) -> bool: """Select and open the channel with the given index. Args: channel_index: Index of the channel to select Returns: True if the channel was found and selected """ for row in self._channel_list: if hasattr(row, "_channel") and row._channel.index == channel_index: self._channel_list.select_row(row) self._on_row_selected(self._channel_list, row) return True return 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 = "meshy-hashtag-symbolic" elif channel.is_public_channel: icon_name = "meshy-system-users-symbolic" else: icon_name = "meshy-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="meshy-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 self._unread_counts.get(r._channel.index, 0) > 0] 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, snr=None): return self._chat_widget.on_repeat_received(channel_idx, sender_ts, text, path_hops, snr) def mark_unread(self, channel_index: int, unread: bool): """Show or hide the unread badge for a channel.""" if unread: self._unread_counts[channel_index] = self._unread_counts.get(channel_index, 0) + 1 else: 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="meshy-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="meshy-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="meshy-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="meshy-edit-clear-all-symbolic", valign=Gtk.Align.CENTER) clear_btn.add_css_class("destructive-action") def _on_clear(*_args): dialog.close() self._window.clear_channel_messages_with_undo(channel) 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="meshy-user-trash-symbolic", valign=Gtk.Align.CENTER) remove_btn.add_css_class("destructive-action") def _on_remove(*args): dialog.close() self._window.remove_channel_with_undo(channel) 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("meshy-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("meshy-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 within the device limit.""" return self._window.find_next_empty_channel_index() def _show_no_slots_dialog(self): max_channels = self._window.device_info.max_channels or 8 show_alert( self._window, _("No Slots Available"), _("All {count} channel slots are in use.").format(count=max_channels), ) def _on_create_private_channel(self, *args): idx = self._find_next_empty_index() if idx is None: self._show_no_slots_dialog() return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/create-private-channel-content.ui" ) dialog = builder.get_object("create_channel_dialog") name_row = builder.get_object("name_row") def _on_create(*_args): import os name = name_row.get_text().strip() if not name: return psk = os.urandom(16) self._window.set_channel(idx, name, psk) dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("create_btn").connect("clicked", _on_create) 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 builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/join-private-channel-content.ui" ) dialog = builder.get_object("join_private_channel_dialog") name_row = builder.get_object("name_row") psk_row = builder.get_object("psk_row") def _on_join(*_args): try: name = name_row.get_text().strip() psk = bytes.fromhex(psk_row.get_text().strip()) if len(psk) == 16 and name: psk_hex = psk.hex() for ch in self._window.channels: if not ch.is_empty and ch.psk_hex == psk_hex: self._window.show_toast( _('Channel "{}" is already added').format(ch.name) ) dialog.close() return self._window.set_channel(idx, name, psk) except ValueError: pass dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("join_btn").connect("clicked", _on_join) 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: show_alert( self._window, _("Already Joined"), _("You are already on the public channel."), ) 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 builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/join-hashtag-channel-content.ui" ) dialog = builder.get_object("join_hashtag_dialog") hashtag_row = builder.get_object("hashtag_row") def _on_join(*_args): tag = hashtag_row.get_text().strip() if not tag: return if not tag.startswith("#"): tag = f"#{tag}" tag = tag.lower() psk = hashtag_channel_psk(tag) psk_hex = psk.hex() for ch in self._window.channels: if not ch.is_empty and ch.psk_hex == psk_hex: self._window.show_toast(_('Channel "{}" is already added').format(ch.name)) dialog.close() return self._window.set_channel(idx, tag, psk) dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("join_btn").connect("clicked", _on_join) 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://"): self._show_channel_import_error(_("No meshcore:// link found in clipboard.")) return from meshy.protocol import parse_meshcore_uri parsed = parse_meshcore_uri(text.strip()) if parsed["kind"] == "channel": from meshy.views import show_confirm_channel_dialog show_confirm_channel_dialog( self._window, parsed["name"], parsed["secret"], parsed.get("region_scope") ) return if parsed["kind"] == "contact": from meshy.views import show_confirm_contact_dialog show_confirm_contact_dialog( self._window, parsed["pub_key_hex"], name=parsed["name"], contact_type=parsed["type"], ) return self._show_channel_import_error(_("No channel link found in clipboard.")) 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): from meshy.protocol import parse_meshcore_uri parsed = parse_meshcore_uri(data) if parsed["kind"] == "channel": from meshy.views import show_confirm_channel_dialog show_confirm_channel_dialog( self._window, parsed["name"], parsed["secret"], parsed.get("region_scope") ) return if parsed["kind"] == "contact": from meshy.views import show_confirm_contact_dialog show_confirm_contact_dialog( self._window, parsed["pub_key_hex"], name=parsed["name"], contact_type=parsed["type"], ) return self._show_channel_import_error(_("No channel found in QR code:\n{}").format(data[:120])) def _show_channel_import_error(self, message): show_alert(self._window, _("Import Failed"), message) meshy/src/views/chat_helpers.py000066400000000000000000000244241526212641700171560ustar00rootroot00000000000000# 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, Gdk, Gio, GLib, GObject, Gtk, Shumate from meshy.i18n import ngettext from meshy.views import ( _CONTACT_TYPE_ICONS, create_shumate_map, setup_contact_picker_filter_sort, show_alert, show_contact_picker, ) CONTACT_TYPE_ICONS = _CONTACT_TYPE_ICONS # Icons for channel types (matches sidebar icons in channels_view) CHANNEL_TYPE_ICONS = { "Hashtag": "meshy-hashtag-symbolic", "Public": "meshy-system-users-symbolic", "Private": "meshy-padlock2-symbolic", } 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, "meshy-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, "meshy-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, "meshy-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 dev = window.device_info self_lat = dev.latitude if dev and dev.latitude is not None else None self_lon = dev.longitude if dev and dev.longitude is not None else None ns = show_contact_picker( window, title=_("Share Contact"), contacts=contacts, on_add=send_callback, button_label=_("Share"), close_on_select=True, ) setup_contact_picker_filter_sort( ns, sort_options=[("name", _("Name")), ("last-seen", _("Last Seen"))], default_sort="name", self_lat=self_lat, self_lon=self_lon, action_prefix="share", ) ns.present() def format_contact_uri(name: str, public_key_hex: str, contact_type: int = 1) -> str: """Build the ``meshcore://contact/add?...`` link for a contact.""" from urllib.parse import quote return ( f"meshcore://contact/add?name={quote(name)}" f"&public_key={public_key_hex}&type={int(contact_type)}" ) def show_contact_qr_dialog(window, name, public_key_hex, contact_type=1, title=None, parent=None): """Show a QR code dialog for adding *name* as a contact.""" import io try: import segno except ImportError: 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") if title: dialog.set_title(title) name_label.set_label(name) qr = segno.make(format_contact_uri(name, public_key_hex, contact_type)) 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(parent or window) 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: show_alert( window, _("Location Not Available"), _("Your companion does not have a GPS location."), ) 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}"), ) class _ChannelPickItem(GObject.Object): def __init__(self, channel): super().__init__() self.channel = channel def share_channel_dialog(window, channels, send_callback): """Show a channel picker dialog and call send_callback(channel) on selection.""" if not channels: return builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/discover-dialog.ui") dialog = builder.get_object("discover_dialog") dialog.set_title(_("Share Channel")) 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=_ChannelPickItem) for ch in sorted(channels, key=lambda c: c.name.lower()): store.append(_ChannelPickItem(ch)) def _filter_func(item, _data): search = search_entry.get_text().lower() return not search or search in item.channel.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) def update_status(): total = store.get_n_items() visible = filter_model.get_n_items() if total == 0: status_label.set_label(_("No channels")) elif visible != total: status_label.set_label( ngettext( "{visible}/{total} channel", "{visible}/{total} channels", total, ).format(visible=visible, total=total) ) else: status_label.set_label( ngettext("{total} channel", "{total} channels", total).format(total=total) ) def _handle_select(channel): send_callback(channel) dialog.close() search_entry.connect( "search-changed", lambda *_: (custom_filter.changed(Gtk.FilterChange.DIFFERENT), update_status()), ) factory = Gtk.SignalListItemFactory() def _setup(f, li): row = Adw.ActionRow(focusable=False) icon = Gtk.Image() row.add_prefix(icon) btn = Gtk.Button(label=_("Share"), 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) li.set_child(row) def _bind(f, li): row = li.get_child() ch = li.get_item().channel row.set_title( GLib.markup_escape_text(ch.name) if ch.name else _("Channel {}").format(ch.index) ) row.set_subtitle(ch.channel_type) icon_name = CHANNEL_TYPE_ICONS.get(ch.channel_type, "meshy-system-users-symbolic") row._icon.set_from_icon_name(icon_name) row._action_handler_id = row._action_btn.connect("clicked", lambda _b: _handle_select(ch)) def _unbind(f, li): row = li.get_child() if row._action_handler_id is not None: row._action_btn.disconnect(row._action_handler_id) row._action_handler_id = None factory.connect("setup", _setup) factory.connect("bind", _bind) factory.connect("unbind", _unbind) list_view.set_factory(factory) list_view.connect( "activate", lambda lv, pos: _handle_select(lv.get_model().get_item(pos).channel) if lv.get_model().get_item(pos) else None, ) dialog._refs = [store, custom_filter, filter_model, selection, factory] update_status() dialog.present(window) meshy/src/views/chat_mixin.py000066400000000000000000000204621526212641700166360ustar00rootroot00000000000000# 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 # Item kinds shared by ChatItem and ChannelChatItem. _KIND_MESSAGE = 0 _KIND_DATE_DIVIDER = 2 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 _char_label Gtk.Label (character counter) _char_warn_icon Gtk.Image (metadata-risk hint) """ # ─── 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) # ─── List model helpers ────────────────────────────────── def _remove_orphaned_dividers(self, removed_index: int): """Drop dividers left stranded after deleting a message. Date and unread dividers exist only as items in ``_store`` and are not tracked anywhere, so deleting the message they introduce can leave them behind. Remove the contiguous divider run immediately preceding ``removed_index`` when no message remains under it. """ if removed_index < 0: return first = removed_index while first - 1 >= 0 and self._store.get_item(first - 1).kind != _KIND_MESSAGE: first -= 1 if first == removed_index: return n = self._store.get_n_items() j = removed_index while j < n: kind = self._store.get_item(j).kind if kind == _KIND_MESSAGE: return if kind == _KIND_DATE_DIVIDER: break j += 1 removed_count = removed_index - first self._store.splice(first, removed_count, []) if self._unread_pos >= 0: if first <= self._unread_pos < first + removed_count: self._unread_pos = -1 elif self._unread_pos >= first + removed_count: self._unread_pos -= removed_count # ─── 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.NONE, 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.NONE, 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) def _on_list_double_tap(self, gesture, n_press, x, y): if n_press == 2: self._on_list_right_click(gesture, n_press, x, y) # ─── Composer feedback ─────────────────────────────────── def _set_metadata_warning(self, active: bool, over_limit: bool): """Toggle the metadata-risk colouring and info icon next to the counter. The icon shows only while the message is still sendable but long enough that receivers may drop its repeat/path metadata. Once the hard limit is exceeded the counter turns red and the icon is hidden. """ show = active and not over_limit self._char_warn_icon.set_visible(show) if show: self._char_warn_icon.add_css_class("warning") self._char_label.add_css_class("warning") else: self._char_warn_icon.remove_css_class("warning") self._char_label.remove_css_class("warning") # ─── Emoji ─────────────────────────────────────────────── def _on_emoji_clicked(self, btn): chooser = Gtk.EmojiChooser() chooser.set_parent(btn) chooser.connect( "emoji-picked", lambda c, emoji: self._text_entry.get_buffer().insert_at_cursor(emoji), ) chooser.connect("closed", lambda c: c.unparent()) chooser.popup() meshy/src/views/chat_view.py000066400000000000000000001141141526212641700164620ustar00rootroot00000000000000# 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.chat_entry import ChatEntry # noqa: F401 – registers GType from meshy.models import Contact, ContactType, Message, MessageStatus from meshy.protocol import METADATA_WARN_COMPOSED_BYTES, max_dm_text_bytes from meshy.shared_uri import ( format_shared_channel, parse_shared_channel, parse_shared_contact, parse_shared_location, ) from meshy.text_utils import extract_emoji from meshy.views import _format_date_text from meshy.views.chat_helpers import ( CHANNEL_TYPE_ICONS, CONTACT_TYPE_ICONS, ensure_loc_map, open_location_external, share_channel_dialog, share_contact_dialog, share_location, share_location_map, show_location_map, ) from meshy.views.chat_mixin import ChatScrollMixin from meshy.views.label_with_widgets import apply_message_text _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") _char_warn_icon = Gtk.Template.Child("char_warn_icon") _placeholder_label = Gtk.Template.Child("placeholder_label") _emoji_btn = Gtk.Template.Child("emoji_btn") _share_btn = Gtk.Template.Child("share_btn") _text_entry = Gtk.Template.Child("text_entry") _text_scroll = Gtk.Template.Child("text_scroll") _chat_entry = Gtk.Template.Child("chat_entry") _send_btn = Gtk.Template.Child("send_btn") def do_grab_focus(self): if self._text_entry.get_editable(): return self._text_entry.grab_focus() return False 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)) attrs.insert(Pango.attr_font_features_new("tnum=1")) 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) double_tap = Gtk.GestureClick(button=1) double_tap.set_touch_only(True) double_tap.connect("pressed", self._on_list_double_tap) self._list_view.add_controller(double_tap) 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.get_buffer().connect("changed", self._on_text_changed) key_ctrl = Gtk.EventControllerKey() key_ctrl.connect("key-pressed", self._on_entry_key_pressed) self._text_entry.add_controller(key_ctrl) 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 My Contact"), "entry.share-my-contact") extra_menu.append(_("Share Contact"), "entry.share-contact") extra_menu.append(_("Share Channel"), "entry.share-channel") 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_my_a = Gio.SimpleAction.new("share-my-contact", None) share_my_a.connect("activate", self._on_share_my_contact) entry_group.add_action(share_my_a) share_a = Gio.SimpleAction.new("share-contact", None) share_a.connect("activate", self._on_share_contact) entry_group.add_action(share_a) share_ch_a = Gio.SimpleAction.new("share-channel", None) share_ch_a.connect("activate", self._on_share_channel) entry_group.add_action(share_ch_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) self._share_btn.set_menu_model(extra_menu) self._share_btn.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._sch_header_lbl = builder.get_object("sch_header_lbl") outer._sch_icon = builder.get_object("sch_icon") outer._sch_name_lbl = builder.get_object("sch_name_lbl") outer._sch_type_lbl = builder.get_object("sch_type_lbl") outer._sch_join_btn = builder.get_object("sch_join_btn") outer._sch_join_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 / shared channel sc = parse_shared_contact(msg.text) sch = parse_shared_channel(msg.text) if not sc else None 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.display_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, "meshy-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, ) elif sch: ch_name, secret_hex, ch_region = sch outer._content_stack.set_visible_child_name("channel") if msg.is_outgoing: outer._sch_header_lbl.set_label(_("You shared a channel:")) else: sender = self._contact.display_name if self._contact else "?" outer._sch_header_lbl.set_label( _("{name} shared a channel:").format(name=sender) ) # Determine channel type for icon from meshy.models import Channel as _Ch from meshy.protocol import hashtag_channel_psk as _htpsk if secret_hex == _Ch.PUBLIC_CHANNEL_PSK: ch_type = "Public" elif ch_name.startswith("#") and ( not secret_hex or secret_hex == _htpsk(ch_name).hex() ): ch_type = "Hashtag" else: ch_type = "Private" outer._sch_icon.set_from_icon_name( CHANNEL_TYPE_ICONS.get(ch_type, "meshy-system-users-symbolic") ) outer._sch_name_lbl.set_label(ch_name) if ch_region: outer._sch_type_lbl.set_label(_("Region: {region}").format(region=ch_region)) else: outer._sch_type_lbl.set_label(ch_type) if msg.is_outgoing: outer._sch_join_btn.set_visible(False) else: outer._sch_join_btn.set_visible(True) is_joined = self._is_channel_joined(ch_name, secret_hex) outer._sch_join_btn.set_sensitive(not is_joined) outer._sch_join_btn.set_label( _("Already joined") if is_joined else _("Join Channel") ) if outer._sch_join_handler_id is not None: outer._sch_join_btn.disconnect(outer._sch_join_handler_id) outer._sch_join_handler_id = outer._sch_join_btn.connect( "clicked", self._on_shared_channel_join, ch_name, secret_hex, ch_region, ) 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.display_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") apply_message_text( outer._text_lbl, msg.text, self._window.device_info.name or "", ) # 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._sch_join_handler_id is not None: outer._sch_join_btn.disconnect(outer._sch_join_handler_id) outer._sch_join_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): if self._window.add_contact(bytes.fromhex(pub_key_hex), contact_type, name): btn.set_sensitive(False) btn.set_label(_("Already added")) # ─── Shared channel helpers ───────────────────────────── def _is_channel_joined(self, ch_name, secret_hex): """Check if a channel with the given name is already joined.""" from meshy.protocol import hashtag_channel_psk for ch in self._window.channels: if ch.is_empty: continue if secret_hex: if ch.psk_hex == secret_hex: return True elif ch_name.startswith("#"): expected_psk = hashtag_channel_psk(ch_name) if ch.psk == expected_psk: return True return False def _on_shared_channel_join(self, btn, ch_name, secret_hex, region=None): from meshy.protocol import hashtag_channel_psk if secret_hex: secret = bytes.fromhex(secret_hex) elif ch_name.startswith("#"): secret = hashtag_channel_psk(ch_name) else: return self._window.add_channel_from_qr(ch_name, secret, region) btn.set_sensitive(False) btn.set_label(_("Already joined")) # ─── 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_my_contact(self, action, param): if not self._contact: return dev = self._window.device_info if not dev or not dev.public_key: return contact = self._contact text = f"<{dev.public_key}:1:{dev.name}>" self._window.send_message(contact, text) 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.display_name}>" self._window.send_message(contact, text) share_contact_dialog(self._window, contacts, _send) def _on_share_channel(self, action, param): if not self._contact: return channels = [ch for ch in self._window.channels if not ch.is_empty] contact = self._contact def _send(ch): text = format_shared_channel(ch.name, ch.psk_hex, ch.region) self._window.send_message(contact, text) share_channel_dialog(self._window, channels, _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 clear(self): """Release all message items from the store to free memory.""" self._store.remove_all() self._item_by_msg_id.clear() def set_contact(self, contact: Contact): if self._contact: if self._is_sticky: self._scroll_positions[self._contact.public_key_hex] = None else: 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_editable(not guest) self._send_btn.set_sensitive(not guest) self._emoji_btn.set_sensitive(not guest) self._share_btn.set_sensitive(not guest) if guest: self._placeholder_label.set_label(_("Read-only room")) else: self._placeholder_label.set_label(_("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 self._is_sticky = True 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 _MAX_INPUT_HEIGHT = 120 def _update_input_height(self): buf = self._text_entry.get_buffer() y, h = self._text_entry.get_line_yrange(buf.get_end_iter()) content = y + h + self._text_entry.get_top_margin() + self._text_entry.get_bottom_margin() self._text_scroll.set_size_request(-1, min(content, self._MAX_INPUT_HEIGHT)) if content > self._MAX_INPUT_HEIGHT: self._text_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) else: self._text_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) GLib.idle_add(self._reset_input_scroll) def _reset_input_scroll(self): self._text_scroll.get_vadjustment().set_value(0) return GLib.SOURCE_REMOVE def _on_text_changed(self, buffer): text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False) used = len(text.encode("utf-8")) limit = max_dm_text_bytes() self._placeholder_label.set_visible(used == 0) if used == 0: self._char_label.set_label("") self._send_btn.set_sensitive(True) self._char_label.remove_css_class("error") self._chat_entry.remove_css_class("error") self._set_metadata_warning(False, False) 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") self._chat_entry.add_css_class("error") else: self._char_label.remove_css_class("error") self._chat_entry.remove_css_class("error") self._set_metadata_warning(used >= METADATA_WARN_COMPOSED_BYTES, over) self._update_input_height() def _on_entry_key_pressed(self, ctrl, keyval, keycode, state): if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): if not (state & Gdk.ModifierType.SHIFT_MASK): self._on_send() return True return False def _on_send(self, *args): if not self._contact: return buf = self._text_entry.get_buffer() text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False).strip() if not text: return if len(text.encode("utf-8")) > max_dm_text_bytes(): return buf.set_text("") self._text_scroll.set_size_request(-1, -1) self._window.send_message(self._contact, text) def _on_send_with_override(self, route_override: str | None): if not self._contact: return buf = self._text_entry.get_buffer() text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False).strip() if not text: return if len(text.encode("utf-8")) > max_dm_text_bytes(): return buf.set_text("") self._text_scroll.set_size_request(-1, -1) 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._remove_orphaned_dividers(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/cli_console.py000066400000000000000000000220671526212641700170070ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared CLI console widget. Owns the output view, the command entry, completion and history. The transport is injected via ``configure(send, cancel, node_type, wait=...)``: in *wait* mode the console serializes commands, shows a busy state and starts a reply timeout; otherwise commands are fire-and-forget and replies are fed back by the host. Used by the repeater management tab and the shared CLI dialog. """ import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gdk, GLib, Gtk from meshy.cli_completion import apply_completion, complete from meshy.views.autocomplete import AutocompletePopover _MAX_LINES = 5000 _RESPONSE_TIMEOUT_MS = 20000 @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/cli-console.ui") class CliConsole(Gtk.Box): __gtype_name__ = "MeshyCliConsole" output_view = Gtk.Template.Child() entry = Gtk.Template.Child() send_btn = Gtk.Template.Child() def __init__(self, **kwargs): super().__init__(**kwargs) self._send = None self._cancel = None self._node_type = None self._wait = False self._buf = self.output_view.get_buffer() self._pending_token = None self._waiting = False self._timeout_id = None self._history: list[str] = [] self._history_pos = 0 self._accepting = False self._idle_placeholder = self.entry.get_placeholder_text() self._complete = AutocompletePopover(self.entry) self._complete.set_on_activate(self._apply_completion) self.entry.connect("activate", self._on_entry_activate) self.send_btn.connect("clicked", lambda *_: self._on_entry_activate(self.entry)) key = Gtk.EventControllerKey() key.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) key.connect("key-pressed", self._on_key_pressed) self.entry.add_controller(key) self.entry.connect("changed", self._on_entry_changed) # ─── Configuration ─────────────────────────────────────── def configure(self, send, cancel, node_type, wait=False): """Bind the console to a transport. ``send(command, on_reply) -> token`` delivers the command; in ``wait`` mode the console serializes commands, shows a busy state and starts a reply timeout, and calls ``cancel(token)`` if the reply never arrives. """ self.cancel_pending() self._send = send self._cancel = cancel self._node_type = node_type self._wait = wait # ─── Output ────────────────────────────────────────────── def append(self, text: str): end = self._buf.get_end_iter() self._buf.insert(end, text + "\n") line_count = self._buf.get_line_count() if line_count > _MAX_LINES: start = self._buf.get_start_iter() trim_end = self._buf.get_iter_at_line(line_count - _MAX_LINES) self._buf.delete(start, trim_end) GLib.idle_add(self._scroll_bottom) def clear(self): self._buf.set_text("") self.hide_completions() def _scroll_bottom(self): parent = self.output_view.get_parent() if parent is not None: adj = parent.get_vadjustment() adj.set_value(adj.get_upper()) return False # ─── Sending ───────────────────────────────────────────── def send(self, command: str): command = command.strip() if not command or self._send is None: return if self._wait and self._waiting: # wait for the previous reply (or timeout) return self.hide_completions() self.entry.set_text("") self.append("> " + command) if self._wait: self._waiting = True self._pending_token = self._send(command, self._on_response) self._set_busy(True) self._timeout_id = GLib.timeout_add(_RESPONSE_TIMEOUT_MS, self._on_timeout) else: self._send(command, None) def _on_entry_activate(self, entry): command = entry.get_text().strip() if not command: return self._history.append(command) self._history_pos = len(self._history) self.send(command) def _on_response(self, reply: str): self._clear_timeout() self._pending_token = None self._waiting = False self._set_busy(False) self.append(reply.rstrip("\n") if reply else _("(empty response)")) def _on_timeout(self): self.cancel_pending() self._set_busy(False) self.append(_("No response (timed out)")) return False def _clear_timeout(self): if self._timeout_id is not None: GLib.source_remove(self._timeout_id) self._timeout_id = None def _set_busy(self, busy: bool): self.entry.set_sensitive(not busy) self.send_btn.set_sensitive(not busy) if busy: self.entry.set_placeholder_text(_("Waiting for response...")) else: self.entry.set_placeholder_text(self._idle_placeholder) self.entry.grab_focus() def cancel_pending(self): """Drop the pending reply timer/token (e.g. on close or target change).""" self._clear_timeout() if self._waiting and self._cancel is not None: self._cancel(self._pending_token) self._pending_token = None self._waiting = False # ─── Completion ────────────────────────────────────────── def hide_completions(self): self._complete.hide() def _show_suggestions(self, suggestions): self._complete.set_items(suggestions, css_class="monospace") self._complete.show() def _on_entry_changed(self, _entry): if self._accepting: return text = self.entry.get_text() if not text.strip(): self.hide_completions() return suggestions = complete(text, self._node_type) if not suggestions or suggestions == [text.strip()]: self.hide_completions() return self._show_suggestions(suggestions) def _apply_completion(self, suggestion): self._accepting = True try: self.entry.set_text(apply_completion(self.entry.get_text(), suggestion)) self.entry.set_position(-1) finally: self._accepting = False self.hide_completions() self.entry.grab_focus() def _accept_selected(self): value = self._complete.selected_value() if value is None: value = self._complete.first_value() if value is None: return False self._apply_completion(value) return True # ─── Keyboard ──────────────────────────────────────────── def _on_key_pressed(self, _controller, keyval, _keycode, _state): visible = self._complete.is_visible() if keyval == Gdk.KEY_Escape: self.hide_completions() return visible if keyval == Gdk.KEY_Tab: if visible: return self._accept_selected() suggestions = complete(self.entry.get_text(), self._node_type) if not suggestions: return False if len(suggestions) == 1: self._apply_completion(suggestions[0]) return True self._show_suggestions(suggestions) self._complete.move_selection(1) return True if visible: if keyval == Gdk.KEY_Down: self._complete.move_selection(1) return True if keyval == Gdk.KEY_Up: self._complete.move_selection(-1) return True if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): value = self._complete.selected_value() if value is not None: self._apply_completion(value) return True if keyval == Gdk.KEY_Up and self._history: self._history_pos = max(0, self._history_pos - 1) self.entry.set_text(self._history[self._history_pos]) self.entry.set_position(-1) return True if keyval == Gdk.KEY_Down and self._history: self._history_pos = min(len(self._history), self._history_pos + 1) if self._history_pos == len(self._history): self.entry.set_text("") else: self.entry.set_text(self._history[self._history_pos]) self.entry.set_position(-1) return True return False meshy/src/views/cli_dialog.py000066400000000000000000000053701526212641700166020ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared CLI dialog: a header, quick commands and a ``CliConsole``. ``__init__`` takes injected ``send``/``cancel`` callables; the ``for_companion`` and ``for_device`` factories wire the known transports (remote companion and local device CLI). """ import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gtk from meshy.views.cli_console import CliConsole # noqa: F401 (registers MeshyCliConsole) @Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/cli-dialog.ui") class CliDialog(Adw.Dialog): __gtype_name__ = "MeshyCliDialog" console = Gtk.Template.Child() # Maps quick-button ids (from the .ui) to their CLI command. _QUICK_COMMANDS = { "quick_reboot": "reboot", "quick_shutdown": "shutdown", "quick_name": "get name", "quick_board": "board", "quick_radio": "get radio", "quick_ver": "ver", } def __init__(self, title, send, cancel, node_type, wait=False, intro=None, **kwargs): super().__init__(**kwargs) self.set_title(title) self.console.configure(send, cancel, node_type, wait=wait) if intro: self.console.append(intro) self.connect("closed", self._on_closed) @classmethod def for_companion(cls, window, contact): """Remote CLI console for a chat contact (fw >= 1.18).""" return cls( title=_("Companion CLI — %s") % contact.display_name, send=lambda command, on_reply: window.send_cli_with_prefix(contact, command, on_reply), cancel=lambda token: window.cancel_cli_prefixes([token]), node_type="companion", wait=True, intro=_( "Type a command and press Enter. The target companion must " "allow this contact for remote CLI." ), ) @classmethod def for_device(cls, window): """Local CLI console for the connected companion (fw >= 1.18).""" name = window.device_info.name or _("My Device") return cls( title=_("Device CLI — %s") % name, send=lambda command, on_reply: window.run_cli_command(command, on_reply), cancel=lambda _token: window.cancel_run_cli_command(), node_type="companion", wait=True, intro=_("Type a command and press Enter."), ) @Gtk.Template.Callback() def on_quick_clicked(self, button): command = self._QUICK_COMMANDS.get(button.get_buildable_id()) if command: self.console.send(command) def _on_closed(self, _dialog): self.console.cancel_pending() meshy/src/views/connection_view.py000066400000000000000000000621311526212641700177030ustar00rootroot00000000000000# 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.constants import SCHEMA_ID from meshy.gtk_utils import clear_children from meshy.host_integration import is_flatpak, run_host_command from meshy.platforms import is_linux from meshy.storage import Storage from meshy.views import show_alert @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("meshy-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("meshy-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("meshy-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("meshy-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("meshy-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("meshy-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: show_alert(self._window, _("USB Connection Failed"), error_msg) 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": self._window.copy_to_clipboard(command) 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: show_alert( self._window, _("Installation Failed"), _("Could not install udev rule:\n{}").format(result.stderr.strip()), ) except Exception as e: show_alert(self._window, _("Installation Failed"), str(e)) 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(SCHEMA_ID) 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("meshy-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("meshy-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) builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/tcp-change-address-content.ui" ) dialog = builder.get_object("change_address_dialog") 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) def _on_save(*_args): 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.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("save_btn").connect("clicked", _on_save) dialog.present(self._window) def _on_tcp_add_new(self, *args): """Show dialog to add a new TCP companion.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/tcp-add-content.ui" ) dialog = builder.get_object("tcp_add_dialog") host_entry = builder.get_object("host_entry") port_entry = builder.get_object("port_entry") def _on_connect(*_args): 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.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("connect_btn").connect("clicked", _on_connect) 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("meshy-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.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/passkey-dialog-content.ui" ) dialog = builder.get_object("passkey_dialog") pin_entry = builder.get_object("pin_entry") def _on_pair(*_args): try: passkey = int(pin_entry.get_text().strip()) reply_callback(passkey) except ValueError: reply_callback(0) dialog.close() def _on_cancel(*_args): reply_callback(0) dialog.close() builder.get_object("cancel_btn").connect("clicked", _on_cancel) builder.get_object("pair_btn").connect("clicked", _on_pair) dialog.present(self._window) meshy/src/views/contact_detail_dialog.py000066400000000000000000001001651526212641700210060ustar00rootroot00000000000000# 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, Gio, GLib, Gtk from meshy.i18n import format_localized, ngettext from meshy.models import ( CONTACT_FLAG_REMOTE_CLI, CONTACT_FLAG_TELE_BASE, CONTACT_FLAG_TELE_ENV, CONTACT_FLAG_TELE_LOC, Contact, ContactType, ) from meshy.time_format import format_trip_time from meshy.views import mark_property_rows _STAR_OFF = "meshy-star-symbolic" _STAR_HALF = "meshy-star-half-symbolic" _STAR_ON = "meshy-star-filled-symbolic" @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() share_btn = Gtk.Template.Child() favorite_btn = Gtk.Template.Child() info_group = Gtk.Template.Child() name_row = 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_btn = Gtk.Template.Child() companion_cli_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.display_name) self._populate(contact) self._add_conditional_widgets(contact) mark_property_rows(self.info_group) self._connect_signals(contact) def _populate(self, contact: Contact): self._update_name_row(contact) self._update_favorite_icon(contact) 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) 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="meshy-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="meshy-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) # Permission switches (only for CHAT contacts) self.tele_group.set_visible(False) self.companion_cli_btn.set_visible(False) if contact.type == ContactType.CHAT: self.tele_group.set_visible(True) 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, ), ): self._add_permission_switch(contact, title, subtitle, flag) if self._window.device_info.supports_cli: self.companion_cli_btn.set_visible(True) self._add_permission_switch( contact, _("Allow Remote CLI"), _("Let this contact run CLI commands on this companion"), CONTACT_FLAG_REMOTE_CLI, ) # Actions group (repeaters/rooms/sensors) actions_count = 0 actions_group = Adw.PreferencesGroup(title=_("Actions")) if contact.type in (ContactType.ROOM, ContactType.REPEATER, ContactType.SENSOR): mgmt_titles = { ContactType.ROOM: _("Room Management"), ContactType.REPEATER: _("Repeater Management"), ContactType.SENSOR: _("Sensor Management"), } mgmt_row = Adw.ActionRow( title=mgmt_titles[contact.type], subtitle=_("Login and access CLI, settings, and regions"), ) mgmt_btn = Gtk.Button( icon_name="meshy-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, ContactType.SENSOR): # Ping ping_row = Adw.ActionRow( title=_("Ping"), subtitle=_("Measure round-trip time to this node"), ) ping_btn = Gtk.Button( icon_name="meshy-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="meshy-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="meshy-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 _add_permission_switch(self, contact: Contact, title: str, subtitle: str, flag: int): """Add a contact-flag toggle to the Permissions group.""" 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) def _connect_signals(self, contact: Contact): # Info signals self.favorite_btn.connect("clicked", self._on_favorite_clicked, contact) fav_motion = Gtk.EventControllerMotion() fav_motion.connect("enter", lambda *_: self._on_favorite_hover(contact, True)) fav_motion.connect("leave", lambda *_: self._on_favorite_hover(contact, False)) self.favorite_btn.add_controller(fav_motion) self._setup_share_menu(contact) self.copy_btn.connect("clicked", self._on_copy_key, contact) self.name_row.connect("activated", self._on_edit_custom_name, 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), ) # Remote companion management self.companion_cli_btn.connect( "clicked", lambda *_: self._open_companion_cli(contact), ) # Danger zone self.remove_btn.connect("clicked", self._on_remove, contact) # Initial state self._update_flood_dependents() # --- Info callbacks --- def _update_favorite_icon(self, contact): self.favorite_btn.set_icon_name(_STAR_ON if contact.is_favorite else _STAR_OFF) self.favorite_btn.set_tooltip_text( _("Remove from favorites") if contact.is_favorite else _("Add to favorites") ) def _on_favorite_hover(self, contact, entered): # Always hint with the half star on hover, so an already-favourited # contact suggests that clicking will remove it again. if entered: self.favorite_btn.set_icon_name(_STAR_HALF) else: self._update_favorite_icon(contact) def _on_favorite_clicked(self, _btn, contact): from meshy.models import CONTACT_FLAG_FAVORITE if contact.flags & CONTACT_FLAG_FAVORITE: 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() self._update_favorite_icon(contact) def _setup_share_menu(self, contact): """Build the Share menu with the actions for this contact.""" menu = Gio.Menu() menu.append(_("Zero Hop Advert"), "contact.share-zero-hop") menu.append(_("Copy Link"), "contact.copy-link") menu.append(_("Show QR Code"), "contact.show-qr") self.share_btn.set_menu_model(menu) action_group = Gio.SimpleActionGroup() handlers = { "share-zero-hop": self._window.share_contact_zero_hop, "copy-link": self._on_copy_link, "show-qr": self._on_show_qr, } for name, handler in handlers.items(): action = Gio.SimpleAction.new(name, None) action.connect("activate", lambda _a, _p, c=contact, h=handler: h(c)) action_group.add_action(action) self.insert_action_group("contact", action_group) def _on_copy_link(self, contact): from meshy.views.chat_helpers import format_contact_uri uri = format_contact_uri(contact.name, contact.public_key_hex, contact.type) self._window.copy_to_clipboard(uri) self._window.show_toast(_("Link copied")) def _on_show_qr(self, contact): from meshy.views.chat_helpers import show_contact_qr_dialog show_contact_qr_dialog( self._window, contact.display_name, contact.public_key_hex, int(contact.type), title=_("Contact QR Code"), parent=self, ) def _on_copy_key(self, _btn, contact): self._window.copy_to_clipboard(contact.public_key_hex) 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 _update_name_row(self, contact: Contact): """Show the custom name if set, otherwise the original advert name.""" if contact.custom_name: self.name_row.set_title(_("Custom Name")) self.name_row.set_subtitle(contact.custom_name) else: self.name_row.set_title(_("Original Name")) self.name_row.set_subtitle(contact.name) def _on_edit_custom_name(self, _row, contact): """Edit the local alias; the device keeps the original advert name.""" builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/custom-name-dialog.ui" ) dialog = builder.get_object("custom_name_dialog") original_row = builder.get_object("original_name_row") entry = builder.get_object("custom_name_entry") original_row.set_subtitle(contact.name) mark_property_rows(dialog) entry.set_text(contact.custom_name) def _save(*_args): contact.custom_name = entry.get_text().strip() self._window.storage.save_contact(contact) self._window.update_contacts_from_storage() self._window.refresh_chat_header() self._update_name_row(contact) self.set_title(contact.display_name) dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("save_btn").connect("clicked", _save) entry.connect("entry-activated", _save) dialog.present(self) def _focus_entry(): # AdwEntryRow selects all text on plain grab_focus; use the # non-selecting variant and put the cursor at the end. entry.grab_focus_without_selecting() entry.set_position(-1) return False GLib.idle_add(_focus_entry) def _on_apply(self, _widget, contact): 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=contact.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=contact.name) except ValueError: pass else: self._window.reset_contact_path(contact) 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.display_name) if records: map_btn = Gtk.Button(icon_name="meshy-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 = [ format_localized(_("Score: %.2f"), path_svc.score_dict(r)), format_localized(_("Weight: %.1f"), r["route_weight"]), f"{r['success_count']}✓ {r['failure_count']}✗", ngettext("%d hop", "%d hops", r["hop_count"]) % 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="meshy-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.display_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 = "meshy-network-wireless-symbolic" name = device.name or _("My Device") elif hop_contact is None: r, g, b = _UNKNOWN_COLOR icon_name = "meshy-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, "meshy-avatar-default-symbolic") name = hop_contact.display_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 = ngettext("%d hop", "%d hops", rec["hop_count"]) % 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): self.close() self._window.remove_contact_with_undo(contact) def _open_companion_cli(self, contact): from meshy.views.cli_dialog import CliDialog CliDialog.for_companion(self._window, contact).present(self) meshy/src/views/contacts_view.py000066400000000000000000001166571526212641700173770ustar00rootroot00000000000000# 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 from meshy.constants import SCHEMA_ID from meshy.geo import format_distance from meshy.gtk_utils import clear_children from meshy.i18n import format_number, ngettext from meshy.models import Contact, ContactType from meshy.protocol import build_sync_next_message, decode_path_len_byte from meshy.time_format import format_trip_time from meshy.views import ( _add_hover_swap, setup_contact_picker_filter_sort, show_alert, show_contact_picker, show_login_dialog, ) 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.display_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_counts: dict[str, int] = {} self._unread_dots: dict[str, Gtk.Label] = {} settings = Gio.Settings(schema_id=SCHEMA_ID) saved_filter = settings.get_string("contact-filter") self._current_filter = ( saved_filter if saved_filter in ( self.FILTER_ALL, self.FILTER_FAVORITES, self.FILTER_USERS, self.FILTER_REPEATERS, self.FILTER_ROOMS, self.FILTER_SENSORS, ) else self.FILTER_ALL ) 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.display_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) settings = Gio.Settings(schema_id=SCHEMA_ID) settings.set_string("contact-filter", self._current_filter) 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=SCHEMA_ID) 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=SCHEMA_ID) 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: show_alert( self._window, _("Discover Contacts"), _( "No new contacts have been heard yet. " "Leave the app running and contacts will appear " "as their adverts are received." ), ) 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.geo import haversine_distance dist = haversine_distance(self_lat, self_lon, contact.latitude, contact.longitude) subtitle += f" · {format_distance(dist)}" if contact.last_seen: d = contact.last_seen gdt = GLib.DateTime.new_local(d.year, d.month, d.day, d.hour, d.minute, d.second) subtitle += " · " + gdt.format(_("%x")) 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"), ) setup_contact_picker_filter_sort( ns, sort_options=[ ("name", _("Name")), ("last-seen", _("Last Seen")), ("arrival", _("Arrival Time")), ], default_sort="last-seen", self_lat=self_lat, self_lon=self_lon, action_prefix="discover", ) ns.update_status() ns.present() def _on_add_manual(self, *args): builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/add-contact-content.ui" ) dialog = builder.get_object("add_contact_dialog") 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") def _on_add(*_args): 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.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("add_btn").connect("clicked", _on_add) 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://"): show_alert( self._window, _("Import Failed"), _("No meshcore:// link found in clipboard."), ) 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 meshy.protocol import parse_meshcore_uri from meshy.views import show_confirm_contact_dialog result = parse_meshcore_uri(data) if result["kind"] is None: self._show_import_error(_("Could not parse QR code data:\n{}").format(data[:120])) return if result["kind"] == "channel": from meshy.views import show_confirm_channel_dialog show_confirm_channel_dialog( self._window, result["name"], result["secret"], result.get("region_scope") ) return # kind == "contact" pub_key_hex = result["pub_key_hex"] name = result["name"] contact_type = result["type"] raw_frame = result["raw_frame"] if self._window.is_contact_known(pub_key_hex): self._show_import_error(_("This contact is already in your list.")) return if raw_frame: orig_name = name orig_type = contact_type def _on_add(pk, ct, n): self._window.import_contact(raw_frame) if n != orig_name or ct != orig_type: self._window.add_contact(pk, ct, n) show_confirm_contact_dialog( self._window, pub_key_hex, name=name, contact_type=contact_type, on_add=_on_add, ) else: show_confirm_contact_dialog( self._window, pub_key_hex, name=name, contact_type=contact_type, ) def _show_import_error(self, message): show_alert(self._window, _("Import Failed"), message) 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.display_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: 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._window.show_sensor(contact) else: self._window.show_chat(contact) def select_contact(self, public_key_hex: str) -> bool: """Select and open the contact with the given public key. Args: public_key_hex: Public key of the contact to select Returns: True if the contact was found and selected """ row = self._contact_rows.get(public_key_hex) if not row: return False self._list_box.select_row(row) self._on_row_selected(self._list_box, row) return True def _show_room_login(self, contact: Contact, repeater: bool = False): """Show a login dialog for a room/repeater contact.""" self._login_dialog_open = True def _on_success(login_timestamp, is_admin): self._window.send_frame(build_sync_next_message()) if repeater: self._window.show_repeater(contact, login_timestamp, is_admin) else: self._window.show_chat(contact) show_login_dialog( self._window, contact, on_success=_on_success, on_dialog_closed=lambda: setattr(self, "_login_dialog_open", False), ) 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.display_name)) row.set_subtitle(self._contact_subtitle(contact)) row._contact = contact count = self._unread_counts.get(key, 0) badge = self._unread_dots.get(key) if badge: badge.set_label(str(count) if count else "") badge.set_visible(count > 0) 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() @staticmethod def _contact_subtitle(contact: Contact) -> str: label = contact.path_label if contact.is_favorite: label = f"★ · {label}" return label def _create_contact_row(self, contact: Contact) -> Gtk.ListBoxRow: row = Adw.ActionRow( title=GLib.markup_escape_text(contact.display_name), subtitle=self._contact_subtitle(contact), ) row._contact = contact icon_name = { ContactType.CHAT: "meshy-avatar-default-symbolic", ContactType.REPEATER: "meshy-repeater-symbolic", ContactType.ROOM: "meshy-user-home-symbolic", ContactType.SENSOR: "meshy-weather-clear-symbolic", }.get(contact.type, "meshy-avatar-default-symbolic") row.add_prefix(Gtk.Image.new_from_icon_name(icon_name)) # 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="meshy-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_counts[public_key_hex] = self._unread_counts.get(public_key_hex, 0) + 1 else: 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 self._unread_counts.get(r._contact.public_key_hex, 0) > 0] 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, opl_hlen = decode_path_len_byte(opl_byte) 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, ipl_hlen = decode_path_len_byte(ipl_byte) 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.display_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.display_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 | None = None): """Request and display telemetry from a contact. When ``row`` is provided its subtitle reports progress; otherwise the status is surfaced through a toast. """ from meshy.protocol import parse_cayenne_lpp def _status(message: str): if row is not None: row.set_subtitle(message) else: self._window.show_toast(message) if row is not None: row.set_subtitle(_("Requesting...")) timeout_id = [None] def on_timeout(): if row is None: self._window.show_toast(_("No telemetry response (timed out)")) else: 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: _status(_("No telemetry data")) return entries = parse_cayenne_lpp(lpp_data) if not entries: _status(_("No telemetry data")) return self._show_telemetry_dialog(contact, entries) if row is not None: 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.display_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, "meshy-avatar-default-symbolic") marker = MapView.create_marker(r, g, b, icon_name, contact.display_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, "meshy-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 MapView hops = MapView.build_hop_list( self._window.device_info, hop_contacts, hop_prefixes=hop_prefixes, target_contact=contact, ) if hops is None: return MapView.open_hop_path_dialog( self._window, hops, self._window.device_info, title=_("Path to {}").format(contact.display_name), on_detail=self._show_contact_detail, ) 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.display_name ) r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5)) icon_name = _MARKER_ICONS.get(contact.type, "meshy-avatar-default-symbolic") marker = MapView.create_marker( r, g, b, icon_name, contact.display_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.py000066400000000000000000001477411526212641700170160ustar00rootroot00000000000000# 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, Gio, GLib, Gtk, Pango from meshy.geo import format_distance from meshy.gtk_utils import clear_children from meshy.i18n import format_number from meshy.models import DeviceInfo, parse_firmware_version, snr_to_icon from meshy.time_format import format_trip_time from meshy.views import mark_property_rows 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" _tab_stack = Gtk.Template.Child("tab_stack") _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_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._cli_row = None 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 # Tab name → stack page name mapping for section navigation self._section_pages = [ ("Device Info", "info"), ("Radio Configuration", "radio"), ("Statistics", "stats"), ] 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) # Render the rows as read-only property rows: the title (label, e.g. # "Battery") is shown small and dimmed, the subtitle (value, e.g. "90%") # is emphasized. for group in ( self._status_group, self._info_group, self._radio_group, self._stats_group, ): mark_property_rows(group) # 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()) self._tab_stack.connect("notify::visible-child", self._on_tab_changed) 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 _on_tab_changed(self, stack, _pspec): if stack.get_visible_child_name() == "stats" and self._window.is_connected: self._window.refresh_stats() 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) builder.get_object("cli_btn").connect("clicked", self._on_device_cli) self._cli_row = builder.get_object("cli_row") self._cli_row.set_visible(self._window.device_info.supports_cli) 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, _page in self._section_pages] def scroll_to_section(self, section_name: str): for name, page_name in self._section_pages: if name == section_name: self._tab_stack.set_visible_child_name(page_name) break def _on_copy_public_key(self, *args): key = self._key_row.get_subtitle() 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.""" from meshy.views.chat_helpers import show_contact_qr_dialog info = self._window.device_info show_contact_qr_dialog( self._window, info.name or _("My Device"), info.public_key, 1, ) 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." ), prefer_wide_layout=True, ) dialog.add_response("cancel", _("Cancel")) dialog.add_response("reset", _("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 _on_device_cli(self, *args): """Open the local CLI console for the connected companion.""" from meshy.views.cli_dialog import CliDialog CliDialog.for_device(self._window).present(self._window) def update_device_info(self, info: DeviceInfo): self._name_row.set_subtitle(info.name or "--") self._board_row.set_subtitle(info.model or "--") self._fw_row.set_subtitle( f"{info.ver} ({info.fw_build})" if info.ver and info.fw_build else info.ver or "--" ) if self._cli_row is not None: self._cli_row.set_visible(info.supports_cli) if info.ver: self._check_firmware_update(info.ver) if info.public_key: self._key_row.set_subtitle(info.public_key) # Battery if info.battery_mv > 0: pct = info.battery_percent self._batt_row.set_subtitle(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_subtitle( 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_subtitle(f"{format_number(info.radio_freq, 3)} MHz") if info.radio_bw: self._bw_row.set_subtitle(f"{format_number(info.radio_bw)} kHz") if info.radio_sf: self._sf_row.set_subtitle(f"SF{info.radio_sf}") if info.radio_cr: self._cr_row.set_subtitle(f"4/{info.radio_cr}") if info.tx_power: self._tx_row.set_subtitle(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_subtitle(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_subtitle(self._format_location(lat, lon)) else: self._location_lat = None self._location_lon = None self._location_row.set_subtitle(_("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_title(_("Location (manually set)") if has_pos else _("Location")) elif self._gps_enabled: self._location_row.set_title( _("Location (GPS)") if has_pos else _("Location (GPS on, no fix)") ) else: self._location_row.set_title( _("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_subtitle(_("Not connected")) return btn.set_sensitive(False) self._telemetry_row.set_subtitle(_("Requesting…")) timeout_id = [None] def on_timeout(): btn.set_sensitive(True) self._telemetry_row.set_subtitle(_("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_subtitle(_("No telemetry data")) return entries = parse_cayenne_lpp(lpp_data) if not entries: self._telemetry_row.set_subtitle(_("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_subtitle(", ".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, "meshy-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_subtitle(" ".join(parts)) self._queue_row.set_subtitle(_("{} messages").format(core.get("queue_len", 0))) radio = stats.get(STATS_TYPE_RADIO, {}) if radio: self._noise_row.set_subtitle(f'{radio.get("noise_floor", 0)} dBm') self._rssi_row.set_subtitle(f'{radio.get("last_rssi", 0)} dBm') self._snr_row.set_subtitle(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_subtitle(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_subtitle(sub) self._pkt_flood_row.set_subtitle( f'TX: {pkts.get("flood_tx", 0)} / RX: {pkts.get("flood_rx", 0)}' ) self._pkt_direct_row.set_subtitle( 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_subtitle("{} \u00b7 {}".format(label, _("Connected"))) else: self._status_row.set_subtitle(_("Connected")) self._status_icon.set_from_icon_name("meshy-charge-symbolic") else: self._status_row.set_subtitle(_("Disconnected")) self._status_icon.set_from_icon_name("meshy-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.display_name), subtitle=hop_hex, activatable=True, ) row.add_prefix(Gtk.Image(icon_name="meshy-avatar-default-symbolic")) row._hop_hex = hop_hex row._search_text = contact.display_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.display_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) builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) 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 MapView hops = MapView.build_hop_list( self._window.device_info, hop_contacts, hop_prefixes=hop_prefixes, snr_values=snr_values, ) if hops is None: return MapView.open_hop_path_dialog( self._window, hops, self._window.device_info, title=_("Trace Path"), labels="snr", on_detail=self._window.show_contact_detail, ) 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: "meshy-avatar-default-symbolic", ContactType.REPEATER: "meshy-repeater-symbolic", ContactType.ROOM: "meshy-user-home-symbolic", ContactType.SENSOR: "meshy-weather-clear-symbolic", } type_labels = { ContactType.CHAT: _("Chat"), ContactType.REPEATER: _("Repeater"), ContactType.ROOM: _("Room Server"), ContactType.SENSOR: _("Sensor"), } # Track rows by pub_key_hex for query callbacks rows_by_prefix = {} # pub_key_hex → 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.geo import haversine_distance dist = haversine_distance(self_lat, self_lon, contact.latitude, contact.longitude) return format_distance(dist) def _add_node_button(row): """Create an Add to Contacts button for unknown nodes.""" btn = Gtk.Button(icon_name="meshy-list-add-symbolic", valign=Gtk.Align.CENTER) btn.set_tooltip_text(_("Add to Contacts")) def on_add(_btn): info = row._node_info if self._window.add_contact(info["pub_key"], info["type"], info["name"]): _btn.set_sensitive(False) _btn.set_icon_name("meshy-object-select-symbolic") self._window.show_toast(_("Added {}").format(info["name"])) btn.connect("clicked", on_add) row.add_suffix(btn) def _make_contact_obj(pub_key_hex): """Get or create a minimal contact-like object for anon requests.""" from types import SimpleNamespace contact_obj = self._window.find_contact_by_key_prefix(pub_key_hex) if not contact_obj: contact_obj = SimpleNamespace( public_key=bytes.fromhex(pub_key_hex), public_key_hex=pub_key_hex, path_length=0, path=b"", path_hash_size=1, ) return contact_obj def _add_query_button(row, pub_key_hex, node_type): """Add a details query button with popup menu for repeaters/rooms.""" menu = Gio.Menu() item_regions = Gio.MenuItem.new(_("Query Regions"), None) item_regions.set_action_and_target_value( "discover.query-regions", GLib.Variant.new_string(pub_key_hex) ) menu.append_item(item_regions) if node_type == ContactType.REPEATER: item_owner = Gio.MenuItem.new(_("Query Name & Owner"), None) item_owner.set_action_and_target_value( "discover.query-owner", GLib.Variant.new_string(pub_key_hex) ) menu.append_item(item_owner) popover = Gtk.PopoverMenu(menu_model=menu) btn = Gtk.MenuButton( icon_name="meshy-preferences-system-details-symbolic", valign=Gtk.Align.CENTER, popover=popover, ) btn.set_tooltip_text(_("Query Details")) row.add_suffix(btn) def _on_query_regions(action, param, pub_key_hex): """Handle region query from popup menu.""" row = rows_by_prefix.get(pub_key_hex) if not row: return contact_obj = _make_contact_obj(pub_key_hex) def _on_regions(text): log.debug("Discover: regions response for %s: %r", pub_key_hex[:16], text) if not text: self._window.show_toast(_("No region response")) return regions = [r.strip() for r in text.replace("\n", ",").split(",") if r.strip()] if not regions: return region_str = ", ".join(regions) cur = row.get_subtitle() or "" GLib.idle_add( lambda: row.set_subtitle(f"{cur}\n{_('Regions')}: {region_str}") or False ) self._window.request_regions(contact_obj, _on_regions, use_path=False) def _on_query_owner(action, param, pub_key_hex): """Handle owner query from popup menu.""" row = rows_by_prefix.get(pub_key_hex) if not row: return contact_obj = _make_contact_obj(pub_key_hex) def _on_owner(text): log.debug("Discover: owner response for %s: %r", pub_key_hex[:16], text) if not text: self._window.show_toast(_("No owner response")) return # Firmware sends "node_name\nowner_info". # owner_info itself may contain \n (set via CLI with | separator). parts = text.split("\n", 1) node_name = parts[0].strip() owner_info = parts[1].strip() if len(parts) > 1 else "" # Update row title if currently showing hex prefix (unknown node). if node_name and row.get_title().startswith("0x"): GLib.idle_add(lambda: row.set_title(node_name) or False) if not owner_info: self._window.show_toast(_("No owner info set")) return # Replace internal newlines with comma for single-line display owner_str = ", ".join( line.strip() for line in owner_info.split("\n") if line.strip() ) cur = row.get_subtitle() or "" GLib.idle_add( lambda: row.set_subtitle(f"{cur}\n{_('Owner')}: {owner_str}") or False ) self._window.request_owner(contact_obj, _on_owner, use_path=False) 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.display_name label = f"{contact.type_label} · {snr_str}" icon = type_icons.get(contact.type, "meshy-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 = "{} · {}".format(type_labels.get(node_type, _("Node")), snr_str) icon = type_icons.get(node_type, "meshy-repeater-symbolic") log.debug("Discover resp: %s not found", pub_key_hex[:16]) dist = _distance_str(contact) if dist: label += f" · {dist}" row = Adw.ActionRow(title=name, subtitle=label) row.set_subtitle_lines(0) 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) # Query button after add button — rightmost position, aligned across rows. # Uses node_type from discover response — contact.type may differ. if node_type in (ContactType.REPEATER, ContactType.ROOM): _add_query_button(row, pub_key_hex, node_type) rows_by_prefix[pub_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() 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) # Action group for per-row query menu items action_group = Gio.SimpleActionGroup() regions_action = Gio.SimpleAction.new("query-regions", GLib.VariantType.new("s")) regions_action.connect( "activate", lambda a, p: _on_query_regions(a, p, p.get_string()), ) action_group.add_action(regions_action) owner_action = Gio.SimpleAction.new("query-owner", GLib.VariantType.new("s")) owner_action.connect( "activate", lambda a, p: _on_query_owner(a, p, p.get_string()), ) action_group.add_action(owner_action) dialog.insert_action_group("discover", action_group) # Start scanning — send discover req twice (0s and 10s) for better coverage self._window.send_discover_req(on_discover_response) GLib.timeout_add_seconds( 10, lambda: ( self._window.send_discover_req(on_discover_response) if seconds_left[0] > 0 else None ) 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.spreading_factor 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 ────────────────────────────── 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 = parse_firmware_version(current_ver) latest = parse_firmware_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/dialogs.py000066400000000000000000000177141526212641700161430ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shared dialogs: simple alerts, room login, and confirm-import dialogs.""" import gettext import gi gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") from gi.repository import Adw, GLib, Gtk def _(msg): return gettext.dgettext("meshy", msg) def show_alert(parent, heading, body): """Show a simple informational alert dialog with a single OK button. ``parent`` is the widget/window to anchor the dialog to. """ dialog = Adw.AlertDialog(heading=heading, body=body) dialog.add_response("ok", _("OK")) dialog.set_default_response("ok") dialog.set_close_response("ok") dialog.present(parent) return dialog def show_login_dialog(window, contact, *, on_success, on_dialog_closed=None): """Show the admin login dialog for a room/repeater/sensor contact. *on_success(login_timestamp, is_admin)* is called after a successful login. *on_dialog_closed()* is called whenever the dialog is dismissed. """ 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.display_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") if window.storage: saved = 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: if window.storage: if save_switch.get_active(): window.storage.save_room_password( contact.public_key_hex, password_entry.get_text() ) else: window.storage.remove_room_password(contact.public_key_hex) window.mark_room_logged_in(contact.public_key_hex, acl_perms) dialog.close() on_success(login_timestamp, is_admin) 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")) 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): password = password_entry.get_text() 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) 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_closed(d): window.stop_login() if timeout_id[0]: GLib.source_remove(timeout_id[0]) if on_dialog_closed: on_dialog_closed() dialog.connect("closed", _on_closed) dialog.present(window) # ── Confirm-contact dialog (shared by clipboard import & QR scan) ─── _TYPE_LABELS = [_("Chat"), _("Repeater"), _("Room Server"), _("Sensor")] # Maps contact_type int (1-4) to combo index (0-3) _TYPE_TO_INDEX = {1: 0, 2: 1, 3: 2, 4: 3} def show_confirm_contact_dialog( window, pub_key_hex: str, *, name: str = "", contact_type: int = 1, on_add=None, ): """Present a confirmation dialog before adding a contact. Args: window: Parent window (MeshyWindow). pub_key_hex: 64-char hex public key string. name: Pre-filled contact name (empty = user must type one). contact_type: 1=Chat, 2=Repeater, 3=Room, 4=Sensor. on_add: Optional callback(pub_key_bytes, contact_type_int, name_str) called instead of the default window.add_contact(). """ builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/confirm-contact-dialog.ui" ) dialog = builder.get_object("confirm_contact_dialog") name_row = builder.get_object("name_row") type_row = builder.get_object("type_row") key_row = builder.get_object("key_row") add_btn = builder.get_object("add_btn") copy_btn = builder.get_object("copy_key_btn") # Populate type combo type_list = Gtk.StringList() for label in _TYPE_LABELS: type_list.append(label) type_row.set_model(type_list) type_row.set_selected(_TYPE_TO_INDEX.get(contact_type, 0)) # Pre-fill name if name: name_row.set_text(name) # Show truncated public key key_row.set_title(f"{pub_key_hex[:16]}...{pub_key_hex[-16:]}") # Enable Add only when name is non-empty def _update_add_sensitivity(*_args): add_btn.set_sensitive(bool(name_row.get_text().strip())) name_row.connect("changed", _update_add_sensitivity) _update_add_sensitivity() # initial state # Copy key to clipboard def _on_copy(*_args): clipboard = window.get_clipboard() clipboard.set(pub_key_hex) window.show_toast(_("Public key copied")) copy_btn.connect("clicked", _on_copy) # Add action def _on_add(*_args): contact_name = name_row.get_text().strip() if not contact_name: return ct = type_row.get_selected() + 1 # combo index 0-3 → type 1-4 pub_key = bytes.fromhex(pub_key_hex) if on_add: on_add(pub_key, ct, contact_name) else: window.add_contact(pub_key, ct, contact_name) dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) add_btn.connect("clicked", _on_add) dialog.present(window) # ── Confirm-channel dialog (shared by clipboard import & QR scan) ─── def show_confirm_channel_dialog( window, name: str, secret: bytes, region: str | None = None, *, on_add=None ): """Present a confirmation dialog before adding a channel. Args: window: Parent window (MeshyWindow). name: Channel name. secret: 16-byte channel PSK. region: Optional region name shared via ``region_scope``; shown and applied to the channel when added. on_add: Optional callback(name, secret, region) called instead of the default window.add_channel_from_qr(). """ builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/confirm-channel-dialog.ui" ) dialog = builder.get_object("confirm_channel_dialog") name_row = builder.get_object("name_row") type_row = builder.get_object("type_row") region_row = builder.get_object("region_row") name_row.set_title(name or _("Unnamed")) ch_type = _("Hashtag") if name.startswith("#") else _("Private") type_row.set_title(ch_type) if region: region_row.set_title(region) region_row.set_visible(True) def _on_add_clicked(*_args): if on_add: on_add(name, secret, region) else: window.add_channel_from_qr(name, secret, region) dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("add_btn").connect("clicked", _on_add_clicked) dialog.present(window) meshy/src/views/label_with_widgets.py000066400000000000000000000232131526212641700203500ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """GtkLabel wrapper that can host inline child widgets (mention pills). Mirrors Fractal's ``LabelWithWidgets``: the label text uses the Unicode Object Replacement Character U+FFFC as a placeholder for each inline widget. Pango ``AttrShape`` attributes reserve the right amount of space and the widgets are allocated at the shape run positions during layout. Height-for-width wrapping works correctly because layout is handled by a custom ``Gtk.LayoutManager`` subclass (GTK4 does **not** forward ``for_size`` to ``Gtk.Widget.do_measure`` overrides in Python — only layout managers receive the real ``for_size``). """ import gi gi.require_version("Gtk", "4.0") gi.require_version("Pango", "1.0") from gi.repository import Gdk, GObject, Gtk, Pango _PLACEHOLDER = "\ufffc" # U+FFFC is 3 bytes in UTF-8 (EF BF BC). _PLACEHOLDER_UTF8_LEN = len(_PLACEHOLDER.encode("utf-8")) # ── Layout manager ────────────────────────────────────────── class _LabelWithWidgetsLayout(Gtk.LayoutManager): """Delegates measure to the inner GtkLabel and positions pill overlays.""" __gtype_name__ = "MeshyLabelWithWidgetsLayout" def do_get_request_mode(self, widget): return widget._label.get_request_mode() def do_measure(self, widget, orientation, for_size): widget._ensure_shapes() return widget._label.measure(orientation, for_size) def do_allocate(self, widget, width, height, baseline): widget._label.allocate(width, height, baseline, None) widget._allocate_pills() # ── Widget ─────────────────────────────────────────────────── class LabelWithWidgets(Gtk.Widget): """A thin Gtk.Widget that wraps a GtkLabel and overlays child widgets.""" __gtype_name__ = "MeshyLabelWithWidgets" selectable = GObject.Property(type=bool, default=False) def __init__(self, **kwargs): super().__init__(**kwargs) self.set_layout_manager(_LabelWithWidgetsLayout()) self._label = Gtk.Label() self._label.set_parent(self) self._label.set_wrap(True) self._label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) self._label.set_xalign(0.0) self._label.set_max_width_chars(40) self._pills: list[Gtk.Widget] = [] self._pill_sizes: list[tuple[int, int]] = [] # UTF-8 byte offsets of each U+FFFC in the parsed label text. self._offsets: list[int] = [] self._shapes_dirty = True @selectable.getter def selectable(self) -> bool: return self._label.get_selectable() @selectable.setter def selectable(self, value: bool) -> None: self._label.set_selectable(value) def do_dispose(self): self._label.unparent() for w in self._pills: w.unparent() self._pills.clear() super().do_dispose() # ── Public API ─────────────────────────────────────────── def set_markup_and_widgets(self, markup: str, widgets: list[Gtk.Widget]) -> None: """Set label markup (containing U+FFFC placeholders) and pills.""" for w in self._pills: w.unparent() self._pills = list(widgets) for w in self._pills: w.set_parent(self) self._label.set_markup(markup) # Discover placeholder byte offsets in the *parsed* text. text = self._label.get_text() self._offsets = [] pos = 0 for _ in self._pills: idx = text.find(_PLACEHOLDER, pos) if idx == -1: break self._offsets.append(len(text[:idx].encode("utf-8"))) pos = idx + 1 self._shapes_dirty = True self.queue_resize() def set_markup(self, markup: str) -> None: """Set plain markup with no inline widgets.""" self.set_markup_and_widgets(markup, []) # ── Layout internals (called by _LabelWithWidgetsLayout) ─ def _ensure_shapes(self) -> None: """(Re)compute Pango AttrShape sized to each pill widget.""" if not self._shapes_dirty: return self._shapes_dirty = False if not self._pills or not self._offsets: self._pill_sizes = [] self._label.set_attributes(None) return attrs = Pango.AttrList() sizes: list[tuple[int, int]] = [] for i, w in enumerate(self._pills): if i >= len(self._offsets): break _, w_nat, _, _ = w.measure(Gtk.Orientation.HORIZONTAL, -1) _, h_nat, _, _ = w.measure(Gtk.Orientation.VERTICAL, -1) w_px = max(w_nat, 1) h_px = max(h_nat, 1) sizes.append((w_px, h_px)) rect = Pango.Rectangle() rect.x = 0 rect.y = -(h_px - h_px // 4) * Pango.SCALE rect.width = w_px * Pango.SCALE rect.height = h_px * Pango.SCALE shape = Pango.AttrShape.new(rect, rect) shape.start_index = self._offsets[i] shape.end_index = self._offsets[i] + _PLACEHOLDER_UTF8_LEN attrs.insert(shape) self._pill_sizes = sizes self._label.set_attributes(attrs) def _allocate_pills(self) -> None: """Position each pill at its Pango layout run location.""" n = min(len(self._pills), len(self._pill_sizes), len(self._offsets)) if n == 0: return layout = self._label.get_layout() off_x, off_y = self._label.get_layout_offsets() it = layout.get_iter() placed = 0 while True: run = it.get_run_readonly() if run is not None and placed < n: item = run.item if item is not None and item.offset == self._offsets[placed]: ext = it.get_run_extents() w_px, h_px = self._pill_sizes[placed] rect = Gdk.Rectangle() rect.x = ext.logical_rect.x // Pango.SCALE + off_x rect.y = ext.logical_rect.y // Pango.SCALE + off_y rect.width = w_px rect.height = h_px self._pills[placed].size_allocate(rect, -1) placed += 1 if not it.next_run(): break # ── Helpers used by the chat views ─────────────────────────── def create_mention_pill(name: str, is_self: bool) -> Gtk.Label: """Create a rounded mention pill widget (non-interactive).""" pill = Gtk.Label(label=f"@{name}") pill.set_valign(Gtk.Align.CENTER) pill.add_css_class("mention-pill-self" if is_self else "mention-pill") # Stash the raw name for extract_buffer_text(). pill._mention_name = name return pill # ── Composer helpers (GtkTextView input field) ─────────────── def insert_mention_pill(text_view: Gtk.TextView, name: str) -> None: """Insert a mention pill at the current cursor position and add a trailing space.""" pill = Gtk.Label(label=f"@{name}") pill.set_valign(Gtk.Align.CENTER) pill.add_css_class("mention-pill") pill._mention_name = name buf = text_view.get_buffer() it = buf.get_iter_at_mark(buf.get_insert()) anchor = buf.create_child_anchor(it) text_view.add_child_at_anchor(pill, anchor) # Insert a space after the pill so the cursor is outside the anchor. it = buf.get_iter_at_child_anchor(anchor) it.forward_char() buf.insert(it, " ") def extract_buffer_text(text_view: Gtk.TextView) -> str: """Read a GtkTextView buffer and return plain text with ``@[name]`` for pills. Regular text is kept as-is. Each child-anchor pill is serialised back to the ``@[name]`` wire format that MeshCore expects. """ buf = text_view.get_buffer() it = buf.get_start_iter() end = buf.get_end_iter() parts: list[str] = [] while it.compare(end) < 0: anchor = it.get_child_anchor() if anchor is not None: widgets = anchor.get_widgets() if widgets and hasattr(widgets[0], "_mention_name"): parts.append(f"@[{widgets[0]._mention_name}]") it.forward_char() continue # Collect plain text up to the next anchor or end. next_it = it.copy() while next_it.compare(end) < 0 and next_it.get_child_anchor() is None: next_it.forward_char() parts.append(buf.get_text(it, next_it, False)) it = next_it return "".join(parts) def buffer_byte_len(text_view: Gtk.TextView) -> int: """Return the UTF-8 byte length of the buffer content, counting pills as ``@[name]``.""" return len(extract_buffer_text(text_view).encode("utf-8")) def apply_message_text(text_lbl: "LabelWithWidgets", text: str, self_name: str = "") -> None: """Render *text* onto *text_lbl* with URL links and mention pills.""" from meshy.gtk_utils import linkify from meshy.text_utils import split_message_text segments = split_message_text(text) widgets: list[Gtk.Widget] = [] parts: list[str] = [] for kind, value in segments: if kind == "mention": parts.append(_PLACEHOLDER) is_self = bool(self_name) and value.casefold() == self_name.casefold() widgets.append(create_mention_pill(value, is_self)) else: parts.append(value) text_lbl.set_markup_and_widgets(linkify("".join(parts)), widgets) meshy/src/views/map_tiles.py000066400000000000000000000326071526212641700164740ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Shumate map tile/style plumbing. Handles OpenFreeMap vector style fetching + patching for Shumate compatibility, raster/vector source creation, theme-aware source swapping, and the map widgets used by every view that shows a map. """ import json import logging import threading import weakref from urllib.request import Request, urlopen 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, Gtk, Shumate from meshy.constants import SCHEMA_ID log = logging.getLogger(__name__) _OFM_BASE = "https://tiles.openfreemap.org" _OFM_STYLE_URLS = { "bright": f"{_OFM_BASE}/styles/bright", "dark": f"{_OFM_BASE}/styles/dark", } _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 _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.debug( "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.debug("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.debug("fetch_vector_source: previously failed for %s", cache_key) callback(None) return def _worker(): try: log.debug("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.debug("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.debug("fetch_vector_source: patched to %d bytes, creating renderer...", len(patched)) def _finish(): try: renderer = Shumate.VectorRenderer.new(renderer_id, patched) log.debug("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 try: log.debug(f"Creating Shumate map: zoom={zoom_level}, lat={lat}, lon={lon}") settings = Gio.Settings.new(SCHEMA_ID) _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.debug("apply_custom_source: url=%s type=%s widgets=%d", url, tile_type, len(widgets)) if not widgets: log.debug("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.debug("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") overlay = builder.get_object("overlay") crosshair = builder.get_object("crosshair") confirm_btn = builder.get_object("confirm_btn") cancel_btn = builder.get_object("cancel_btn") 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.set_label(button_label) 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() def _on_cancel(*_args): dialog.close() confirm_btn.connect("clicked", _on_confirm) cancel_btn.connect("clicked", _on_cancel) dialog.connect("closed", lambda _d: deregister_map_widget(map_widget)) dialog.present(window) meshy/src/views/map_view.py000066400000000000000000001706701526212641700163310ustar00rootroot00000000000000# 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 from datetime import datetime, timedelta 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, GLib, Gtk, Pango, Shumate from meshy.i18n import format_number from meshy.models import Contact, ContactType # 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: "meshy-avatar-default-symbolic", ContactType.REPEATER: "meshy-repeater-symbolic", ContactType.ROOM: "meshy-user-home-symbolic", ContactType.SENSOR: "meshy-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 # Last Seen filter windows, indexed by the sidebar combo row selection # (labels in build_sidebar_filters() must stay in this order). None = no limit. _LAST_SEEN_WINDOWS = ( None, timedelta(days=30), timedelta(days=1), timedelta(hours=1), ) _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.geo 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 def _pixels_to_lat_lon(px, py, zoom): """Convert pixel coordinates back to lat/lon at a given zoom level.""" n = 2.0**zoom lon = px / (n * 256) * 360.0 - 180.0 lat_rad = math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (n * 256)))) lat = math.degrees(lat_rad) return lat, lon def _compute_clusters(contacts, zoom): """Group contacts that are too close at the current zoom into clusters. Uses grid-based spatial hashing — O(n) instead of O(n²). Each grid cell is _CLUSTER_RADIUS_PX pixels wide; contacts in the same cell form one cluster. No neighbor merging — matches meshcore-open's approach and keeps cluster size proportional to the cell, not 3×3 blocks. """ if not contacts: return [] cell_size = _CLUSTER_RADIUS_PX grid: dict[tuple[int, int], list] = {} for c in contacts: px, py = _lat_lon_to_pixels(c.latitude, c.longitude, zoom) key = (int(px // cell_size), int(py // cell_size)) grid.setdefault(key, []).append(c) return list(grid.values()) def _within_last_seen(contact: Contact, now: datetime, cutoff: datetime | None) -> bool: """Return True when the contact was heard within the active time window.""" if cutoff is None: return True if contact.last_seen is None: return False # Uncalibrated node clocks can report future timestamps; treat as now. return min(contact.last_seen, now) >= cutoff _LABEL_ZOOM = 10 def _rebuild_clustered_markers( contacts, marker_layer, raw_zoom, expanded_clusters, last_zoom_ref, last_hash_ref, make_contact_marker, on_cluster_click, extra_markers_fn=None, visible_bounds=None, ): """Shared clustering + marker rebuild logic for all map views. Args: contacts: list of contacts to cluster marker_layer: Shumate.MarkerLayer to populate raw_zoom: current raw zoom level expanded_clusters: list of list[Contact] forced open last_zoom_ref: mutable [float] tracking last bucketed zoom last_hash_ref: mutable [frozenset|None] tracking last cluster hash make_contact_marker: callable(contact, zoom) -> Shumate.Marker on_cluster_click: callable(contacts, lat, lon) extra_markers_fn: optional callable(zoom) -> list[Shumate.Marker] visible_bounds: optional (min_lat, max_lat, min_lon, max_lon) Returns: False (for GLib.timeout_add compatibility) """ zoom = round(raw_zoom * 2) / 2 labels_changed = (last_zoom_ref[0] < _LABEL_ZOOM) != (raw_zoom < _LABEL_ZOOM) if zoom == last_zoom_ref[0] and not expanded_clusters and not labels_changed: return False last_zoom_ref[0] = zoom 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 contacts if c.public_key_hex not in expanded_keys] if visible_bounds: min_lat, max_lat, min_lon, max_lon = visible_bounds to_cluster = [ c for c in to_cluster if min_lat <= c.latitude <= max_lat and min_lon <= c.longitude <= max_lon ] clusters = _compute_clusters(to_cluster, zoom) cluster_hash = frozenset(frozenset(c.public_key_hex for c in cluster) for cluster in clusters) if cluster_hash == last_hash_ref[0] and not expanded_clusters: return False last_hash_ref[0] = cluster_hash marker_layer.remove_all() for cluster in clusters: if len(cluster) == 1: marker = make_contact_marker(cluster[0], zoom) else: marker = MapView._create_cluster_marker(cluster, on_cluster_click) marker_layer.add_marker(marker) for group in expanded_clusters: avg_lat = sum(c.latitude 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 = make_contact_marker(contact, zoom) marker.set_location(contact.latitude + dlat, contact.longitude + dlon) marker_layer.add_marker(marker) if extra_markers_fn: for m in extra_markers_fn(zoom): marker_layer.add_marker(m) return False @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._last_seen_idx: int = 0 # index into _LAST_SEEN_WINDOWS (0 = any time) 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._last_cluster_hash = None self._last_bounds = None self._pan_handler_ids = [] self._initial_fit_done = False self._visible_disc_count = 0 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 viewport = self._map.get_viewport() if self._zoom_handler_id is not None: viewport.disconnect(self._zoom_handler_id) self._zoom_handler_id = None for hid in self._pan_handler_ids: viewport.disconnect(hid) self._pan_handler_ids = [] 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) self._zoom_handler_id = viewport.connect("notify::zoom-level", self._on_zoom_changed) self._pan_handler_ids = [ viewport.connect("notify::latitude", self._on_viewport_panned), viewport.connect("notify::longitude", self._on_viewport_panned), ] 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 fit = not self._initial_fit_done if fit: self._initial_fit_done = True self._apply_filters(fit_bounds=fit) 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 last-seen window, then refresh the map.""" now = datetime.now() window = _LAST_SEEN_WINDOWS[self._last_seen_idx] cutoff = now - window if window is not None else None # Type-visible saved contacts; used for the last-seen filter and to # shadow their discovered duplicates regardless of that filter. saved = [c for c in self._all_contacts if c.type in self._visible_types] self._located_contacts = [c for c in saved if _within_last_seen(c, now, cutoff)] self._visible_disc_count = 0 if self._show_discovered: saved_keys = {c.public_key_hex for c in saved} disc_visible = [ c for c in self._discovered_contacts if c.type in self._visible_types and c.public_key_hex not in saved_keys and _within_last_seen(c, now, cutoff) ] self._visible_disc_count = len(disc_visible) self._located_contacts += disc_visible 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._last_cluster_hash = None self._recluster() self._update_status_label() def _update_status_label(self): """Update the status label with contact counts.""" if hasattr(self, "_status_label"): total = len(self._all_contacts) all_contacts = len(self._window.contacts) if self._window.contacts else 0 visible_contacts = len(self._located_contacts) - self._visible_disc_count 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 += "\n+ " + _("{} discovered").format(self._visible_disc_count) 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) # Last Seen time window last_seen_row = builder.get_object("last_seen_row") last_seen_list = Gtk.StringList() for label in (_("Any time"), _("Past month"), _("Past day"), _("Past hour")): last_seen_list.append(label) last_seen_row.set_model(last_seen_list) last_seen_row.set_selected(self._last_seen_idx) def _on_last_seen_changed(row, _pspec): self._last_seen_idx = row.get_selected() self._apply_filters() last_seen_row.connect("notify::selected", _on_last_seen_changed) 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() self._last_bounds = None if self._recluster_timeout_id: GLib.source_remove(self._recluster_timeout_id) self._recluster_timeout_id = GLib.timeout_add(150, self._recluster) def _on_viewport_panned(self, viewport, pspec): """Debounce pan changes to recluster with viewport culling.""" if self._recluster_timeout_id: GLib.source_remove(self._recluster_timeout_id) self._recluster_timeout_id = GLib.timeout_add(150, self._recluster) def _get_visible_bounds(self, buffer_fraction=0.5): """Return (min_lat, max_lat, min_lon, max_lon) of visible area with buffer.""" viewport = self._map.get_viewport() w = self._map.get_width() h = self._map.get_height() if w <= 0 or h <= 0: return None center_lat = viewport.get_latitude() center_lon = viewport.get_longitude() zoom = viewport.get_zoom_level() cx, cy = _lat_lon_to_pixels(center_lat, center_lon, zoom) half_w = w / 2 half_h = h / 2 buf_w = half_w * buffer_fraction buf_h = half_h * buffer_fraction min_px, max_px = cx - half_w - buf_w, cx + half_w + buf_w min_py, max_py = cy - half_h - buf_h, cy + half_h + buf_h min_lat, min_lon = _pixels_to_lat_lon(min_px, max_py, zoom) max_lat, max_lon = _pixels_to_lat_lon(max_px, min_py, zoom) return (min_lat, max_lat, min_lon, max_lon) def _recluster(self): """Recompute clusters at the current zoom level and rebuild markers.""" self._recluster_timeout_id = None raw_zoom = self._map.get_viewport().get_zoom_level() bounds = self._get_visible_bounds() if bounds and self._last_bounds: bucketed = round(raw_zoom * 2) / 2 bounds_inside = ( bounds[0] >= self._last_bounds[0] and bounds[1] <= self._last_bounds[1] and bounds[2] >= self._last_bounds[2] and bounds[3] <= self._last_bounds[3] ) labels_changed = (self._last_zoom < _LABEL_ZOOM) != (raw_zoom < _LABEL_ZOOM) if ( bucketed == self._last_zoom and not self._expanded_clusters and bounds_inside and not labels_changed ): return False self._last_bounds = bounds zoom_ref = [self._last_zoom] hash_ref = [self._last_cluster_hash] def _extra(zoom): markers = [] if self._self_lat and self._self_lon: markers.append(self._create_self_marker(self._self_lat, self._self_lon, zoom)) return markers result = _rebuild_clustered_markers( self._located_contacts, self._marker_layer, raw_zoom, self._expanded_clusters, zoom_ref, hash_ref, self._create_contact_marker, self._zoom_or_expand_cluster, extra_markers_fn=_extra, visible_bounds=bounds, ) self._last_zoom = zoom_ref[0] self._last_cluster_hash = hash_ref[0] return result _compute_clusters = staticmethod(_compute_clusters) def _create_contact_marker(self, contact: Contact, zoom=None) -> 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, "meshy-avatar-default-symbolic") if zoom is None: zoom = self._map.get_viewport().get_zoom_level() widget = self._build_marker_widget( r, g, b, icon_name, contact.display_name, boxed_label=True, show_label=zoom >= 10 ) widget.set_tooltip_text( f"{contact.display_name}\n{contact.type_label}\n{contact.latitude:.5f}, {contact.longitude:.5f}" ) click = Gtk.GestureClick() if discovered: def _on_disc_clicked(g, n, x, y, w=widget, c=contact): def _add(): self._window.add_discovered_contact(c) self._apply_filters() MapView.show_marker_popover( w, c.display_name, c.latitude, c.longitude, contact_type=c.type_label, last_seen=c.last_seen, public_key_hex=c.public_key_hex, on_add=_add, ) click.connect("pressed", _on_disc_clicked) 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.display_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() test_zoom = min(current_zoom + 2, max_zoom) test_clusters = _compute_clusters(contacts, test_zoom) would_split = len(test_clusters) > 1 if would_split and current_zoom < max_zoom: viewport.set_zoom_level(test_zoom) viewport.set_location(lat, lon) else: self._expanded_clusters.append(contacts) self._last_zoom = -1 self._last_cluster_hash = None self._recluster() def _create_self_marker(self, lat: float, lon: float, zoom=None) -> Shumate.Marker: """Create the self/device location marker.""" r, g, b = _SELF_COLOR name = self._window.device_info.name or _("My Device") if zoom is None: zoom = self._map.get_viewport().get_zoom_level() widget = self._build_marker_widget( r, g, b, "meshy-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, last_seen=None, public_key_hex=None, on_add=None, ): """Show a detail popover anchored to a map marker widget.""" popover = Gtk.Popover() box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=8, margin_start=4, margin_end=4, 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, margin_start=4) name_label.add_css_class("heading") box.append(name_label) group = Adw.PreferencesGroup() if contact_type is not None: group.add(Adw.ActionRow(title=contact_type, subtitle=_("Type"))) if public_key_hex is not None: key_row = Adw.ActionRow( title=public_key_hex[:16] + "…", subtitle=_("Public Key"), title_selectable=True, ) copy_btn = Gtk.Button( icon_name="meshy-edit-copy-symbolic", tooltip_text=_("Copy to clipboard"), valign=Gtk.Align.CENTER, ) copy_btn.add_css_class("flat") copy_btn.connect( "clicked", lambda *_, k=public_key_hex: Gdk.Display.get_default().get_clipboard().set(k), ) key_row.add_suffix(copy_btn) group.add(key_row) if interpolated: group.add( Adw.ActionRow( title=_("Position is illustrative — real location unknown"), subtitle=_("Location"), ) ) else: group.add(Adw.ActionRow(title=f"{lat:.5f}, {lon:.5f}", subtitle=_("Location"))) if snr is not None: group.add(Adw.ActionRow(title=_("{} dB").format(format_number(snr)), subtitle=_("SNR"))) if last_seen is not None: gdt = GLib.DateTime.new_local( last_seen.year, last_seen.month, last_seen.day, last_seen.hour, last_seen.minute, last_seen.second, ) group.add(Adw.ActionRow(title=gdt.format(_("%x")), subtitle=_("Last Seen"))) box.append(group) if on_add is not None: add_btn = Gtk.Button(label=_("Add to Contacts"), margin_top=4) add_btn.add_css_class("suggested-action") add_btn.connect("clicked", lambda *_: (popover.popdown(), on_add())) box.append(add_btn) 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 = "meshy-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 = "meshy-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, "meshy-avatar-default-symbolic") name = contact.display_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) @staticmethod def build_hop_list( device, hop_contacts, *, hop_prefixes=None, snr_values=None, target_contact=None, skip_far_companion=True, append_self=False, ): """Build an interpolated hop list for a single route. Returns a list of ``(lat, lon, contact_or_none, prefix_or_none, snr_or_none)`` tuples (self/device node has contact=None and prefix=None), or ``None`` if no hop has a usable position. - ``device``: the local device info (for self coordinates/name). - ``target_contact``: optional final destination contact to append. - ``skip_far_companion``: when True, drop the self node if it is too far from the rest of the route (``companion_too_far``). - ``append_self``: when True, place the self node at the end of the list (inbound routes, e.g. received channel messages) instead of the start (outbound routes). ``Shumate.PathLayer.add_node()`` prepends nodes, so the position controls which end of the drawn polyline the companion attaches to. """ 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) raw_hops = [] for i, contact in enumerate(hop_contacts): prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None snr = snr_values[i] if snr_values and i < len(snr_values) 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 target_contact is not None and target_contact.has_location: raw_hops.append( (target_contact.latitude, target_contact.longitude, target_contact, None, None) ) if has_self: if skip_far_companion and companion_too_far(self_lat, self_lon, raw_hops): pass elif append_self: raw_hops.append((self_lat, self_lon, None, None, None)) else: 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 None return hops @staticmethod def draw_hop_path( map_widget, marker_layer, viewport, hops, device, *, labels="none", on_detail=None, ): """Render a single-route hop path onto an existing dialog map. - ``hops``: list produced by :meth:`build_hop_list`. - ``device``: local device info (for the self node's display name). - ``labels``: ``"snr"`` for SNR labels at midpoints, ``"distance"`` for segment distance labels, or ``"none"``. - ``on_detail``: optional ``callable(contact)`` invoked from a marker's detail popover / click. When None, markers show the interpolated popover for unknown hops and nothing extra for known contacts. A dashed path layer is created and inserted below ``marker_layer``. """ from meshy.geo import format_distance, haversine_distance 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 = "meshy-network-wireless-symbolic" name = (getattr(device, "name", "") or _("My Device")) if device else _("My Device") type_label = None badge = "" elif contact is None or not contact.has_location: r, g, b = _UNKNOWN_COLOR icon_name = "meshy-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, "meshy-avatar-default-symbolic") name = contact.display_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: on_detail(c)) if (contact and on_detail) else None widget = MapView._build_marker_widget(r, g, b, icon_name, "", badge_text=badge) widget.set_tooltip_text(name) popover_snr = snr if labels == "snr" else None click = Gtk.GestureClick() click.connect( "pressed", lambda g, n, x, y, w=widget, nm=name, la=lat, lo=lon, s=popover_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) if labels == "snr": 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_marker = Shumate.Marker() snr_marker.set_child(MapView.build_snr_widget(snr)) snr_marker.set_location(mid_lat, mid_lon) marker_layer.add_marker(snr_marker) elif labels == "distance": def _has_real_location(hop): if hop[0] is None: return False if hop[2] is not None: return hop[2].has_location return hop[3] is None # no contact and no prefix → self/device for idx in range(1, len(hops)): prev_hop = hops[idx - 1] cur_hop = hops[idx] if not _has_real_location(prev_hop) or not _has_real_location(cur_hop): continue dist = haversine_distance(prev_hop[0], prev_hop[1], cur_hop[0], cur_hop[1]) dist_marker = Shumate.Marker() dist_marker.set_child(MapView.build_map_label_widget(format_distance(dist))) dist_marker.set_location( (prev_hop[0] + cur_hop[0]) / 2, (prev_hop[1] + cur_hop[1]) / 2, ) marker_layer.add_marker(dist_marker) MapView.fit_viewport(viewport, lats, lons) @staticmethod def open_hop_path_dialog( window, hops, device, *, title=None, labels="none", on_detail=None, ): """Open a map dialog and draw a single-route hop path into it. Convenience wrapper around :meth:`open_map_dialog` + :meth:`draw_hop_path`. Presents the dialog and returns it. """ dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(window, title=title) MapView.draw_hop_path( map_widget, marker_layer, viewport, hops, device, labels=labels, on_detail=on_detail, ) dialog.present(window) return dialog 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)) dialog._header = builder.get_object("header") 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() # Configure headerbar: Cancel left, Done right (HIG pattern) header = dialog._header header.set_show_start_title_buttons(False) header.set_show_end_title_buttons(False) cancel_btn = Gtk.Button(label=_("_Cancel"), use_underline=True) cancel_btn.connect("clicked", lambda *_: dialog.close()) header.pack_start(cancel_btn) done_btn = Gtk.Button(label=_("_Done"), use_underline=True, sensitive=False) done_btn.add_css_class("suggested-action") header.pack_end(done_btn) 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.display_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) expanded_clusters = [] last_zoom = [-1] last_hash = [None] 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, "meshy-avatar-default-symbolic") widget = MapView._build_marker_widget( r, g, b, icon_name, contact.display_name, boxed_label=True, show_label=zoom >= 10 ) widget.set_tooltip_text(contact.display_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 = _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 last_hash[0] = None _rebuild_markers() def _self_marker_fn(zoom): if not has_self: return [] self_name = getattr(device, "name", "") or _("My Device") r, g, b = _SELF_COLOR widget = MapView._build_marker_widget( r, g, b, "meshy-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) return [sm] def _rebuild_markers(): recluster_timeout[0] = 0 return _rebuild_clustered_markers( located, marker_layer, viewport.get_zoom_level(), expanded_clusters, last_zoom, last_hash, _create_picker_contact_marker, _zoom_or_expand, extra_markers_fn=_self_marker_fn, ) def _on_zoom_changed(vp, pspec): expanded_clusters.clear() last_hash[0] = None 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") 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/pickers.py000066400000000000000000000243361526212641700161570ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Reusable contact-picker list infrastructure (search / filter / sort).""" import gettext import types import gi gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") from gi.repository import Adw, Gio, GLib, GObject, Gtk def _(msg): return gettext.dgettext("meshy", msg) _CONTACT_TYPE_ICONS = { 1: "meshy-avatar-default-symbolic", 2: "meshy-repeater-symbolic", 3: "meshy-user-home-symbolic", 4: "meshy-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.display_name) if contact.display_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, "meshy-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, close_on_select=False, ): 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.display_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.display_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) if close_on_select: dialog.close() return 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), ) def setup_contact_picker_filter_sort( ns, *, sort_options, default_sort="name", self_lat=None, self_lon=None, action_prefix="picker" ): from meshy.models import ContactType has_self_loc = self_lat is not None and self_lon is not None 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"{action_prefix}.filter::{fid}")) ns.filter_btn.set_menu_model(filter_menu) ns.sort_btn.set_visible(True) sort_menu = Gio.Menu() for sid, label in sort_options: sort_menu.append_item(Gio.MenuItem.new(label, f"{action_prefix}.sort::{sid}")) if has_self_loc: sort_menu.append_item(Gio.MenuItem.new(_("Distance"), f"{action_prefix}.sort::distance")) ns.sort_btn.set_menu_model(sort_menu) 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(default_sort) ) action_group.add_action(filter_action) action_group.add_action(sort_action) ns.dialog.insert_action_group(action_prefix, action_group) def _filter_func(item, _data): contact = item.contact search = ns.search_entry.get_text().lower() if search and search not in contact.display_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 ) ns.custom_filter.set_filter_func(_filter_func, None) 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) def _sort_func(a, b, _data=None): mode = sort_action.get_state().get_string() if mode == "last-seen": ta, tb = a.contact.last_seen, 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 elif mode == "arrival": ta, tb = a.contact.received_at, 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 elif mode == "distance" and has_self_loc: from meshy.geo 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.display_name.lower() nb = b.contact.display_name.lower() return (na > nb) - (na < nb) custom_sorter = Gtk.CustomSorter.new(_sort_func, None) 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]) meshy/src/views/repeater_view.py000066400000000000000000001652501526212641700173610ustar00rootroot00000000000000# 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("Gdk", "4.0") gi.require_version("Adw", "1") import contextlib import copy from gi.repository import Adw, Gio, GLib, Gtk from meshy.gtk_utils import TimeoutManager, clear_children from meshy.i18n import format_number 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.time_format import format_duration, format_time_ago from meshy.views.cli_console import CliConsole # noqa: F401 (registers MeshyCliConsole) 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_console = Gtk.Template.Child("cli_console") # 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") # Network _net_fetch_spinner = Gtk.Template.Child("net_fetch_spinner") _net_fetch_btn = Gtk.Template.Child("net_fetch_btn") _net_save_btn = Gtk.Template.Child("net_save_btn") _set_path_hash = Gtk.Template.Child("set_path_hash") _set_default_region = Gtk.Template.Child("set_default_region") # 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") if self._contact and self._contact.type == ContactType.SENSOR: return _("sensor") 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 self._net_region_options = [""] self._net_default_region = "" # 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._cli_console.send(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._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._net_fetch_btn.connect( "clicked", lambda *_: self._fetch_section( ["get path.hash.mode", "region default", "region"], self._parse_network, self._net_fetch_btn, self._net_fetch_spinner, ), ) self._net_save_btn.connect("clicked", lambda *_: self._save_network()) 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.views import build_telemetry_groups groups, gps_entry = build_telemetry_groups(entries) 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.display_name if self._contact else "") ) dialog.set_content_height(550 if gps_entry else 400) for group in groups: 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 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_console.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.display_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("meshy-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, "meshy-repeater-symbolic", "" ) center_widget.set_tooltip_text(center.display_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.display_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, "meshy-repeater-symbolic", "" ) nb_widget.set_tooltip_text(contact.display_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.display_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.display_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("meshy-avatar-default-symbolic")) # Remove button remove_btn = Gtk.Button(icon_name="meshy-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.display_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, 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 = ( "meshy-check-plain-symbolic" if entry["flood"] else "meshy-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="meshy-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 builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/region-add-content.ui" ) dialog = builder.get_object("region_add_dialog") 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_add(*_args): 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.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("add_btn").connect("clicked", _on_add) 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, 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 _parse_network(self, responses): if responses and responses[0] is not None: mode = responses[0].strip().lstrip("> ").strip() with contextlib.suppress(ValueError): self._set_path_hash.set_selected(int(float(mode))) default_text = responses[1] if len(responses) > 1 else "" region_text = responses[2] if len(responses) > 2 else "" entries, default_name = parse_region_text(region_text or "", default_text or "") names = [e["name"] for e in entries if e["name"] and e["name"] != "*"] if default_name and default_name not in names: names.insert(0, default_name) self._net_default_region = default_name self._net_region_options = [""] + names self._set_default_region.set_model(Gtk.StringList.new([_("Unscoped")] + names)) index = self._net_region_options.index(default_name) if default_name else 0 self._set_default_region.set_selected(index) 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 _save_network(self): if not self._contact: return cmds = [] mode = self._set_path_hash.get_selected() if mode >= 0: cmds.append(f"set path.hash.mode {mode}") selected = self._set_default_region.get_selected() region = "" if selected < len(self._net_region_options): region = self._net_region_options[selected] if region != self._net_default_region: cmds.append(f"region default {region}" if region else "region default ") self._net_default_region = region 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.display_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.display_name if self._contact else self._node_label ), body=_("Reboot {}? It will be offline briefly.").format( self._contact.display_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._cli_console.send("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) self._set_path_hash.set_selected(0) self._net_region_options = [""] self._net_default_region = "" self._set_default_region.set_model(Gtk.StringList.new([_("Unscoped")])) self._set_default_region.set_selected(0) # CLI self._cli_console.clear() 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 is_sensor = contact.type == ContactType.SENSOR try: cli_node_type = ContactType(contact.type).name.lower() except ValueError: cli_node_type = None self._cli_console.configure( lambda cmd, _cb: self._window.send_cli_command(contact, cmd), None, cli_node_type, wait=False, ) # 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 only supported by repeater firmware nb_child = self._stack.get_child_by_name("neighbors") if nb_child: self._stack.get_page(nb_child).set_visible(not is_room and not is_sensor) # Status request not supported by sensor firmware st_child = self._stack.get_child_by_name("status") if st_child: self._stack.get_page(st_child).set_visible(not is_sensor) # Guest access toggle only for room servers self._set_allow_read_only.set_visible(is_room) # Guest password: repeaters and room servers (sensor firmware has no guest login) self._set_guest_pwd.set_visible(not is_sensor) self._guest_pwd_save_btn.set_visible(not is_sensor) # 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("cli" if is_sensor else "status") # Clear CLI output self._cli_console.clear() # 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); # sensors don't support status requests, load regions directly if is_sensor: self._start_pending_regions() else: 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._cli_console.cancel_pending() 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.py000066400000000000000000000175611526212641700170640ustar00rootroot00000000000000# 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 GLib, Gtk from meshy.models import Contact, ContactType from meshy.views import build_telemetry_groups, show_login_dialog 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]): self._remove_dynamic_groups() self._cleanup_map() self._status_group.set_visible(False) groups, gps_entry = build_telemetry_groups(entries) for group in groups: 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, "meshy-avatar-default-symbolic") marker = MapView.create_marker(r, g, b, icon_name, self._contact.display_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, "meshy-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 contact = self._contact show_login_dialog( self._window, contact, on_success=lambda ts, admin: self._window.show_repeater(contact, ts, admin), ) 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.py000066400000000000000000002347761526212641700174240ustar00rootroot00000000000000# 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.constants import SCHEMA_ID from meshy.gtk_utils import clear_children from meshy.i18n import format_number, parse_number from meshy.models import Contact, DeviceInfo from meshy.views import show_alert # (name, freq_mhz, bw_hz, sf, cr, tx_dbm, path_hash_size) # path_hash_size: 1/2/3 when the preset prescribes it, None otherwise. # Synced with https://api.meshcore.nz/api/v1/config (suggested_radio_settings.entries). RADIO_PRESETS = [ ("Australia", 915.8, 250000, 10, 5, 20, None), ("Australia (Narrow)", 916.575, 62500, 7, 7, 20, None), ("Australia (Mid)", 915.075, 125000, 9, 5, 20, None), ("Australia: SA, WA", 923.125, 62500, 8, 8, 20, None), ("Australia: QLD", 923.125, 62500, 8, 5, 20, None), ("Brazil", 923.125, 62500, 8, 8, 20, None), ("Canada", 910.525, 62500, 7, 5, 20, 3), ("Costa Rica", 910.525, 125000, 11, 5, 20, None), ("EU/UK (Narrow)", 869.618, 62500, 8, 8, 14, None), ("EU/UK (Deprecated)", 869.525, 250000, 11, 5, 14, None), ("Czech Republic (Narrow)", 869.432, 62500, 7, 5, 14, 2), ("EU 433MHz (Long Range)", 433.650, 250000, 11, 5, 20, None), ("EU 433MHz (Narrow)", 433.650, 62500, 8, 8, 20, None), ("Hungary", 869.618, 62500, 7, 5, 14, 2), ("Netherlands", 869.618, 62500, 7, 5, 14, None), ("Netherlands (Limburg)", 869.618, 62500, 8, 8, 14, 2), ("New Zealand (Narrow)", 917.375, 62500, 7, 5, 20, 2), ("New Zealand (Gisborne)", 917.375, 250000, 11, 5, 20, 1), ("Portugal 433", 433.375, 62500, 9, 6, 20, None), ("Portugal 868", 869.618, 62500, 7, 6, 14, None), ("Slovakia", 869.618, 62500, 7, 5, 14, 2), ("Switzerland", 869.618, 62500, 8, 8, 14, None), ("USA", 910.525, 62500, 7, 5, 20, None), ("USA - Southern California", 927.875, 62500, 7, 5, 20, 3), ("Vietnam (Narrow)", 920.250, 62500, 8, 5, 20, None), ("Vietnam (Deprecated)", 920.250, 250000, 11, 5, 20, None), ] 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") _online_nodes_switch = Gtk.Template.Child("online_nodes_switch") _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") _auto_hop_limit_row = Gtk.Template.Child("auto_hop_limit_row") _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") _add_region_btn = Gtk.Template.Child("add_region_btn") _discover_regions_btn = Gtk.Template.Child("discover_regions_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), (_("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._add_region_btn.connect("clicked", self._on_add_region) self._discover_regions_btn.connect("clicked", self._on_discover_regions) # 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() def _on_path_hash_changed(*_args): """Reset preset to Custom only when it prescribes a conflicting path hash size. Presets that don't prescribe a path hash size keep the preset selected when the path hash size is changed manually. """ if self._suppressing_changes or self._applying_preset: self._check_dirty() return self._radio_dirty = True idx = self._preset_row.get_selected() if idx > 0: ph = RADIO_PRESETS[idx - 1][6] if ph is not None and ph - 1 != self._path_hash_combo.get_selected(): 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) settings = Gio.Settings.new(SCHEMA_ID) self._online_nodes_switch.set_active(settings.get_boolean("online-nodes-enabled")) self._online_nodes_switch.connect("notify::active", self._on_online_nodes_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", _on_path_hash_changed) 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=SCHEMA_ID) 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: auto_add = self._auto_add_switch.get_active() supported = self._window._device_info.supports_auto_add_config hop_supported = self._window._device_info.supports_auto_add_max_hops return { "auto_add_chat": auto_add and supported and self._auto_chat_switch.get_active(), "auto_add_repeater": auto_add and supported and self._auto_repeater_switch.get_active(), "auto_add_room_server": auto_add and supported and self._auto_room_switch.get_active(), "auto_add_sensor": auto_add and supported and self._auto_sensor_switch.get_active(), "overwrite_oldest": self._auto_overwrite_switch.get_active(), "auto_add_max_hops": ( int(self._auto_hop_limit_entry.get_text() or "0") if hop_supported else 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._restore_scope_selection(snap.get("default_scope", "")) # The snapshot region may have been deleted meanwhile; keep the baseline # in sync with what we could actually restore. snap["default_scope"] = self.default_scope 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(), "default_scope": self.default_scope, } 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", "") or self.default_scope != snap.get("default_scope", "") ) 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 dev_path_hash = getattr(info, "path_hash_mode", None) def _params_match(freq, bw, sf, cr): return ( 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 ) # Prefer an exact match including the prescribed path hash size # (some presets share radio params and differ only in path hash size). for i, (_, freq, bw, sf, cr, _tx, ph) in enumerate(RADIO_PRESETS): if ( ph is not None and dev_path_hash is not None and ph - 1 == dev_path_hash and _params_match(freq, bw, sf, cr) ): self._preset_row.set_selected(i + 1) # +1 for "Custom" at index 0 return # Fall back to matching radio params only. Prefer presets that do not # prescribe a path hash size: a ph-prescribing preset should have been # caught by the exact pass above, and once its ph is unknown it would # otherwise shadow a ph-agnostic preset with the same radio params # (e.g. Canada shadowing USA). for ph_is_none in (True, False): for i, (_, freq, bw, sf, cr, _tx, ph) in enumerate(RADIO_PRESETS): if (ph is None) == ph_is_none and _params_match(freq, bw, sf, cr): self._preset_row.set_selected(i + 1) 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, ph = 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) if ph is not None and self._path_hash_combo.get_sensitive(): self._path_hash_combo.set_selected(ph - 1) 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 active = switch.get_active() if active and not any( ( self._auto_chat_switch.get_active(), self._auto_repeater_switch.get_active(), self._auto_room_switch.get_active(), self._auto_sensor_switch.get_active(), ) ): # Turning the global toggle on with no type selected should enable # auto-add for all types instead of silently doing nothing. self._suppressing_changes = True for type_switch in ( self._auto_chat_switch, self._auto_repeater_switch, self._auto_room_switch, self._auto_sensor_switch, ): type_switch.set_active(True) self._suppressing_changes = False self._auto_add_expander.set_sensitive(active) def _on_online_nodes_toggled(self, switch, *args): if self._suppressing_changes: return if switch.get_active(): self._show_online_nodes_enable_dialog() else: self._show_online_nodes_disable_dialog() def _show_online_nodes_enable_dialog(self): builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/online-nodes-enable-dialog.ui" ) dialog = builder.get_object("enable_dialog") auto_update_check = builder.get_object("auto_update_check") def _on_response(d, response): if response == "download": settings = Gio.Settings.new(SCHEMA_ID) settings.set_boolean("online-nodes-enabled", True) settings.set_boolean("online-nodes-auto-update", auto_update_check.get_active()) from meshy.map_nodes import MapNodesManager if not hasattr(self._window, "_map_nodes_manager"): self._window._map_nodes_manager = MapNodesManager() if auto_update_check.get_active(): self._window._map_nodes_manager.schedule(self._window, force=True) else: self._window._map_nodes_manager.run(self._window, force=True) else: self._suppressing_changes = True self._online_nodes_switch.set_active(False) self._suppressing_changes = False dialog.connect("response", _on_response) dialog.present(self._window) def _show_online_nodes_disable_dialog(self): builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/online-nodes-disable-dialog.ui" ) dialog = builder.get_object("disable_dialog") delete_check = builder.get_object("delete_check") def _on_response(d, response): if response == "disable": settings = Gio.Settings.new(SCHEMA_ID) settings.set_boolean("online-nodes-enabled", False) if hasattr(self._window, "_map_nodes_manager"): self._window._map_nodes_manager.cancel() if delete_check.get_active() and self._window.storage: self._window.storage.delete_map_contacts() self._window.reload_discovered() self._window.show_toast(_("Online node contacts deleted")) else: self._suppressing_changes = True self._online_nodes_switch.set_active(True) self._suppressing_changes = False dialog.connect("response", _on_response) dialog.present(self._window) 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 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() if ( auto_add != snap.get("auto_add", False) or auto_chat != snap.get("auto_chat", False) or auto_repeater != snap.get("auto_repeater", False) or auto_room != snap.get("auto_room", False) or auto_sensor != snap.get("auto_sensor", False) or auto_overwrite != 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 # The firmware ignores the type bitmask while manual_add_contacts is # clear, so keep the manual gate set and encode the selection in the # flags (meshcore-open model). Rewriting CMD_SET_OTHER_PARAMS on # every type toggle only adds a redundant prefs write and a second # identical OK frame, so do it only when the gate must change. current_manual = bool(self._window._self_info.get("manual_add_contacts", False)) if self._window._device_info.supports_auto_add_config: if not current_manual: cmds.append(lambda: self._window.set_manual_add_contacts(1)) frame = build_set_auto_add_config( chat=auto_add and auto_chat, repeater=auto_add and auto_repeater, room=auto_add and auto_room, sensor=auto_add and auto_sensor, overwrite_oldest=auto_overwrite, max_hops=hop_limit, ) cmds.append(lambda f=frame: self._window.send_frame(f)) else: # Pre-1.12 firmware: only the global toggle exists. manual = 0 if auto_add else 1 if manual != current_manual: cmds.append(lambda m=manual: self._window.set_manual_add_contacts(m)) # 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)) # Default flood scope (empty = clear) scope = self.default_scope if scope != snap.get("default_scope", ""): from meshy.protocol import build_set_default_flood_scope if self._window.storage: self._window.storage.set_default_scope(scope) cmds.append(lambda s=scope: self._window.send_frame(build_set_default_flood_scope(s))) 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_auto_add_capability(self, info: DeviceInfo): """Show granular auto-add options only on firmware that supports them. CMD_SET_AUTOADD_CONFIG/CMD_GET_AUTOADD_CONFIG exist from v1.12.0; the hop limit from v1.14.0. Older firmware only supports the global manual_add_contacts toggle. """ supported = info.supports_auto_add_config self._auto_add_expander.set_visible(supported) self._auto_hop_limit_row.set_visible(info.supports_auto_add_max_hops) def update_auto_add_from_self_info(self, manual_add_contacts: bool): """Reflect the global auto-add toggle from SELF_INFO. On firmware without granular auto-add config this is the authoritative source, because the AUTOADD_CONFIG response never arrives. """ if self._window._device_info.supports_auto_add_config: return self._suppressing_changes = True self._auto_add_switch.set_active(not manual_add_contacts) self._auto_add_expander.set_sensitive(not manual_add_contacts) self._suppressing_changes = False self._take_snapshot() 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_add_region(self, *_args): builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/add-regions-dialog.ui" ) dialog = builder.get_object("add_regions_dialog") entry = builder.get_object("region_entry") def _on_confirm(*_a): self._add_regions_from_text(entry.get_text()) dialog.close() builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) builder.get_object("add_btn").connect("clicked", _on_confirm) entry.connect("entry-activated", _on_confirm) dialog.present(self._window) def _focus_entry(): entry.grab_focus_without_selecting() entry.set_position(-1) return False GLib.idle_add(_focus_entry) def _add_regions_from_text(self, text: str): """Add one or more comma/newline-separated region names as chips.""" for raw in text.replace("\n", ",").split(","): name = raw.strip().strip("#") if not name or name == "*" or name in self._region_rows: continue if self._window.storage: self._window.storage.add_region(name) self._add_region_chip(name) 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) # 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): # The scope is a device setting, so it is written on Apply like the # others (not immediately); just mark the form dirty. if self._suppressing_changes: return self._check_dirty() def _restore_scope_selection(self, name: str): """Select *name* in the scope combo, falling back to "None".""" selected = 0 for i in range(self._scope_model.get_n_items()): if name and self._scope_model.get_string(i) == name: selected = i break self._default_scope_combo.set_selected(selected) def _set_default_scope_ui(self, scope_name: str): """Adopt *scope_name* into the region list, storage and combo. Nothing is written back to the device (the change handler is suppressed). Used both when loading the local setting and when the device reports its persisted scope on connect. """ if not self._window.storage: return name = scope_name.lstrip("#") self._suppressing_changes = True try: if name and name not in self._window.storage.get_regions(): self._window.storage.add_region(name) if name: self._add_region_chip(name) self._window.storage.set_default_scope(name) self._restore_scope_selection(name) # Adopting is not a user edit: keep the clean baseline in sync so # Apply does not light up right after a connect. if self._clean_snapshot is not None: self._clean_snapshot["default_scope"] = name finally: self._suppressing_changes = False def restore_default_scope(self): """Restore default scope selection from storage.""" if self._window.storage: self._set_default_scope_ui(self._window.storage.get_default_scope()) def adopt_default_scope(self, scope_name: str): """Show and store the default flood scope the device reported on connect.""" self._set_default_scope_ui(scope_name) 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") cancel_btn = builder.get_object("cancel_btn") retry_btn = builder.get_object("retry_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 _update_add_btn(): addable = any( name not in self._region_rows and check.get_active() for name, check in discovered_regions.items() ) add_btn.set_sensitive(addable) 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) ) retry_btn.set_visible(False) _update_add_btn() 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_sensitive(False) retry_btn.set_visible(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) check.connect("toggled", lambda *_: _update_add_btn()) 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), use_path=True, ) def _retry(): if responded[0]: return False self._window.request_regions( contact, lambda text: _handle_response(text), use_path=True, ) 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.display_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.display_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_sensitive(False) retry_btn.set_visible(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): 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) cancel_btn.connect("clicked", lambda *_: dialog.close()) retry_btn.connect("clicked", lambda *_: _start_discover()) dialog.present(self._window) def update_auto_add_config(self, config: dict): """Update auto-add switches from device config. The autoadd_config bitmask is only honoured by the firmware when manual_add_contacts is set; otherwise every contact type is auto-added. """ from meshy.protocol import ( AUTO_ADD_ALL_TYPES, AUTO_ADD_CHAT, AUTO_ADD_OVERWRITE_OLDEST, AUTO_ADD_REPEATER, AUTO_ADD_ROOM_SERVER, AUTO_ADD_SENSOR, ) self._suppressing_changes = True flags = config.get("config", 0) # While the manual gate is clear the firmware auto-adds every type and # ignores the bitmask, so pretend all type bits are set. if not self._window._self_info.get("manual_add_contacts", False): flags |= AUTO_ADD_ALL_TYPES any_type = bool(flags & AUTO_ADD_ALL_TYPES) self._auto_add_switch.set_active(any_type) self._auto_add_expander.set_sensitive(any_type) self._auto_chat_switch.set_active(bool(flags & AUTO_ADD_CHAT)) self._auto_repeater_switch.set_active(bool(flags & AUTO_ADD_REPEATER)) self._auto_room_switch.set_active(bool(flags & AUTO_ADD_ROOM_SERVER)) self._auto_sensor_switch.set_active(bool(flags & AUTO_ADD_SENSOR)) self._auto_overwrite_switch.set_active(bool(flags & AUTO_ADD_OVERWRITE_OLDEST)) 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: show_alert( self._window, _("Not Connected"), _("Connect to a device first to export a backup."), ) 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) builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) 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(SCHEMA_ID) 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, parent=None, back_label=None, on_restore_started=None): if not self._window.is_connected: show_alert( parent or self._window, _("Not Connected"), _("Connect to a device first to import a backup."), ) 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(), parent=parent, back_label=back_label, on_restore_started=on_restore_started, ) except GLib.Error: pass file_dialog.open(self._window, None, on_open_response) def _on_restore_auto_backup(self, *args, parent=None, back_label=None, on_restore_started=None): if not self._window.is_connected: show_alert( parent or self._window, _("Not Connected"), _("Connect to a device first to restore a backup."), ) return device_addr = self._window._transport.connected_address device_pubkey = self._window._device_info.public_key if self._window._device_info else None device_backups = self._window._auto_backup.list_backups( device_addr, device_pubkey=device_pubkey ) all_backups = self._window._auto_backup.list_backups() if not all_backups: show_alert( parent or self._window, _("No Auto Backups"), _("No automatic backups found."), ) return builder = Gtk.Builder.new_from_resource( "/page/codeberg/sesivany/Meshy/ui/restore-snapshot-dialog.ui" ) dialog = builder.get_object("restore_snapshot_dialog") content_box = builder.get_object("content_box") restore_btn = builder.get_object("restore_btn") cancel_btn = builder.get_object("cancel_btn") show_all_btn = builder.get_object("show_all_btn") selected_path = [None] from meshy.views import _format_date_text def _clear(): while child := content_box.get_first_child(): content_box.remove(child) def _on_select(row): if row: selected_path[0] = row._backup_path def _add_rows(listbox, backups_list): for b in backups_list: row = Adw.ActionRow( title=_format_date_text(b["mtime_dt"]), subtitle=b["time"], ) row._backup_path = b["path"] listbox.append(row) def _show_device_view(): _clear() selected_path[0] = None lb = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) lb.add_css_class("boxed-list") _add_rows(lb, device_backups) if lb.get_row_at_index(0): lb.select_row(lb.get_row_at_index(0)) selected_path[0] = device_backups[0]["path"] lb.connect("row-selected", lambda _lb, row: _on_select(row)) content_box.append(lb) if len(all_backups) > len(device_backups): show_all_btn.set_label(_("Show All Devices")) content_box.append(show_all_btn) def _show_all_view(): _clear() selected_path[0] = None groups = {} for b in all_backups: groups.setdefault(b["name"] or _("Unknown"), []).append(b) current_name = None if self._window._device_info: current_name = self._window._device_info.name sorted_names = sorted(groups, key=lambda n: (n != current_name, n)) listboxes = [] for name in sorted_names: title = name if current_name and name == current_name: title = _("{} (current)").format(name) label = Gtk.Label(label=title, halign=Gtk.Align.START) label.add_css_class("heading") content_box.append(label) lb = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) lb.add_css_class("boxed-list") _add_rows(lb, groups[name]) listboxes.append(lb) content_box.append(lb) def _make_handler(current_lb): def handler(_lb, row): if row: _on_select(row) for other in listboxes: if other is not current_lb: other.select_row(None) return handler for lb in listboxes: lb.connect("row-selected", _make_handler(lb)) if listboxes and listboxes[0].get_row_at_index(0): listboxes[0].select_row(listboxes[0].get_row_at_index(0)) if device_backups: show_all_btn.set_label(_("Show Only This Device")) content_box.append(show_all_btn) showing_all = [not device_backups] def _on_toggle(*_args): if showing_all[0]: _show_device_view() showing_all[0] = False else: _show_all_view() showing_all[0] = True show_all_btn.connect("clicked", _on_toggle) if showing_all[0]: _show_all_view() else: _show_device_view() def on_restore(*_args): if selected_path[0]: dialog.close() self._show_import_preview( selected_path[0], parent=parent, back_label=back_label, on_restore_started=on_restore_started, ) if back_label: cancel_btn.set_label(back_label) restore_btn.connect("clicked", on_restore) cancel_btn.connect("clicked", lambda *_: dialog.close()) dialog.present(parent or self._window) def _show_import_preview( self, path: str, parent=None, back_label=None, on_restore_started=None ): import json from meshy.backup import BackupManager try: with open(path, encoding="utf-8") as f: backup = json.load(f) except Exception as e: show_alert( parent or self._window, _("Import Failed"), _("Could not read backup file: {}").format(e), ) 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() if on_restore_started: on_restore_started() 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) if back_label: builder.get_object("cancel_btn").set_label(back_label) builder.get_object("restore_btn").connect("clicked", on_restore) builder.get_object("cancel_btn").connect("clicked", lambda *_: dialog.close()) dialog.present(parent or self._window) meshy/src/window.py000066400000000000000000003207631526212641700146740ustar00rootroot00000000000000# 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.constants import SCHEMA_ID from meshy.frame_handler import FrameHandler from meshy.gtk_utils import clear_children from meshy.i18n import format_number from meshy.map_nodes import MapNodesManager from meshy.message_controller import MessageController from meshy.models import ( Channel, Contact, ContactType, DeviceInfo, Message, battery_icon_name, snr_to_icon, ) from meshy.notification import NotificationService from meshy.protocol import ( STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO, _parse_path_length_byte, build_add_update_contact, build_anon_req_owner, build_anon_req_regions, build_export_contact, build_export_private_key, build_get_advert_path, build_get_default_flood_scope, 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_run_cli_command, 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_device_pin, build_set_flood_scope, build_set_path_hash_mode, build_set_radio_params, build_set_radio_tx_power, build_share_contact, build_trace_path, cli_txt_type, ) from meshy.storage import Storage from meshy.transport_base import ConnectionState from meshy.views import snr_quality_label 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._pending_custom_names: dict[str, str] = {} # pub_key_hex -> custom name (restore) self._pending_channel_regions: dict[int, str] = {} # channel index -> region (restore) 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._local_cli_callback = None # callback for CMD_RUN_CLI_COMMAND replies self._status_callback = None # callback for binary status responses self._region_callbacks: dict[str, callable] = {} # ack_hex -> callback for region responses self._owner_callbacks: dict[str, callable] = {} # ack_hex -> callback for owner responses self._table_full_error = False self._pending_binary_requests: dict[str, dict] = {} # tag_hex -> {type, callback} 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(SCHEMA_ID) Contact._global_smart_default = self._settings.get_string("default-routing-mode") == "smart" self._backup = BackupManager() self._auto_backup = AutoBackupManager() self._map_nodes_manager = MapNodesManager() self._frame_handler = FrameHandler(self) self._notification_service = NotificationService(self.get_application()) self._notification_service.set_on_activate(self._on_notification_activate) self._notification_service.set_on_reply(self._on_notification_reply) 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 fullscreen/maximized state. Fullscreen is applied once the # window is mapped, which is more reliable than before it is presented. self._pending_fullscreen = False if self._settings.get_boolean("window-fullscreen"): self._pending_fullscreen = True self.connect("map", self._on_map_restore_fullscreen) elif self._settings.get_boolean("window-maximized"): self.maximize() self.connect("close-request", self._on_close_request) def _on_map_restore_fullscreen(self, *_args): if not self._pending_fullscreen: return self._pending_fullscreen = False self.fullscreen() def _save_window_state(self): 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()) self._settings.set_boolean("window-fullscreen", self.is_fullscreen()) def set_background_mode(self, enabled): self._background_mode = enabled self._update_background_status() def _update_background_status(self): """Publish the connected companion's node name as the background status. Shown by the desktop's background apps monitor next to Meshy's entry (org.freedesktop.portal.Background.SetStatus). """ app = self.get_application() if app is None or not hasattr(app, "set_background_message"): return name = (self._device_info.name or "").strip() connected = bool(self._transport is not None and self._transport.is_connected) if self._background_mode and connected and name: app.set_background_message(name) else: app.set_background_message("") def shutdown(self): """Full cleanup for application quit.""" self._conn_ctrl.stop_sleep_monitor() 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._save_window_state() 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) fullscreen_action = Gio.SimpleAction.new("toggle-fullscreen", None) fullscreen_action.connect("activate", self._on_toggle_fullscreen) self.add_action(fullscreen_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("meshy-sidebar-show-symbolic") else: self._nav_rail_toggle.set_icon_name("meshy-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_toggle_fullscreen(self, action, param): if self.is_fullscreen(): self.unfullscreen() self._settings.set_boolean("window-fullscreen", False) self.show_toast(_("Fullscreen off")) else: self.fullscreen() self._settings.set_boolean("window-fullscreen", True) self.show_toast(_("Fullscreen on")) 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="meshy-system-search-symbolic", tooltip_text=_("Search") ) search_btn.add_css_class("flat") view.set_search_button(search_btn) filter_btn = Gtk.MenuButton(icon_name="meshy-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="meshy-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._chat_view_inst is not None and view_id != "contacts": self._chat_view_inst.clear() if self._channels_view_inst is not None and view_id != "channels": self._channels_view_inst.get_chat_widget().clear() 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 self._clear_box(self._sidebar_box) self._clear_box(self._content_box) # Force Python GC and return freed memory to the OS import gc gc.collect() try: import ctypes ctypes.CDLL("libc.so.6").malloc_trim(0) except OSError: pass # 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) self._split_view.set_show_content(False) # Defer contact selection until GTK has completed at least one # layout cycle for the freshly built widget tree. Selecting # synchronously (which triggers show_chat → set_show_content) # before the first allocation leaves the content panel with a # stale geometry and the text entry unresponsive to clicks. GLib.idle_add(self._ensure_contact_selected, priority=GLib.PRIORITY_LOW) 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) self._split_view.set_show_content(False) GLib.idle_add(self._ensure_channel_selected, priority=GLib.PRIORITY_LOW) 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 = { "Device Info": "meshy-computer-symbolic", "Radio Configuration": "meshy-preferences-system-symbolic", "Statistics": "meshy-system-monitor-symbolic", "Radio": "meshy-preferences-system-symbolic", "Actions": "meshy-system-run-symbolic", "Advert": "meshy-network-wireless-symbolic", "Auto-Add Contacts": "meshy-avatar-default-symbolic", "Location": "meshy-find-location-symbolic", "Telemetry Privacy": "meshy-preferences-other-symbolic", "Regions": "meshy-network-workgroup-symbolic", } for section_name in view.sections: row = Adw.ActionRow(title=section_name) icon = section_icons.get(section_name, "meshy-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.display_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.display_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("meshy-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("meshy-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 _ensure_contact_selected(self): """Re-select the previously active contact row, or pick the first one. In expanded mode the chat content is loaded into the content panel so it is visible immediately. In collapsed mode only the row highlight is set — loading the chat and marking messages as read is deferred until the user actually opens the chat, so that unread badges are not cleared while the chat panel is hidden. """ lb = self._contacts_view._list_box target = None if self._chat_view_inst and self._chat_view_inst._contact: pk = self._chat_view_inst._contact.public_key_hex row = lb.get_first_child() while row: if hasattr(row, "_contact") and row._contact.public_key_hex == pk: target = row break row = row.get_next_sibling() if target is None: target = lb.get_first_child() if target and hasattr(target, "_contact"): lb.select_row(target) if not self._split_view.get_collapsed(): # Expanded: both panels visible — load chat content # without changing focus or navigating to the content # panel (show_chat would call set_show_content and # grab_focus which steals focus from the sidebar). self._swap_content_view(self._chat_view) self._chat_view.set_contact(target._contact) self._set_contact_title_widget(target._contact) self._contacts_view.mark_unread(target._contact.public_key_hex, False) def _ensure_channel_selected(self): """Re-select the previously active channel row, or pick the first one. In expanded mode the channel content is loaded into the content panel so it is visible immediately. In collapsed mode only the row highlight is set — loading the channel and marking messages as read is deferred until the user actually opens the channel, so that unread badges are not cleared while the chat panel is hidden. """ lb = self._channels_view._channel_list cw = self._channels_view.get_chat_widget() target = None if cw._current_channel is not None: idx = cw._current_channel.index row = lb.get_first_child() while row: if hasattr(row, "_channel") and row._channel.index == idx: target = row break row = row.get_next_sibling() if target is None: target = lb.get_first_child() if target and hasattr(target, "_channel"): lb.select_row(target) if not self._split_view.get_collapsed(): # Expanded: both panels visible — load channel content. cw.set_channel(target._channel) self._set_channel_title_widget(target._channel) self._channels_view.mark_unread(target._channel.index, False) 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) self._chat_view.grab_focus() def _on_notification_activate(self, notification_id: str): """Navigate to the conversation a clicked notification refers to. Platform backends are responsible for foregrounding the window before this runs; this handler only performs the in-app navigation. """ self.present() if not notification_id: return if notification_id.startswith("contact-"): pubkey = notification_id[len("contact-") :] if pubkey in self._contacts_by_key: self._nav_buttons["contacts"].set_active(True) self._contacts_view.select_contact(pubkey) elif notification_id.startswith("channel-"): try: channel_idx = int(notification_id[len("channel-") :]) except ValueError: return self._nav_buttons["channels"].set_active(True) self._channels_view.select_channel(channel_idx) def _on_notification_reply(self, notification_id: str, text: str): """Handle inline reply from notification by sending the message.""" if not notification_id or not text: return if notification_id.startswith("contact-"): pubkey = notification_id[len("contact-") :] contact = self._contacts_by_key.get(pubkey) if contact: self.send_message(contact, text) elif notification_id.startswith("channel-"): try: channel_idx = int(notification_id[len("channel-") :]) except ValueError: return self.send_channel_message(channel_idx, text) 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.display_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.display_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.display_name) self._split_view.set_show_content(True) def mark_contact_read(self, public_key_hex: str): """Mark a contact's messages as read. Uses max(now, newest_message_ts) so that messages with a future timestamp (sender clock ahead) don't stay permanently unread. """ now = datetime.now().timestamp() max_ts = self._storage.get_max_message_timestamp(public_key_hex) self._storage.set_contact_last_read_at(public_key_hex, max(now, max_ts) if max_ts else now) self._storage.zero_contact_unread(public_key_hex) self._withdraw_notification(f"contact-{public_key_hex}") 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): """Mark a channel's messages as read. Uses max(now, newest_message_ts) so that messages with a future timestamp (sender clock ahead) don't stay permanently unread. """ now = datetime.now().timestamp() max_ts = self._storage.get_max_channel_message_timestamp(channel_index) self._storage.set_channel_last_read_at(channel_index, max(now, max_ts) if max_ts else now) self._storage.zero_channel_unread(channel_index) self._withdraw_notification(f"channel-{channel_index}") 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. In collapsed mode the content panel may be hidden (sidebar showing) even though a chat is loaded — skip clearing the badge when the user cannot actually see the chat. """ if not self.is_active(): return # The user is looking at Meshy now, so the "messages arrived while # disconnected" summary has served its purpose. self._withdraw_notification("sync-summary") if self._split_view.get_collapsed() and not self._split_view.get_show_content(): 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 self._update_background_status() if not name: self._companion_box.set_visible(False) return self._settings.set_string("last-device-name", name) if self._device_info.public_key: self._settings.set_string("last-device-pubkey", self._device_info.public_key) 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.spreading_factor icon = snr_to_icon(best_snr, sf) self._companion_signal_icon.set_from_icon_name(icon) self._companion_signal_icon.set_tooltip_text( f"{snr_quality_label(best_snr, sf)}, SNR: {format_number(best_snr)} dB" ) self._companion_signal_icon.set_visible(True) else: self._companion_signal_icon.set_from_icon_name("meshy-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._update_background_status() 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 _read_default_scope(self): """Ask the device for its persistent default flood scope. The device stores it in flash, so it is the source of truth. The reply is adopted by FrameHandler._handle_default_flood_scope; the device is written only when the user changes the scope in Settings. Older firmware has no handler for the command, so it is only sent when supported. """ if self._device_info.supports_default_flood_scope: self.send_frame(build_get_default_flood_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, repliable: bool = False): """Send a desktop notification using platform-specific service.""" self._notification_service.send_notification(title, body, notif_id, repliable) def _withdraw_notification(self, notif_id: str): """Withdraw a previously sent desktop notification (best effort).""" try: self._notification_service.withdraw_notification(notif_id) except Exception: log.debug("Failed to withdraw notification %s", notif_id, exc_info=True) # ─── 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, scope_override: str | None = None ): self._msg_ctrl.send_channel_message(channel_index, text, scope_override=scope_override) 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 _cli_txt_type(self, contact: Contact | None) -> int: """Pick the over-the-air text type for a CLI command.""" return cli_txt_type( contact.type if contact is not None else None, self.device_info.supports_cli, ) def send_cli_command(self, contact: Contact, command: str): """Send a CLI command to a repeater, room server, sensor or companion.""" frame = build_send_cli_command( contact.public_key, command, txt_type=self._cli_txt_type(contact) ) self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(frame) def send_cli_with_prefix(self, contact: Contact, command: str, callback): """Send a CLI command with a correlation prefix. The node firmware echoes the prefix back, allowing reliable matching of responses to commands. Returns the prefix so callers can cancel it on timeout. """ 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.public_key, prefix + command, txt_type=self._cli_txt_type(contact) ) self._msg_ctrl.sent_msg_queue.append(None) self.send_frame(frame) return prefix 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 run_cli_command(self, command: str, callback): """Run a CLI command on the connected companion (fw >= 1.18). The reply arrives as ``RESP_CODE_CLI_REPLY``; it is uncorrelated, so only one command may be in flight at a time. """ self._local_cli_callback = callback self.send_frame(build_run_cli_command(command)) def cancel_run_cli_command(self): """Drop the pending local CLI callback (e.g. on timeout or dialog close).""" self._local_cli_callback = 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)) def share_contact_zero_hop(self, contact: Contact): """Broadcast a contact's stored advert zero-hop so nearby nodes hear it.""" self._frame_handler.expect_ok( on_ok=lambda: self.show_toast(_("Sharing {}").format(contact.display_name)), on_error=lambda _err: self.show_toast(_("Could not share contact")), ) self.send_frame(build_share_contact(contact.public_key)) @property def discovered_contacts(self) -> list: return self._discovered def reload_discovered(self): if self._storage: self._discovered = self._storage.get_discovered_contacts() self._discovered_by_key = {c.public_key_hex: c for c in 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) -> bool: """Move a contact from discovered to the device's contact list. Returns False (and shows a dialog) when the device contact list is full. """ if not self._can_add_contact(): self._show_contacts_full_dialog() return False 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() return True def _temp_direct_route(self, contact): """Make the device send an anonymous request to *contact* direct, not flood. Repeaters answer the regions/owner anonymous requests only when they arrive direct-routed. The companion firmware routes by its own contact table: a contact with an unknown path (-1) is sent as a flood and silently dropped by the repeater, so the request never gets an answer. A node that is not a contact at all is sent zero-hop by the firmware on its own (it keeps it in a transient slot). Same approach as ``send_anon_req`` in the meshcore Python library: set an unknown path to zero-hop for this one request, and the caller restores it with ``reset_contact_path()`` right after sending (the frames go back-to-back, the library awaits each one). Returns the stored contact whose path was changed, or None. """ # Device contacts only: set_contact_path() on a merely discovered node # would add it to the device's contact table. stored = next( (c for c in self._contacts if c.public_key_hex == contact.public_key_hex), None ) if stored is None or getattr(stored, "path_length", -1) >= 0: return None log.debug("Anon request to %s: no path set, using zero hop", contact.public_key_hex[:12]) self._expect_quiet_ok("set zero-hop path", stored) self.set_contact_path(stored, 0) return stored def _expect_quiet_ok(self, what, contact): """Claim the OK of a frame nobody else waits for. OK responses are matched FIFO; an unclaimed one would be taken by whatever operation registers ``expect_ok`` next. """ self._frame_handler.expect_ok( on_ok=lambda: None, on_error=lambda err: log.warning( "Anon request: %s for %s failed: %s", what, contact.public_key_hex[:12], err ), ) def _send_anon_req(self, contact, frame): """Send an anonymous request frame; the zero-hop route lasts for this request only.""" zero_hop_contact = self._temp_direct_route(contact) # A successful request is answered with MSG_SENT, but it can still fail # with ERR. Claim that reply so the OK/ERR queue stays aligned and the # reset-path frame below cannot lose its OK to the request's error. self._frame_handler.expect_sent_or_error( on_sent=lambda: None, on_error=lambda err: log.warning( "Anon request to %s failed: %s", contact.public_key_hex[:12], err ), ) self.send_frame(frame) if zero_hop_contact is not None: # The firmware handles frames in order and copies the path into the # packet when sending, so resetting now does not affect the request; # the reply is matched by tag, not by path. self._expect_quiet_ok("reset path", zero_hop_contact) self.reset_contact_path(zero_hop_contact) 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"") hash_size = getattr(contact, "path_hash_size", 1) frame = build_anon_req_regions(contact.public_key, contact.path_length, path, hash_size) else: frame = build_anon_req_regions(contact.public_key, 0, b"") self._msg_ctrl.sent_msg_queue.append(f"region:{callback_key}") self._send_anon_req(contact, frame) def request_owner(self, contact, callback, use_path=False): """Send an anonymous owner-info request to a repeater. Callback receives ``"node_name\\nowner_info"`` string or ``None``. """ callback_key = contact.public_key_hex[:12] self._owner_callbacks[callback_key] = callback if use_path and getattr(contact, "path_length", -1) > 0: path = getattr(contact, "path", b"") hash_size = getattr(contact, "path_hash_size", 1) frame = build_anon_req_owner(contact.public_key, contact.path_length, path, hash_size) else: frame = build_anon_req_owner(contact.public_key, 0, b"") self._msg_ctrl.sent_msg_queue.append(f"owner:{callback_key}") self._send_anon_req(contact, 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 # CMD_SEND_TELEMETRY_REQ generates a RESP_CODE_SENT that must be # consumed so it doesn't steal another operation's SENT response. # Response arrives as PUSH_CODE_TELEMETRY_RESPONSE matched via # _telemetry_callback, so this sentinel is just absorbed. self._msg_ctrl.sent_msg_queue.append({"type": "telemetry_chat"}) 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 _contacts_at_capacity(self) -> bool: """True when the local contact list has reached the device's limit.""" limit = self._device_info.max_contacts or 32 return len(self._contacts) >= limit def _can_add_contact(self) -> bool: """Whether the device can accept another contact. When the table is full the firmware can still evict the oldest non-favourite contact if "overwrite oldest" is enabled. That flag is global (``shouldOverwriteWhenFull``), so it applies to manual adds too, not only to automatic ones. """ if not self._contacts_at_capacity(): return True if not self.get_auto_add_config().get("overwrite_oldest", False): return False # The firmware never evicts a favourite; if all are favourites the add # still fails on the device. return any(not c.is_favorite for c in self._contacts) def _show_contacts_full_dialog(self): """Explain that the device's contact table is full.""" from meshy.views import show_alert show_alert( self, _("Contact List Full"), _( "The contact list on your device is full. Remove unused " "contacts to make room for new ones." ), ) def add_contact(self, pub_key: bytes, contact_type: int, name: str) -> bool: """Add a contact manually by public key. Returns False (and shows a dialog) when the device contact list is full. """ if not self.is_contact_known(pub_key.hex()) and not self._can_add_contact(): self._show_contacts_full_dialog() return False 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() return True def import_contact(self, contact_frame: bytes) -> bool: """Import a contact from a meshcore:// URI payload. Returns False (and shows a dialog) when the device contact list is full. """ from meshy.protocol import parse_advert_frame parsed = parse_advert_frame(contact_frame) is_new = bool( parsed and parsed["name"] and not self.is_contact_known(parsed["pub_key_hex"]) ) if is_new and not self._can_add_contact(): self._show_contacts_full_dialog() return False self.send_frame(build_import_contact(contact_frame)) # Try to add to local list immediately by parsing the advert frame if parsed and parsed["name"]: pub_key = bytes.fromhex(parsed["pub_key_hex"]) contact = Contact( public_key=pub_key, name=parsed["name"], type=parsed["type"], path_length=-1, is_active=True, ) self._add_local_contact(contact) self.schedule_device_contacts_refetch() return True 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_manual_add_contacts(self, value: int): """Set whether new contacts must be added manually (0/1).""" self._self_info["manual_add_contacts"] = bool(value) 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) self._contacts_view._unread_counts.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) self.update_nav_unread_contacts() def remove_contact_with_undo(self, contact: Contact): """Remove contact from UI and DB immediately, show toast with Undo to restore.""" # Save state for undo saved_unread = self._contacts_view._unread_counts.get(contact.public_key_hex, 0) saved_messages = self._storage.get_messages(contact.public_key_hex, limit=10000) # Remove from DB and UI 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) self._contacts_view._unread_counts.pop(contact.public_key_hex, None) self._contacts_view.update_contacts(self._contacts) self.update_nav_unread_contacts() def _on_undo(): # Restore contact and messages in DB and UI self._storage.save_contact(contact) for msg in saved_messages: self._storage.save_message(contact.public_key_hex, msg) self._contacts.append(contact) self._contacts_by_key[contact.public_key_hex] = contact if saved_unread: self._contacts_view._unread_counts[contact.public_key_hex] = saved_unread self._contacts_view.update_contacts(self._contacts) self.update_nav_unread_contacts() def _on_dismiss(): # Actually remove from device self.send_frame(build_remove_contact(contact.public_key)) if contact.public_key_hex not in self._discovered_by_key: self._frame_handler.add_or_update_discovered(contact) self.show_toast_with_undo( _("{} removed").format(contact.display_name), _on_undo, _on_dismiss, ) 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) @staticmethod def _known_last_advert(contact: Contact) -> int: """The last advert time Meshy has seen for *contact* (0 if never heard). Rewriting a contact with CMD_ADD_UPDATE_CONTACT must carry it: left out, the builder sends the PC clock, and the firmware then drops every later advert of that contact stamped before it as a replay. """ return int(contact.last_seen.timestamp()) if contact.last_seen else 0 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, last_advert=self._known_last_advert(contact), ) ) 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, last_advert=self._known_last_advert(contact), ) ) # Optimistic update: reflect the change in local state immediately new_path_length, new_hash_size = _parse_path_length_byte(path_len) new_path = b"" if new_path_length < 0 else 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)) GLib.timeout_add(500, self._fetch_all_channels) def find_next_empty_channel_index(self) -> int | None: """Return the first free channel slot within the device limit, or None.""" max_channels = self._device_info.max_channels or 8 used = {ch.index for ch in self._channels if not ch.is_empty} for idx in range(max_channels): if idx not in used: return idx return None def add_channel_from_qr(self, name: str, secret: bytes, region: str | None = None): """Add a channel scanned from a QR code, using the first empty slot. When *region* is given it is registered and applied to the new channel once the device reports it (mirrors the backup-restore flow). """ secret_hex = secret.hex() for ch in self._channels: if not ch.is_empty and ch.psk_hex == secret_hex: self.show_toast(_('Channel "{}" is already added').format(ch.name)) return idx = self.find_next_empty_channel_index() if idx is None: self.show_toast(_("No empty channel slots available")) return self.set_channel(idx, name, secret) if region: if self._storage: self._storage.add_region(region) self._storage.set_channel_region(idx, region) self._pending_channel_regions[idx] = region self.show_toast(_('Channel "{}" added').format(name)) 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._channels_view._unread_counts.pop(channel.index, None) self._frame_handler.refresh_channels() self.update_nav_unread_channels() def remove_channel_with_undo(self, channel: Channel): """Remove channel from UI and DB immediately, show toast with Undo to restore.""" # Save state for undo saved_unread = self._channels_view._unread_counts.get(channel.index, 0) saved_messages = self._storage.get_channel_messages(channel.index, limit=10000) # Remove from DB, UI, and hide content self._storage.remove_channel(channel.index) self._channels = [ch for ch in self._channels if ch.index != channel.index] self._channels_view._unread_counts.pop(channel.index, None) self._frame_handler.refresh_channels() self.update_nav_unread_channels() self._split_view.set_show_content(False) def _on_undo(): # Restore channel in DB and UI self._storage.save_channel(channel) for msg in saved_messages: self._storage.save_channel_message(msg) self._channels.append(channel) self._channels.sort(key=lambda ch: ch.index) if saved_unread: self._channels_view._unread_counts[channel.index] = saved_unread self._frame_handler.refresh_channels() self.update_nav_unread_channels() def _on_dismiss(): # Actually remove from device self.send_frame(build_set_channel(channel.index, "", bytes(16))) channel_name = channel.name or _("Channel {}").format(channel.index) self.show_toast_with_undo( _("{} removed").format(channel_name), _on_undo, _on_dismiss, ) def clear_channel_messages_with_undo(self, channel: Channel): """Clear channel messages from DB immediately, show toast with Undo to restore.""" # Save messages for undo saved_messages = self._storage.get_channel_messages(channel.index, limit=10000) saved_unread = self._channels_view._unread_counts.get(channel.index, 0) # Delete from DB and refresh UI self._storage.clear_channel_messages(channel.index) self._channels_view._unread_counts[channel.index] = 0 self._channels_view._reload_channel_chat(channel) self.update_nav_unread_channels() def _on_undo(): # Restore messages in DB and reload UI for msg in saved_messages: self._storage.save_channel_message(msg) if saved_unread: self._channels_view._unread_counts[channel.index] = saved_unread self._channels_view._reload_channel_chat(channel) self.update_nav_unread_channels() def _on_dismiss(): pass # Already deleted from DB channel_name = channel.name or _("Channel {}").format(channel.index) self.show_toast_with_undo( _('Messages in "{}" cleared').format(channel_name), _on_undo, _on_dismiss, ) 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 copy_to_clipboard(self, text: str): """Copy *text* to the clipboard and persist it after the app exits.""" clipboard = self.get_clipboard() clipboard.set(text) def _on_store_done(clipboard, result, *_args): try: clipboard.store_finish(result) except Exception: log.debug("Clipboard store failed", exc_info=True) clipboard.store_async(GLib.PRIORITY_DEFAULT, None, _on_store_done) def show_toast_with_undo( self, message: str, on_undo_callback, on_dismiss_callback, timeout: int = 5, ): """Show toast with Undo button. Calls on_dismiss_callback if dismissed without undo.""" undone = [False] def _on_undo(*_args): undone[0] = True on_undo_callback() def _on_dismissed(*_args): if not undone[0]: on_dismiss_callback() toast = Adw.Toast( title=GLib.markup_escape_text(message), button_label=_("Undo"), timeout=timeout, ) toast.connect("button-clicked", _on_undo) toast.connect("dismissed", _on_dismissed) self._toast_overlay.add_toast(toast) def update_nav_unread_contacts(self): self._nav_contacts_dot.set_visible( any(v > 0 for v in self._contacts_view._unread_counts.values()) ) def update_nav_unread_channels(self): self._nav_channels_dot.set_visible( any(v > 0 for v in self._channels_view._unread_counts.values()) ) 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 known_region_names(self) -> list[str]: """Region names usable to reverse-resolve a packet's transport code. Merges discovered regions, per-channel regions, and the default flood scope. Private (``$``-prefixed) names are filtered by the resolver. """ names = set(self.get_regions()) for ch in self._channels: if ch.region: names.add(ch.region) if self._storage: scope = self._storage.get_default_scope() if scope: names.add(scope) return sorted(names) 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[i].custom_name = stored.custom_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): # A restore started from the onboarding wizard may run while the initial # sync is still in flight; wait for it to finish before writing. if self._conn_ctrl.is_syncing(): self._conn_ctrl.run_after_sync( lambda: self._backup.import_backup( self, backup, sections=sections, on_complete=on_complete ) ) return 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/000077500000000000000000000000001526212641700133535ustar00rootroot00000000000000meshy/tests/conftest.py000066400000000000000000000016741526212641700155620ustar00rootroot00000000000000import sys from pathlib import Path # Provide a minimal ``gi`` stand-in when PyGObject is not installed, so the # pure-Python unit tests can be collected without GTK/GObject available. from gi_stub import install as _install_gi_stub _install_gi_stub() # 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/gi_stub.py000066400000000000000000000076511526212641700153720ustar00rootroot00000000000000"""Lightweight stand-in for PyGObject, used only when the real ``gi`` module cannot be imported. The unit tests exercise pure-Python logic and mostly need ``gi.repository`` imports to resolve (plus a handful of GLib helpers). Real PyGObject is always preferred when available, so this stub is a no-op on machines where ``gi`` is installed. """ import importlib.abc import importlib.util import itertools import sys import types from unittest.mock import MagicMock _installed = False class _PermissiveMeta(type): """Metaclass whose classes can be subclassed and freely attribute-accessed. Calling such a class (including using it as a decorator) returns a ``MagicMock`` so that ``@Gtk.Template`` / ``@GObject.Property`` style decorators work at import time. """ def __getattr__(cls, name): if name.startswith("__") and name.endswith("__"): raise AttributeError(name) return _make_class(f"{cls.__name__}.{name}") def __call__(cls, *args, **kwargs): return MagicMock() _classes: dict[str, type] = {} def _make_class(name): cls = _classes.get(name) if cls is None: cls = _PermissiveMeta(name, (object,), {}) _classes[name] = cls return cls def _markup_escape_text(text): if text is None: return "" return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("'", "'") .replace('"', """) ) def _make_glib(): glib = types.ModuleType("gi.repository.GLib") counter = itertools.count(1) timers = {} def timeout_add(ms, callback, *args): tid = next(counter) timers[tid] = callback return tid def timeout_add_seconds(seconds, callback, *args): return timeout_add(seconds * 1000, callback, *args) def idle_add(callback, *args): try: callback(*args) except Exception: pass return next(counter) def source_remove(tid): timers.pop(tid, None) return True glib.timeout_add = timeout_add glib.timeout_add_seconds = timeout_add_seconds glib.idle_add = idle_add glib.source_remove = source_remove glib.markup_escape_text = _markup_escape_text glib.__getattr__ = lambda name: MagicMock() return glib class _StaticLoader(importlib.abc.Loader): """Loader that hands out a pre-built module object.""" def __init__(self, module): self._module = module def create_module(self, spec): return self._module def exec_module(self, module): pass class _Finder(importlib.abc.MetaPathFinder): """Re-serve the stub modules if a test removes them from ``sys.modules``.""" def __init__(self, modules): self._modules = modules def find_spec(self, fullname, path=None, target=None): module = self._modules.get(fullname) if module is None: return None return importlib.util.spec_from_loader(fullname, _StaticLoader(module)) def install(): """Install the stub into ``sys.modules`` if real PyGObject is missing.""" global _installed if _installed or "gi" in sys.modules: return try: import gi # noqa: F401 return except ImportError: pass gi = types.ModuleType("gi") gi.__path__ = [] gi.require_version = lambda *a, **k: None gi.require_versions = lambda *a, **k: None repository = types.ModuleType("gi.repository") repository.__path__ = [] repository.GLib = _make_glib() repository.Gio = MagicMock(name="gi.repository.Gio") def repo_getattr(name): cls = _make_class(name) setattr(repository, name, cls) return cls repository.__getattr__ = repo_getattr gi.repository = repository sys.modules["gi"] = gi sys.modules["gi.repository"] = repository sys.meta_path.insert(0, _Finder({"gi": gi, "gi.repository": repository})) _installed = True meshy/tests/test_app_trip_time.py000066400000000000000000000221361526212641700176240ustar00rootroot00000000000000"""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 from meshy.utils import format_number 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] # Locale-independent: "5.0 s" or "5,0 s" depending on LC_NUMERIC. assert format_number(5.0, 1) 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.py000066400000000000000000000115411526212641700214410ustar00rootroot00000000000000"""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_backup_custom_name.py000066400000000000000000000056141526212641700206310ustar00rootroot00000000000000"""Tests for custom contact names in backup/restore.""" from unittest.mock import MagicMock from meshy.backup import BackupManager from meshy.models import Contact, ContactType PUB_KEY = bytes(range(32)) PUB_KEY_HEX = PUB_KEY.hex() def _contact(name="Advert Name", custom=""): return Contact( public_key=PUB_KEY, name=name, custom_name=custom, type=ContactType.CHAT, ) def _win(storage): win = MagicMock() win.storage = storage win._storage = storage win.contacts = [] win._pending_custom_names = {} return win class TestExportCustomName: def test_export_includes_name_and_custom_name(self): backup = {} win = _win(None) win.contacts = [_contact(name="Advert", custom="Alias")] BackupManager()._export_contacts(backup, win) entry = backup["contacts"][0] assert entry["name"] == "Advert" assert entry["custom_name"] == "Alias" def test_export_uses_null_for_empty_custom_name(self): backup = {} win = _win(None) win.contacts = [_contact(name="Advert", custom="")] BackupManager()._export_contacts(backup, win) assert backup["contacts"][0]["custom_name"] is None class TestImportCustomName: def test_import_applies_to_existing_contact(self, storage): contact = _contact(custom="") storage.save_contact(contact) win = _win(storage) win.contacts = [contact] backup = {"contacts": [{"public_key": PUB_KEY_HEX, "custom_name": "Alias"}]} BackupManager()._import_contacts(win, backup) assert contact.custom_name == "Alias" assert storage.get_contact(PUB_KEY_HEX).custom_name == "Alias" assert win._pending_custom_names == {} def test_import_queues_for_unknown_contact(self, storage): win = _win(storage) win.contacts = [] backup = {"contacts": [{"public_key": PUB_KEY_HEX, "custom_name": "Alias"}]} BackupManager()._import_contacts(win, backup) assert win._pending_custom_names == {PUB_KEY_HEX: "Alias"} def test_import_clears_custom_name_when_null(self, storage): contact = _contact(custom="Alias") storage.save_contact(contact) win = _win(storage) win.contacts = [contact] backup = {"contacts": [{"public_key": PUB_KEY_HEX, "custom_name": None}]} BackupManager()._import_contacts(win, backup) assert contact.custom_name == "" assert storage.get_contact(PUB_KEY_HEX).custom_name == "" def test_import_without_key_keeps_existing_custom_name(self, storage): contact = _contact(custom="Alias") storage.save_contact(contact) win = _win(storage) win.contacts = [contact] backup = {"contacts": [{"public_key": PUB_KEY_HEX, "name": "Advert"}]} BackupManager()._import_contacts(win, backup) assert contact.custom_name == "Alias" meshy/tests/test_backup_official_compat.py000066400000000000000000000247071526212641700214420ustar00rootroot00000000000000"""Compatibility with the official MeshCore companion app backup format.""" import struct from unittest.mock import MagicMock, patch from meshy.backup import BackupManager from meshy.models import Channel, DeviceInfo from meshy.protocol import build_add_update_contact, compute_scope_key, encode_path_len PUB_KEY_HEX = "aa" * 32 def _win(contacts=None, channels=None): win = MagicMock() win._device_info = DeviceInfo() win.device_info = win._device_info win._self_info = {} win.contacts = contacts or [] win.channels = channels or [] win._pending_channel_regions = {} win.storage = MagicMock() win._settings_view = MagicMock() win.send_frame = MagicMock() win.get_auto_add_config = MagicMock(return_value={}) return win def _channel(name="Public", secret="8b3387e9c5cdea6ac9e5edbaa115cd72", region="", index=0): return Channel(index=index, name=name, psk=bytes.fromhex(secret), region=region) class TestPathLenEncoding: def test_flood(self): assert encode_path_len(-1) == 0xFF def test_direct(self): assert encode_path_len(0, 1) == 0 def test_hash_size_two(self): assert encode_path_len(3, 2) == (1 << 6) | 3 def test_hash_size_three(self): assert encode_path_len(3, 3) == (2 << 6) | 3 class TestBuildContactLastAdvert: def test_last_advert_written(self): frame = build_add_update_contact(pub_key=bytes(32), name="n", last_advert=123456) offset = 1 + 32 + 3 + 64 + 32 assert struct.unpack_from(" 0 class TestImportDeviceSettings: def test_other_settings_wires_all_fields(self): win = _win() backup = { "other_settings": { "manual_add_contacts": 1, "advert_location_policy": 1, "telemetry_mode_base": 2, "telemetry_mode_location": 1, "telemetry_mode_environment": 0, "multi_acks": 1, "path_hash_mode": 1, } } cmds = BackupManager()._import_device_settings(win, backup) for cmd in cmds: cmd() win.set_manual_add_contacts.assert_called_once_with(1) win.set_advert_loc_policy.assert_called_once_with(1) win.set_telemetry_mode.assert_called_once_with(2, 1, 0) win.set_multi_acks.assert_called_once_with(1) win.set_path_hash_mode.assert_called_once_with(1) assert win._device_info.path_hash_mode == 1 def test_auto_add_official_max_hops_key(self): win = _win() backup = {"auto_add_settings": {"auto_add_chat": True, "auto_add_max_hops": 3}} cmds = BackupManager()._import_device_settings(win, backup) for cmd in cmds: cmd() frame = win.send_frame.call_args[0][0] assert frame[2] == 3 def test_radio_settings_units(self): win = _win() backup = { "radio_settings": { "frequency": 869432, "bandwidth": 62500, "spreading_factor": 7, "coding_rate": 5, "tx_power": 14, } } cmds = BackupManager()._import_device_settings(win, backup) for cmd in cmds: cmd() # Command is sent in kHz / Hz, DeviceInfo keeps MHz / kHz. win.set_radio_params.assert_called_once_with(869432, 62500, 7, 5) assert win._device_info.radio_freq == 869432 / 1000 assert win._device_info.radio_bw == 62500 / 1000 assert win._device_info.radio_sf == 7 assert win._device_info.radio_cr == 5 def test_radio_settings_cr_old_encoding(self): win = _win() win._device_info.cr_old_encoding = True backup = { "radio_settings": { "frequency": 869432, "bandwidth": 62500, "spreading_factor": 7, "coding_rate": 5, } } cmds = BackupManager()._import_device_settings(win, backup) for cmd in cmds: cmd() win.set_radio_params.assert_called_once_with(869432, 62500, 7, 1) assert win._device_info.radio_cr == 5 def test_auto_add_legacy_max_hops_key(self): win = _win() backup = {"auto_add_settings": {"auto_add_chat": True, "max_hops": 5}} cmds = BackupManager()._import_device_settings(win, backup) for cmd in cmds: cmd() assert win.send_frame.call_args[0][0][2] == 5 class TestExportDeviceSettings: def test_includes_path_hash_mode(self): win = _win() win._device_info.path_hash_mode = 1 backup = {} BackupManager()._export_device_settings(backup, win, win._self_info, win._device_info) assert backup["other_settings"]["path_hash_mode"] == 1 def test_channels_include_region_scope(self): win = _win(channels=[_channel(name="#redhat", region="cz-jmk")]) backup = {} BackupManager()._export_channels(backup, win) entry = backup["channels"][0] assert entry["region_scope_name"] == "#cz-jmk" assert entry["region_scope_key"] == compute_scope_key("#cz-jmk").hex() def test_channel_without_region_has_null_scope(self): win = _win(channels=[_channel()]) backup = {} BackupManager()._export_channels(backup, win) assert backup["channels"][0]["region_scope_name"] is None assert backup["channels"][0]["region_scope_key"] is None class TestContactPathParsing: def test_missing_path_is_flood(self): assert BackupManager._parse_contact_path({}, 1) == (0xFF, b"") def test_null_path_is_flood(self): assert BackupManager._parse_contact_path({"out_path_list": None}, 1) == (0xFF, b"") def test_empty_path_is_direct(self): assert BackupManager._parse_contact_path({"out_path_list": ""}, 1) == (0, b"") def test_comma_separated_hops(self): path_len, path = BackupManager._parse_contact_path({"out_path_list": "32cd,e975,ec10"}, 1) assert path_len == encode_path_len(3, 2) assert path == bytes.fromhex("32cde975ec10") def test_contiguous_path_uses_hash_mode(self): path_len, path = BackupManager._parse_contact_path( {"out_path": "32cde975", "path_hash_mode": 1}, 1 ) assert path_len == encode_path_len(2, 2) assert path == bytes.fromhex("32cde975") def test_contiguous_path_falls_back_to_device_mode(self): path_len, path = BackupManager._parse_contact_path({"out_path": "32cde975ec10"}, 2) assert path_len == encode_path_len(3, 2) assert path == bytes.fromhex("32cde975ec10") def test_invalid_hex_is_flood(self): assert BackupManager._parse_contact_path({"out_path_list": "zz,zz"}, 1) == (0xFF, b"") class TestImportContacts: def test_path_and_last_advert_passed(self): win = _win() backup = { "contacts": [ { "public_key": PUB_KEY_HEX, "name": "babilom", "type": 2, "out_path_list": "32cd,e975,ec10", "last_advert": 111, "last_modified": 222, } ] } with patch("meshy.backup.build_add_update_contact") as builder: builder.return_value = b"frame" cmds, count = BackupManager()._import_contacts(win, backup) for cmd in cmds: cmd() assert count == 1 kwargs = builder.call_args.kwargs assert kwargs["path_len"] == encode_path_len(3, 2) assert kwargs["path"] == bytes.fromhex("32cde975ec10") assert kwargs["last_advert"] == 111 def test_flood_path_when_unknown(self): win = _win() backup = {"contacts": [{"public_key": PUB_KEY_HEX, "name": "x", "last_advert": 0}]} with patch("meshy.backup.build_add_update_contact") as builder: builder.return_value = b"frame" cmds, _ = BackupManager()._import_contacts(win, backup) for cmd in cmds: cmd() kwargs = builder.call_args.kwargs assert kwargs["path_len"] == 0xFF assert kwargs["last_advert"] is None class TestExportContacts: def test_direct_and_unknown_paths(self): from meshy.models import Contact direct = Contact(public_key=b"\x01" * 32, name="d", path_length=0) unknown = Contact(public_key=b"\x02" * 32, name="u", path_length=-1) routed = Contact( public_key=b"\x03" * 32, name="r", path_length=3, path=bytes.fromhex("32cde975ec10"), path_hash_size=2, ) win = _win(contacts=[direct, unknown, routed]) backup = {} BackupManager()._export_contacts(backup, win) assert backup["contacts"][0]["out_path_list"] == "" assert backup["contacts"][1]["out_path_list"] is None assert backup["contacts"][2]["out_path_list"] == "32cd,e975,ec10" assert backup["contacts"][2]["path_hash_mode"] == 1 class TestImportChannelsRegion: def test_region_restored_to_storage_and_pending(self): win = _win() backup = { "channels": [ { "name": "#redhat", "secret": "89fc27b6e7252ba264e4a45e3e9abe32", "region_scope_name": "#cz-jmk", "region_scope_key": "adeb2fe54a17f2bd04fa51071b76d7a0", } ] } cmds, count = BackupManager()._import_channels(win, backup) assert count == 1 assert win._pending_channel_regions[0] == "cz-jmk" win.storage.add_region.assert_called_once_with("cz-jmk") win.storage.set_channel_region.assert_called_once_with(0, "cz-jmk") def test_existing_channel_gets_region(self): ch = _channel(name="#redhat", secret="89fc27b6e7252ba264e4a45e3e9abe32") win = _win(channels=[ch]) backup = { "channels": [ { "name": "#redhat", "secret": "89fc27b6e7252ba264e4a45e3e9abe32", "region_scope_name": "#cz-jmk", } ] } cmds, count = BackupManager()._import_channels(win, backup) assert count == 0 assert ch.region == "cz-jmk" meshy/tests/test_ble_dedup.py000066400000000000000000000036221526212641700167120ustar00rootroot00000000000000"""Tests for BlueZ notification deduplication. Two legitimate command responses can be byte-identical (e.g. consecutive 1-byte OK frames). The dedup history must not carry over across writes, or the second response is silently dropped and the caller times out. """ from collections import deque from unittest.mock import MagicMock from meshy.platforms.linux.ble_bluez import BleTransportBlueZ def _transport() -> BleTransportBlueZ: t = object.__new__(BleTransportBlueZ) t._recent_rx = deque(maxlen=32) t._write_queue = deque() t._write_in_flight = False t._write_current = None t._write_retries = 0 t._rx_char_path = "/org/bluez/hci0/char" t._call_bluez_async = MagicMock() return t class TestRxDedup: def test_first_frame_delivered(self): t = _transport() assert t._dedup_duplicate_rx(b"\x00", now=1.0) is False def test_immediate_identical_frame_dropped(self): t = _transport() assert t._dedup_duplicate_rx(b"\x00", now=1.0) is False assert t._dedup_duplicate_rx(b"\x00", now=1.1) is True def test_identical_frame_after_window_delivered(self): t = _transport() assert t._dedup_duplicate_rx(b"\x00", now=1.0) is False assert t._dedup_duplicate_rx(b"\x00", now=1.6) is False def test_write_resets_history(self): t = _transport() assert t._dedup_duplicate_rx(b"\x00", now=1.0) is False # A new write means a fresh response is expected. t._do_write(b"\x3a\x00\x00") assert t._dedup_duplicate_rx(b"\x00", now=1.1) is False def test_do_write_starts_write_and_clears_history(self): t = _transport() t._dedup_duplicate_rx(b"\x00", now=1.0) t._do_write(b"\x3a\x00\x00") assert len(t._recent_rx) == 0 assert t._write_in_flight is True assert t._write_current == b"\x3a\x00\x00" t._call_bluez_async.assert_called_once() meshy/tests/test_chat_dividers.py000066400000000000000000000046671526212641700176110ustar00rootroot00000000000000from meshy.views.chat_mixin import ChatScrollMixin MESSAGE = 0 UNREAD = 1 DATE = 2 class _Item: def __init__(self, kind): self.kind = kind class _Store: def __init__(self, items): self.items = list(items) def get_n_items(self): return len(self.items) def get_item(self, i): return self.items[i] def splice(self, pos, n_removals, additions): self.items[pos : pos + n_removals] = additions class _Host(ChatScrollMixin): def __init__(self, items, unread_pos=-1): self._store = _Store(items) self._unread_pos = unread_pos def _kinds(host): return [it.kind for it in host._store.items] def test_future_message_dividers_removed(): # Deleting the only message of a trailing (e.g. future-dated) group # must not leave its date divider behind. host = _Host([_Item(DATE), _Item(MESSAGE), _Item(DATE), _Item(MESSAGE)]) host._store.items.pop(3) host._remove_orphaned_dividers(3) assert _kinds(host) == [DATE, MESSAGE] def test_divider_kept_when_group_still_has_messages(): host = _Host([_Item(DATE), _Item(MESSAGE), _Item(MESSAGE)]) host._store.items.pop(2) host._remove_orphaned_dividers(2) assert _kinds(host) == [DATE, MESSAGE] def test_middle_single_message_group_removed(): host = _Host( [ _Item(DATE), _Item(MESSAGE), _Item(DATE), _Item(MESSAGE), _Item(DATE), _Item(MESSAGE), ] ) host._store.items.pop(3) host._remove_orphaned_dividers(3) assert _kinds(host) == [DATE, MESSAGE, DATE, MESSAGE] def test_orphaned_unread_divider_removed_and_position_reset(): host = _Host([_Item(DATE), _Item(UNREAD), _Item(MESSAGE)], unread_pos=1) host._store.items.pop(2) host._remove_orphaned_dividers(2) assert _kinds(host) == [] assert host._unread_pos == -1 def test_message_following_keeps_divider(): host = _Host( [ _Item(DATE), _Item(MESSAGE), _Item(MESSAGE), _Item(DATE), _Item(MESSAGE), ] ) host._store.items.pop(1) host._remove_orphaned_dividers(1) assert _kinds(host) == [DATE, MESSAGE, DATE, MESSAGE] def test_leading_message_without_divider_is_noop(): host = _Host([_Item(MESSAGE), _Item(DATE), _Item(MESSAGE)]) host._store.items.pop(0) host._remove_orphaned_dividers(0) assert _kinds(host) == [DATE, MESSAGE] meshy/tests/test_cli_completion.py000066400000000000000000000132241526212641700177660ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later import pytest from meshy.cli_completion import apply_completion, available_commands, complete def test_empty_input_returns_all_commands(): assert "reboot" in complete("") assert "get" in complete("") assert "region" in complete("") def test_command_prefix_match(): assert complete("re") == ["reboot", "region"] assert complete("region") == ["region"] assert complete("advert.") == ["advert.zerohop"] def test_command_prefix_no_match(): assert complete("zzz") == [] def test_get_params_remote_excludes_serial_only(): params = complete("get ") assert "name" in params assert "radio" in params assert "prv.key" not in params def test_set_params_remote_excludes_serial_only(): params = complete("set ") assert "name" in params assert "prv.key" in params assert "freq" not in params def test_get_param_prefix(): assert complete("get na") == ["name"] assert complete("get radio") == [ "radio", "radio.fem.rxgain", "radio.fem.txgain", "radio.rxgain", ] def test_set_value_enum(): assert complete("set repeat ") == ["off", "on"] assert complete("set repeat o") == ["off", "on"] assert complete("set loop.detect m") == ["minimal", "moderate"] assert complete("set path.hash.mode ") == ["0", "1", "2"] assert complete("set multi.acks ") == ["0", "1"] assert complete("set bridge.source ") == ["logRx", "logTx"] def test_set_unknown_param_has_no_values(): assert complete("set name ") == [] def test_subcommands(): assert complete("clock ") == ["sync"] assert complete("clear ") == ["stats"] assert complete("start ") == ["ota"] assert complete("log ") == ["erase", "start", "stop"] assert complete("sensor ") == ["get", "list", "set"] assert complete("powersaving ") == ["off", "on"] def test_region_subcommands_and_values(): regions = complete("region ") assert "allowf" in regions assert "def" in regions assert "list" in regions assert complete("region list ") == ["allowed", "denied"] assert complete("region l") == ["list", "load"] def test_gps_subcommands_and_values(): assert complete("gps ") == ["advert", "off", "on", "setloc", "sync"] assert complete("gps advert ") == ["none", "prefs", "share"] assert complete("gps advert s") == ["share"] assert complete("gps s") == ["setloc", "sync"] def test_arg_position_stops_after_known_arity(): assert complete("get name ") == [] assert complete("clock sync ") == [] def test_node_type_filtering_sensor(): sensor = complete("", node_type="sensor") assert "io" in sensor assert "neighbors" not in sensor assert "powersaving" not in sensor assert "discover.neighbors" not in sensor assert "get" in sensor def test_node_type_filtering_repeater(): repeater = complete("", node_type="repeater") assert "neighbors" in repeater assert "neighbor.remove" in repeater assert "discover.neighbors" in repeater assert "powersaving" in repeater assert "eth.status" in repeater assert "io" not in repeater def test_node_type_filtering_room_excludes_repeater_only(): room = complete("", node_type="room") assert "neighbors" not in room assert "neighbor.remove" not in room assert "discover.neighbors" not in room assert "powersaving" in room assert "eth.status" in room assert "io" not in room def test_unavailable_command_still_prefix_filtered_per_type(): assert complete("ne", node_type="sensor") == [] assert complete("ne", node_type="repeater") == ["neighbor.remove", "neighbors"] @pytest.mark.parametrize( ("text", "suggestion", "expected"), [ ("", "get", "get "), ("g", "get", "get "), ("get ", "name", "get name "), ("get na", "name", "get name "), ("set repeat o", "off", "set repeat off "), ("region list a", "allowed", "region list allowed "), ], ) def test_apply_completion(text, suggestion, expected): assert apply_completion(text, suggestion) == expected def test_available_commands_unknown_type_is_permissive(): commands = available_commands(None) assert "neighbors" in commands def test_node_type_filtering_companion(): companion = complete("", node_type="companion") assert set(companion) == {"board", "get", "poweroff", "reboot", "set", "shutdown", "ver"} assert "region" not in companion assert "neighbors" not in companion def test_companion_get_params(): params = complete("get ", node_type="companion") assert "name" in params assert "radio" in params assert "radio.fem.rxgain" in params assert "wifi.status" in params assert "tz.offset" in params assert "region" not in params def test_companion_set_params(): params = complete("set ", node_type="companion") assert "name" in params assert "pin" in params assert "wifi.ssid" in params assert "wifi.clear" in params assert "fan.lo" in params assert "freq" not in params def test_companion_set_value_enum(): assert complete("set wifi.enabled ", node_type="companion") == ["0", "1"] assert complete("set cad ", node_type="companion") == ["off", "on"] assert complete("set radio.fem.txgain ", node_type="companion") == ["off", "on"] assert complete("set name ", node_type="companion") == [] def test_companion_prefix_match(): assert complete("re", node_type="companion") == ["reboot"] assert complete("get wifi.", node_type="companion") == [ "wifi.enabled", "wifi.ip", "wifi.pwd", "wifi.ssid", "wifi.status", ] meshy/tests/test_companion_setup.py000066400000000000000000000134621526212641700201750ustar00rootroot00000000000000"""Tests for freshly-flashed companion detection and setup flag storage.""" from unittest.mock import MagicMock from meshy import companion_setup from meshy.companion_setup import ( default_name_for, is_unconfigured, looks_like_default_name, should_offer, ) PUB_KEY_HEX = "0123456789abcdef" + "00" * 24 DEFAULT_NAME = PUB_KEY_HEX[:8].upper() def _self_info(name=DEFAULT_NAME, pubkey=PUB_KEY_HEX): return {"name": name, "public_key": pubkey} def _win(self_info, storage=None, connected=True, address=None): win = MagicMock() win.is_connected = connected win.self_info = self_info win.storage = storage win._onboarding_dialog_open = False if address is None: win._transport = None else: win._transport = MagicMock() win._transport.connected_address = address return win class TestDefaultNameFor: def test_uses_first_four_pubkey_bytes(self): assert default_name_for(_self_info()) == PUB_KEY_HEX[:8].upper() def test_short_or_missing_pubkey(self): assert default_name_for({"public_key": "0123"}) == "" assert default_name_for(None) == "" class TestIsUnconfigured: def test_default_name_matches_key_prefix(self): assert is_unconfigured(_self_info()) is True def test_device_id_name_is_unconfigured(self): # Some companions are provisioned with their 6-byte device/MAC id. assert is_unconfigured(_self_info(name="C2FDD39BD7DB")) is True def test_mac_marker_is_unconfigured(self): # ADVERT_NAME="@@MAC" stays literal when BLE is not initialised. assert is_unconfigured(_self_info(name="@@MAC")) is True def test_name_matching_connected_mac_is_unconfigured(self): assert ( is_unconfigured( _self_info(name="c2fdd39bd7db", pubkey=""), connected_address="C2:FD:D3:9B:D7:DB", ) is True ) def test_custom_name_with_address_is_configured(self): assert ( is_unconfigured( _self_info(name="My Node"), connected_address="C2:FD:D3:9B:D7:DB", ) is False ) def test_custom_name_is_configured(self): assert is_unconfigured(_self_info(name="My Node")) is False def test_name_case_is_ignored(self): assert is_unconfigured(_self_info(name=DEFAULT_NAME.lower())) is True def test_noname_is_configured(self): # A build-time ADVERT_NAME default must not look freshly flashed. assert is_unconfigured(_self_info(name="NONAME")) is False def test_empty_name(self): assert is_unconfigured(_self_info(name="")) is False def test_missing_or_short_pubkey(self): assert is_unconfigured(_self_info(name="My Node", pubkey="")) is False assert is_unconfigured(_self_info(name="My Node", pubkey="0123")) is False def test_none_self_info(self): assert is_unconfigured(None) is False class TestLooksLikeDefaultName: def test_known_auto_generated_names(self): assert looks_like_default_name("3208B444") is True assert looks_like_default_name("c2fdd39bd7db") is True def test_real_names(self): assert looks_like_default_name("My Node") is False assert looks_like_default_name("NONAME") is False assert looks_like_default_name("") is False # Not a known identifier length. assert looks_like_default_name("ABCD") is False assert looks_like_default_name("ABCDEF1234567890") is False class TestShouldOffer: def test_offers_for_unconfigured_device(self, storage): assert should_offer(_win(_self_info(), storage)) is True def test_skips_when_already_offered(self, storage): storage.set_setting(companion_setup._ONBOARDING_SETTING, "1") assert should_offer(_win(_self_info(), storage)) is False def test_skips_configured_device(self, storage): assert should_offer(_win(_self_info(name="My Node"), storage)) is False def test_skips_when_disconnected(self, storage): assert should_offer(_win(_self_info(), storage, connected=False)) is False def test_offers_without_storage(self): assert should_offer(_win(_self_info(), None)) is True def test_force_env_overrides_detection(self, storage, monkeypatch): monkeypatch.setenv("MESHY_FORCE_NEW_COMPANION", "1") storage.set_setting(companion_setup._ONBOARDING_SETTING, "1") assert should_offer(_win(_self_info(name="My Node"), storage)) is True def test_force_env_still_needs_connection(self, storage, monkeypatch): monkeypatch.setenv("MESHY_FORCE_NEW_COMPANION", "1") assert should_offer(_win(_self_info(), storage, connected=False)) is False class TestMaybeOffer: def test_marks_offered_only_after_showing(self, storage, monkeypatch): shown = [] monkeypatch.setattr( companion_setup, "show_new_companion_dialog", lambda _win: shown.append(True) or True ) win = _win(_self_info(), storage) companion_setup.maybe_offer(win) assert shown == [True] assert storage.get_setting(companion_setup._ONBOARDING_SETTING) == "1" def test_not_marked_when_dialog_fails(self, storage, monkeypatch): monkeypatch.setattr(companion_setup, "show_new_companion_dialog", lambda _win: False) win = _win(_self_info(), storage) companion_setup.maybe_offer(win) assert storage.get_setting(companion_setup._ONBOARDING_SETTING) == "" class TestStorageSetting: def test_roundtrip(self, storage): assert storage.get_setting("onboarding_offered") == "" storage.set_setting("onboarding_offered", "1") assert storage.get_setting("onboarding_offered") == "1" def test_default_returned_when_missing(self, storage): assert storage.get_setting("missing", "fallback") == "fallback" meshy/tests/test_contact_sorting.py000066400000000000000000000154751526212641700202000ustar00rootroot00000000000000"""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_default_scope.py000066400000000000000000000036551526212641700176120ustar00rootroot00000000000000"""Tests for reading the device's default flood scope on connect.""" from unittest.mock import MagicMock from meshy.frame_handler import FrameHandler from meshy.protocol import ( RESP_CODE_DEFAULT_FLOOD_SCOPE, CommandType, build_get_default_flood_scope, build_set_default_flood_scope, ) def _scope_frame(scope: str) -> bytes: """DEFAULT_FLOOD_SCOPE response as the device sends it (same layout as cmd 63).""" return bytes([RESP_CODE_DEFAULT_FLOOD_SCOPE]) + build_set_default_flood_scope(scope)[1:] class TestBuildGetDefaultFloodScope: def test_layout(self): assert build_get_default_flood_scope() == bytes([CommandType.GET_DEFAULT_FLOOD_SCOPE.value]) class TestDefaultFloodScopeResponse: def test_device_scope_adopted(self): win = MagicMock() FrameHandler(win)._handle_default_flood_scope(_scope_frame("cz")) win._settings_view.adopt_default_scope.assert_called_once_with("#cz") def test_no_scope_adopted_as_empty(self): win = MagicMock() FrameHandler(win)._handle_default_flood_scope(bytes([RESP_CODE_DEFAULT_FLOOD_SCOPE])) win._settings_view.adopt_default_scope.assert_called_once_with("") def test_non_hashtag_key_not_adopted(self): win = MagicMock() frame = bytearray(_scope_frame("cz")) frame[32:48] = bytes(range(16)) # key not derived from the name FrameHandler(win)._handle_default_flood_scope(bytes(frame)) win._settings_view.adopt_default_scope.assert_not_called() class TestBackupRestoresDefaultScope: def test_written_to_connected_device(self): from meshy.backup import BackupManager win = MagicMock() win.is_connected = True BackupManager()._import_local_settings(win, {"local_settings": {"default_scope": "cz"}}) win.storage.set_default_scope.assert_called_once_with("cz") win.send_frame.assert_called_once_with(build_set_default_flood_scope("cz")) meshy/tests/test_device_identity.py000066400000000000000000000047041526212641700201410ustar00rootroot00000000000000import os import pytest from meshy.storage import Storage CONTACT_HEX = "aa" * 32 PUBKEY = "cc" * 32 @pytest.fixture def data_dir(tmp_path, monkeypatch): monkeypatch.setattr("meshy.storage._data_dir", lambda: str(tmp_path)) return tmp_path # ─── db_path property ──────────────────────────────────────── class TestDbPath: def test_db_path_matches_init(self, data_dir): path = str(data_dir / "test.db") s = Storage(db_path=path) assert s.db_path == path s.close() def test_db_path_for_device(self, data_dir): path = Storage.db_path_for_device("abc123") assert "device_abc123.db" in path # ─── Migration from transport-addr DB to pubkey DB ─────────── class TestTransportAddrMigration: def test_rename_old_db_to_pubkey(self, data_dir): transport_addr = "AA:BB:CC:DD:EE:FF" old_path = Storage.db_path_for_device(transport_addr) s = Storage(db_path=old_path) s._db.execute( "INSERT INTO contacts (public_key_hex, name, type, flags, path_length, path, path_hash_size) " "VALUES (?, 'Alice', 1, 0, 0, X'', 1)", (CONTACT_HEX,), ) s._db.commit() s.close() assert os.path.exists(old_path) pubkey_path = Storage.db_path_for_device(PUBKEY) os.rename(old_path, pubkey_path) for suffix in ("-wal", "-shm"): src = old_path + suffix if os.path.exists(src): os.rename(src, pubkey_path + suffix) assert not os.path.exists(old_path) assert os.path.exists(pubkey_path) s2 = Storage(db_path=pubkey_path) contacts = s2.get_contacts() assert len(contacts) == 1 assert contacts[0].name == "Alice" s2.close() def test_pubkey_db_already_exists_no_rename(self, data_dir): pubkey_path = Storage.db_path_for_device(PUBKEY) s = Storage(db_path=pubkey_path) s._db.execute( "INSERT INTO contacts (public_key_hex, name, type, flags, path_length, path, path_hash_size) " "VALUES (?, 'Bob', 1, 0, 0, X'', 1)", (CONTACT_HEX,), ) s._db.commit() s.close() s2 = Storage(db_path=pubkey_path) contacts = s2.get_contacts() assert len(contacts) == 1 assert contacts[0].name == "Bob" s2.close() meshy/tests/test_discover.py000066400000000000000000000342271526212641700166120ustar00rootroot00000000000000"""Tests for discover nearby + anonymous requests (regions, owner) in FrameHandler.""" import struct from unittest.mock import MagicMock from meshy.frame_handler import FrameHandler from meshy.message_controller import MessageController from meshy.protocol import ( RESP_CODE_BINARY_RESPONSE, RESP_CODE_CONTROL_DATA, RESP_CODE_ERR, RESP_CODE_OK, RESP_CODE_SENT, ) from meshy.storage import Storage PUB_KEY = bytes(range(32)) PUB_KEY_HEX = PUB_KEY.hex() def _make_win(storage): win = MagicMock() win._storage = storage win._contacts = [] win._contacts_by_key = {} win._conn_ctrl.contacts_iter_busy = False win._conn_ctrl.synced_contact_keys = None win._conn_ctrl.sync_contact_current = 0 win._conn_ctrl.syncing = False win._device_info = None win._login_callback = None win._ignore_next_err = False win._msg_ctrl = MessageController(win) win._region_callbacks = {} win._owner_callbacks = {} win._pending_binary_requests = {} win._neighbors_callback = None win._acl_callback = None win._telemetry_callback = None win._table_full_error = False win._discover_callback = None return win def _build_binary_response(tag: int, text: str) -> bytes: """Build a BINARY_RESPONSE (0x8C) frame. Format: [0x8C][reserved(1)][tag(4)][reserved(4)][text_utf8] tag at data[2:6], text at data[10:]. """ data = bytes([RESP_CODE_BINARY_RESPONSE, 0x00]) data += struct.pack(" bytes: """Build a SENT response (0x06) frame. Format: [0x06][type(1)][ack(4)][timeout(2)] """ data = bytes([RESP_CODE_SENT, 0x00]) data += bytes.fromhex(ack_hex) data += struct.pack(" connected") cb.assert_called_once_with("> connected") assert win._local_cli_callback is None def test_no_pending_callback_is_noop(self): win = _make_win(MagicMock()) win._local_cli_callback = None fh = FrameHandler(win) fh._handle_cli_reply(bytes([RESP_CODE_CLI_REPLY]) + b"OK") assert win._local_cli_callback is None class TestMessageWaitingDuringSync: """MSG_WAITING must not pull the queue before the channels are loaded.""" def test_deferred_while_initial_sync_runs(self, storage): win = _make_win(storage) win._conn_ctrl.syncing = True fh = FrameHandler(win) fh._handle_msg_waiting(b"\x83") win._send_sync_next_message.assert_not_called() def test_pulled_once_sync_is_done(self, storage): win = _make_win(storage) win._conn_ctrl.syncing = False fh = FrameHandler(win) fh._handle_msg_waiting(b"\x83") win._send_sync_next_message.assert_called_once() class TestContactsFullPush: """PUSH_CODE_CONTACTS_FULL notifies that the device could not store a contact.""" def test_push_code_value(self): assert PUSH_CODE_CONTACTS_FULL == 0x90 def test_dispatches_to_toast(self, storage): win = _make_win(storage) fh = FrameHandler(win) fh.handle_frame(bytes([PUSH_CODE_CONTACTS_FULL])) win.show_toast.assert_called_once() def test_toast_is_rate_limited(self, storage): win = _make_win(storage) fh = FrameHandler(win) fh._handle_contacts_full(b"\x90") fh._handle_contacts_full(b"\x90") win.show_toast.assert_called_once() meshy/tests/test_i18n.py000066400000000000000000000015341526212641700155460ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Tests for locale-aware number formatting helpers.""" import locale from meshy.i18n import format_localized, format_number class TestFormatNumber: def test_decimal_places(self): sep = locale.localeconv()["decimal_point"] assert format_number(1.5, 1) == f"1{sep}5" def test_sign(self): assert format_number(3.0, 0, sign=True).startswith("+") class TestFormatLocalized: def test_fills_translated_template_with_locale_number(self): sep = locale.localeconv()["decimal_point"] assert format_localized("Score: %.2f", 1.234) == f"Score: 1{sep}23" def test_matches_format_number(self): assert format_localized("Weight: %.1f", 2.5) == f"Weight: {format_number(2.5, 1)}" meshy/tests/test_imports.py000066400000000000000000000025401526212641700164620ustar00rootroot00000000000000"""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_map_clustering.py000066400000000000000000000242671526212641700200130ustar00rootroot00000000000000import importlib import random import sys import time import types from unittest.mock import MagicMock from meshy.models import Contact, ContactType def _import_map_view_functions(): """Import pure functions from map_view without triggering GTK/Shumate imports. The views package __init__.py requires Shumate which is not available in the test environment, so we import map_view directly, bypassing the package init. """ if "meshy.views" not in sys.modules: views_pkg = types.ModuleType("meshy.views") views_pkg.__path__ = [] views_pkg.__package__ = "meshy.views" sys.modules["meshy.views"] = views_pkg loader = importlib.machinery.SourceFileLoader( "meshy.views.map_view", str( __import__("pathlib").Path(__file__).resolve().parent.parent / "src" / "views" / "map_view.py" ), ) spec = importlib.util.spec_from_loader("meshy.views.map_view", loader) mod = importlib.util.module_from_spec(spec) _original_gi = sys.modules.get("gi") gi_mock = types.ModuleType("gi") gi_mock.require_version = lambda *a, **kw: None repo_mock = types.ModuleType("gi.repository") for name in ("Adw", "Gdk", "GLib", "Gtk", "Pango", "Shumate"): setattr(repo_mock, name, MagicMock()) gi_mock.repository = repo_mock sys.modules["gi"] = gi_mock sys.modules["gi.repository"] = repo_mock try: spec.loader.exec_module(mod) finally: if _original_gi is not None: sys.modules["gi"] = _original_gi else: sys.modules.pop("gi", None) sys.modules.pop("gi.repository", None) sys.modules["meshy.views.map_view"] = mod return mod _mv = _import_map_view_functions() _compute_clusters = _mv._compute_clusters _lat_lon_to_pixels = _mv._lat_lon_to_pixels _pixels_to_lat_lon = _mv._pixels_to_lat_lon _rebuild_clustered_markers = _mv._rebuild_clustered_markers def _make_contact(lat, lon, name="test", pk=None, ctype=ContactType.CHAT): if pk is None: pk = random.randbytes(32) return Contact(public_key=pk, name=name, type=ctype, latitude=lat, longitude=lon) class TestLatLonPixelConversion: def test_roundtrip(self): lat, lon, zoom = 50.08, 14.43, 12 px, py = _lat_lon_to_pixels(lat, lon, zoom) lat2, lon2 = _pixels_to_lat_lon(px, py, zoom) assert abs(lat - lat2) < 1e-6 assert abs(lon - lon2) < 1e-6 def test_roundtrip_negative_coords(self): lat, lon, zoom = -33.87, -151.21, 10 px, py = _lat_lon_to_pixels(lat, lon, zoom) lat2, lon2 = _pixels_to_lat_lon(px, py, zoom) assert abs(lat - lat2) < 1e-6 assert abs(lon - lon2) < 1e-6 def test_zoom_affects_pixel_distance(self): a = _make_contact(50.0, 14.0) b = _make_contact(50.1, 14.0) _, ay5 = _lat_lon_to_pixels(a.latitude, a.longitude, 5) _, by5 = _lat_lon_to_pixels(b.latitude, b.longitude, 5) _, ay10 = _lat_lon_to_pixels(a.latitude, a.longitude, 10) _, by10 = _lat_lon_to_pixels(b.latitude, b.longitude, 10) assert abs(by10 - ay10) > abs(by5 - ay5) class TestComputeClusters: def test_empty(self): assert _compute_clusters([], 10) == [] def test_single_contact(self): c = _make_contact(50.0, 14.0) clusters = _compute_clusters([c], 10) assert len(clusters) == 1 assert clusters[0] == [c] def test_close_contacts_cluster_together(self): a = _make_contact(50.0, 14.0, name="A") b = _make_contact(50.0, 14.00001, name="B") clusters = _compute_clusters([a, b], 10) assert len(clusters) == 1 assert len(clusters[0]) == 2 def test_distant_contacts_separate(self): a = _make_contact(50.0, 14.0, name="A") b = _make_contact(52.0, 16.0, name="B") clusters = _compute_clusters([a, b], 10) assert len(clusters) == 2 def test_zooming_in_splits_clusters(self): a = _make_contact(50.0, 14.0, name="A") b = _make_contact(50.005, 14.005, name="B") clusters_low = _compute_clusters([a, b], 5) clusters_high = _compute_clusters([a, b], 15) assert len(clusters_low) <= len(clusters_high) def test_boundary_contacts_separate(self): """Contacts straddling a grid cell boundary end up in different clusters.""" c1 = _make_contact(50.0, 14.0, name="C1") px, py = _lat_lon_to_pixels(50.0, 14.0, 10) cell_size = 60 boundary_px = (int(px // cell_size) + 1) * cell_size lat2, lon2 = _pixels_to_lat_lon(boundary_px + 1, py, 10) c2 = _make_contact(lat2, lon2, name="C2") clusters = _compute_clusters([c1, c2], 10) assert len(clusters) == 2 def test_all_contacts_present_in_output(self): contacts = [_make_contact(50 + i * 0.01, 14 + i * 0.01, name=f"C{i}") for i in range(20)] clusters = _compute_clusters(contacts, 10) result_contacts = [c for cluster in clusters for c in cluster] assert len(result_contacts) == 20 assert set(c.public_key_hex for c in result_contacts) == set( c.public_key_hex for c in contacts ) def test_performance_15k_contacts(self): rng = random.Random(42) contacts = [ _make_contact( rng.uniform(35.0, 60.0), rng.uniform(-10.0, 30.0), name=f"N{i}", ) for i in range(15_000) ] start = time.monotonic() clusters = _compute_clusters(contacts, 10) elapsed = time.monotonic() - start assert elapsed < 0.5 total = sum(len(c) for c in clusters) assert total == 15_000 class TestRebuildClusteredMarkers: def _make_mock_marker_layer(self): layer = MagicMock() layer._markers = [] def add_marker(m): layer._markers.append(m) def remove_all(): layer._markers.clear() layer.add_marker = MagicMock(side_effect=add_marker) layer.remove_all = MagicMock(side_effect=remove_all) return layer def _make_marker(self, contact, zoom): m = MagicMock() m.contact = contact m.set_location = MagicMock() return m def test_basic_rebuild(self): contacts = [_make_contact(50.0, 14.0, name="A"), _make_contact(52.0, 16.0, name="B")] layer = self._make_mock_marker_layer() zoom_ref = [-1] hash_ref = [None] result = _rebuild_clustered_markers( contacts, layer, 10.0, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) assert result is False assert layer.remove_all.called assert layer.add_marker.call_count >= 2 def test_skip_on_same_zoom_bucket(self): contacts = [_make_contact(50.0, 14.0, name="A")] layer = self._make_mock_marker_layer() zoom_ref = [10.0] hash_ref = [None] _rebuild_clustered_markers( contacts, layer, 10.1, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) first_call_count = layer.remove_all.call_count _rebuild_clustered_markers( contacts, layer, 10.2, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) assert layer.remove_all.call_count == first_call_count def test_rebuild_on_zoom_bucket_change(self): contacts = [_make_contact(50.0, 14.0, name="A")] layer = self._make_mock_marker_layer() zoom_ref = [-1] hash_ref = [None] _rebuild_clustered_markers( contacts, layer, 10.0, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) assert zoom_ref[0] == 10.0 _rebuild_clustered_markers( contacts, layer, 10.6, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) assert zoom_ref[0] == 10.5 def test_cluster_hash_skips_redundant_rebuild(self): contacts = [_make_contact(50.0, 14.0, name="A")] layer = self._make_mock_marker_layer() zoom_ref = [-1] hash_ref = [None] _rebuild_clustered_markers( contacts, layer, 10.0, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) assert hash_ref[0] is not None zoom_ref[0] = -1 first_hash = hash_ref[0] _rebuild_clustered_markers( contacts, layer, 10.0, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, ) assert hash_ref[0] == first_hash def test_viewport_culling(self): inside = _make_contact(50.0, 14.0, name="Inside") outside = _make_contact(10.0, -50.0, name="Outside") layer = self._make_mock_marker_layer() zoom_ref = [-1] hash_ref = [None] _rebuild_clustered_markers( [inside, outside], layer, 10.0, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, visible_bounds=(49.0, 51.0, 13.0, 15.0), ) assert layer.add_marker.call_count == 1 def test_extra_markers(self): contacts = [_make_contact(50.0, 14.0, name="A")] layer = self._make_mock_marker_layer() zoom_ref = [-1] hash_ref = [None] extra = MagicMock() _rebuild_clustered_markers( contacts, layer, 10.0, [], zoom_ref, hash_ref, self._make_marker, lambda c, lat, lon: None, extra_markers_fn=lambda z: [extra], ) assert layer.add_marker.call_count == 2 meshy/tests/test_map_hop_path.py000066400000000000000000000103001526212641700174150ustar00rootroot00000000000000"""Tests for MapView.build_hop_list (single-route hop list construction). This exercises the pure list-building/interpolation logic shared by the contacts, device (trace) and channel path-on-map dialogs after the draw_hop_path refactor. Only build_hop_list is tested here; the actual marker/layer rendering requires a live GTK/Shumate stack. """ import inspect import pytest from meshy.views.map_view import MapView # MapView is a @Gtk.Template-decorated class. Under the lightweight ``gi`` stub # (used when PyGObject is not installed) the decorator turns the class into a # MagicMock, so its real static methods are unavailable. Skip in that case; the # tests run fully when real PyGObject is present. _HAS_REAL_MAPVIEW = inspect.isfunction(getattr(MapView, "build_hop_list", None)) pytestmark = pytest.mark.skipif( not _HAS_REAL_MAPVIEW, reason="MapView is mocked by the gi stub (PyGObject not installed)", ) class _Contact: def __init__(self, lat=None, lon=None, name="c", type_="chat"): self.latitude = lat self.longitude = lon self.display_name = name self.type = type_ self.type_label = type_ @property def has_location(self): return self.latitude is not None and self.longitude is not None class _Device: def __init__(self, lat=None, lon=None, name="me"): self.latitude = lat self.longitude = lon self.name = name def test_returns_none_when_no_positions(): device = _Device() hops = MapView.build_hop_list(device, [_Contact(), _Contact()]) assert hops is None def test_basic_route_with_self_and_target(): device = _Device(50.0, 14.0) mid = _Contact(50.1, 14.1, "mid") target = _Contact(50.2, 14.2, "dst") hops = MapView.build_hop_list(device, [mid], target_contact=target) assert hops is not None # self node inserted first (contact=None, prefix=None), then mid, then target assert hops[0][2] is None and hops[0][3] is None assert hops[0][0] == 50.0 and hops[0][1] == 14.0 assert hops[1][2] is mid assert hops[-1][2] is target def test_self_not_inserted_when_no_device_location(): device = _Device() # no coords c = _Contact(50.0, 14.0) hops = MapView.build_hop_list(device, [c]) # No self node prepended assert all(not (h[2] is None and h[3] is None) for h in hops) assert hops[0][2] is c def test_snr_values_carried_through(): device = _Device() c1 = _Contact(50.0, 14.0) c2 = _Contact(50.1, 14.1) hops = MapView.build_hop_list(device, [c1, c2], snr_values=[-3.0, -7.5]) assert hops[0][4] == -3.0 assert hops[1][4] == -7.5 def test_unknown_hops_interpolated_between_known(): device = _Device() a = _Contact(50.0, 14.0) unknown = _Contact(None, None, "?") # no location b = _Contact(50.2, 14.2) hops = MapView.build_hop_list(device, [a, unknown, b], hop_prefixes=[None, "ab", None]) # Middle hop should now have interpolated coordinates (not None) assert hops[1][0] is not None assert hops[1][1] is not None # And it stays associated with its prefix so it renders as "unknown" assert hops[1][3] == "ab" def test_skip_far_companion_drops_distant_self(): # Device far away (other hemisphere) from the only located hop device = _Device(-40.0, 170.0) c = _Contact(50.0, 14.0) hops = MapView.build_hop_list(device, [c], skip_far_companion=True) # Self node dropped -> first hop is the contact assert hops[0][2] is c def test_keep_far_companion_when_disabled(): device = _Device(-40.0, 170.0) c = _Contact(50.0, 14.0) hops = MapView.build_hop_list(device, [c], skip_far_companion=False) # Self node kept despite distance assert hops[0][2] is None and hops[0][3] is None def test_append_self_puts_companion_last(): # Inbound routes (received channel messages) must attach the companion to # the last hop, which requires the self node at the end of the list. device = _Device(50.0, 14.0) r1 = _Contact(50.1, 14.1, "r1") r2 = _Contact(50.2, 14.2, "r2") hops = MapView.build_hop_list(device, [r1, r2], append_self=True) assert hops[0][2] is r1 assert hops[-1][2] is None and hops[-1][3] is None assert hops[-1][0] == 50.0 and hops[-1][1] == 14.0 meshy/tests/test_map_last_seen.py000066400000000000000000000026241526212641700176020ustar00rootroot00000000000000"""Tests for the map view's Last Seen time-window filter.""" from datetime import datetime, timedelta from meshy.models import Contact, ContactType from test_map_clustering import _import_map_view_functions _mv = _import_map_view_functions() _within_last_seen = _mv._within_last_seen _NOW = datetime(2026, 9, 25, 12, 0, 0) def _contact(last_seen): return Contact( public_key=bytes(32), name="test", type=ContactType.CHAT, last_seen=last_seen, ) def test_no_cutoff_shows_everything_including_never_seen(): assert _within_last_seen(_contact(None), _NOW, None) assert _within_last_seen(_contact(_NOW - timedelta(days=400)), _NOW, None) def test_never_seen_hidden_when_window_active(): assert not _within_last_seen(_contact(None), _NOW, _NOW - timedelta(days=1)) def test_within_window(): cutoff = _NOW - timedelta(hours=1) assert _within_last_seen(_contact(_NOW - timedelta(minutes=30)), _NOW, cutoff) def test_outside_window(): cutoff = _NOW - timedelta(hours=1) assert not _within_last_seen(_contact(_NOW - timedelta(hours=2)), _NOW, cutoff) def test_exact_cutoff_is_included(): cutoff = _NOW - timedelta(days=1) assert _within_last_seen(_contact(cutoff), _NOW, cutoff) def test_future_timestamp_is_clamped_to_now(): cutoff = _NOW - timedelta(hours=1) assert _within_last_seen(_contact(_NOW + timedelta(days=5)), _NOW, cutoff) meshy/tests/test_map_nodes.py000066400000000000000000000063551526212641700167420ustar00rootroot00000000000000"""Tests for strict validation of online node database import.""" from meshy.map_nodes import _matches_preset, _parse_node, _parse_nodes, _sanitize_name from meshy.models import Contact, ContactType _CUTOFF = 1_700_000_000 class _DevInfo: radio_freq = 869.618 radio_bw = 62.5 radio_sf = 8 radio_cr = 8 def _mknode(**overrides): node = { "pk": bytes(range(32)), "t": 1, "n": "test node", "la": 1_700_000_000, "lat": 49.1, "lon": 14.4, "ud": 1_800_000_000, "p": {"freq": 869.618, "bw": 62.5, "sf": 8, "cr": 8}, } node.update(overrides) return node def _parse(**overrides): return _parse_node(_mknode(**overrides), _DevInfo(), _CUTOFF, Contact, ContactType) def test_valid_node_parsed(): contact = _parse() assert isinstance(contact, Contact) assert contact.public_key == bytes(range(32)) assert contact.name == "test node" assert contact.type == ContactType.CHAT def test_wrong_preset_rejected(): assert _parse(p={"freq": 433.0, "bw": 125.0, "sf": 12, "cr": 5}) is None def test_pk_must_be_32_bytes(): assert _parse(pk=bytes(31)) is None assert _parse(pk=bytes(33)) is None assert _parse(pk="A" * 32) is None assert _parse(pk=12345) is None def test_name_must_be_string_and_sanitized(): assert _parse(n=123) is None assert _parse(n=["a"]) is None contact = _parse(n="bad\x01\x02name\x7f") assert contact is not None assert "\x01" not in contact.name assert "\x7f" not in contact.name def test_name_length_capped(): contact = _parse(n="x" * 500) assert contact is not None assert len(contact.name) <= 64 def test_type_must_be_int(): assert _parse(t="chat") is None assert _parse(t=[1]) is None assert _parse(t=True) is None contact = _parse(t=3) assert contact is not None assert contact.type == ContactType.ROOM def test_coordinates_validated(): assert _parse(lat="49") is None assert _parse(lon=1e12) is None assert _parse(lat=float("nan")) is None contact = _parse(lat=None, lon=None) assert contact is not None assert contact.latitude is None def test_old_nodes_rejected(): assert _parse(ud=1_000_000_000) is None def test_last_seen_tolerates_bad_timestamp(): contact = _parse(la=2**63) assert contact is not None assert contact.last_seen is None def test_parse_nodes_skips_malformed_without_raising(): data = [_mknode(), 42, "garbage", _mknode(pk=bytes(10)), _mknode()] contacts = _parse_nodes(data, _DevInfo()) assert len(contacts) == 2 assert all(isinstance(c, Contact) for c in contacts) def test_parse_nodes_requires_list(): assert _parse_nodes({"pk": b"x"}, _DevInfo()) == [] assert _parse_nodes(None, _DevInfo()) == [] def test_sanitize_name(): assert _sanitize_name(" plain ") == " plain " assert _sanitize_name(5) is None assert len(_sanitize_name("y" * 200)) == 64 def test_matches_preset_rejects_malformed(): assert _matches_preset(None, _DevInfo()) is False assert _matches_preset("bad", _DevInfo()) is False assert _matches_preset({"freq": "869.618"}, _DevInfo()) is False assert _matches_preset({"freq": 869.618, "bw": 62.5, "sf": 8, "cr": 8}, _DevInfo()) is True meshy/tests/test_mesh_crypto.py000066400000000000000000000166151526212641700173310ustar00rootroot00000000000000import struct from meshy.mesh_crypto import ( PAYLOAD_ADVERT, PAYLOAD_GROUP_TEXT, calc_transport_code, channel_hash, decode_log_packet, derive_scope_key, parse_advert_payload, resolve_region_name, ) 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.py000066400000000000000000001026211526212641700166270ustar00rootroot00000000000000import struct from meshy.meshcore_packets import PacketType from meshy.protocol import ( ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ANON_REQ_OWNER, ANON_REQ_REGIONS, CMD_GET_ALLOWED_REPEAT_FREQ, CMD_GET_CONTACT_BY_KEY, CMD_LOGOUT, CMD_RUN_CLI_COMMAND, 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_CLI_REPLY, RESP_CODE_CONTACT, RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CUSTOM_VARS, RESP_CODE_DEVICE_INFO, RESP_CODE_ERR, RESP_CODE_SENT, TXT_TYPE_CLI_COMMAND, TXT_TYPE_CLI_DATA, TXT_TYPE_PLAIN, _parse_path_length_byte, _parse_snr, _truncate_utf8, build_anon_req_owner, build_anon_req_regions, build_get_allowed_repeat_freq, build_get_contact_by_key, build_logout, build_run_cli_command, build_send_cli_command, build_send_discover_req, build_send_text_msg, build_set_advert_latlon, build_set_radio_tx_power, cli_txt_type, decode_path_len_byte, hashtag_channel_psk, max_channel_text_bytes, max_dm_text_bytes, parse_advert_appdata, parse_advert_path_response, parse_allowed_repeat_freq, parse_batt_and_storage, parse_cayenne_lpp, parse_channel_message, parse_channel_url_params, parse_cli_reply, parse_contact_frame, parse_contact_message, parse_custom_vars, parse_device_info, parse_discover_response, parse_error_response, parse_meshcore_uri, 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 class TestDecodePathLenByte: def test_direct(self): assert decode_path_len_byte(0) == (0, 1) def test_normal_1byte_hash(self): assert decode_path_len_byte(3) == (3, 1) def test_2byte_hash(self): assert decode_path_len_byte(0b01_000010) == (2, 2) def test_3byte_hash(self): assert decode_path_len_byte(0b10_000001) == (1, 3) def test_raw_no_clamping_for_255(self): # Unlike _parse_path_length_byte, the raw decode does not treat 255 as # flood; it returns the raw hop_count/hash_size for byte-offset math. assert decode_path_len_byte(255) == (63, 4) def test_raw_no_clamping_for_reserved_hash_size(self): # hash_size 4 (bits 11) is reserved but the raw decode still reports it. assert decode_path_len_byte(0b11_000001) == (1, 4) class TestParseAdvertAppdata: def test_empty_when_no_flags(self): assert parse_advert_appdata(b"", 0, 0x00) == (None, None, "") def test_name_only(self): buf = b"MyNode\x00" assert parse_advert_appdata(buf, 0, 0x80) == (None, None, "MyNode") def test_name_runs_to_end_without_null(self): assert parse_advert_appdata(b"Node", 0, 0x80) == (None, None, "Node") def test_latlon(self): lat_raw = (50_000_000).to_bytes(4, "little", signed=True) lon_raw = (14_000_000).to_bytes(4, "little", signed=True) lat, lon, name = parse_advert_appdata(lat_raw + lon_raw, 0, 0x10) assert round(lat, 6) == 50.0 assert round(lon, 6) == 14.0 assert name == "" def test_null_island_treated_as_none(self): buf = bytes(8) # lat=0, lon=0 assert parse_advert_appdata(buf, 0, 0x10) == (None, None, "") def test_latlon_feat_and_name(self): lat_raw = (50_000_000).to_bytes(4, "little", signed=True) lon_raw = (14_000_000).to_bytes(4, "little", signed=True) feat1 = b"\x34\x12" feat2 = b"\x78\x56" buf = lat_raw + lon_raw + feat1 + feat2 + b"Node\x00" flags = 0x10 | 0x20 | 0x40 | 0x80 lat, lon, name = parse_advert_appdata(buf, 0, flags) assert round(lat, 6) == 50.0 assert round(lon, 6) == 14.0 assert name == "Node" def test_respects_offset(self): buf = b"\xaa\xbb" + b"Name\x00" assert parse_advert_appdata(buf, 2, 0x80) == (None, None, "Name") # ─── 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(" 1.18.0") == "> 1.18.0" def test_trailing_nul_stripped(self): assert parse_cli_reply(bytes([RESP_CODE_CLI_REPLY]) + b"OK\x00") == "OK" def test_empty(self): assert parse_cli_reply(bytes([RESP_CODE_CLI_REPLY])) == "" def test_wrong_code(self): assert parse_cli_reply(bytes([0x00, 0x41])) is None def test_no_data(self): assert parse_cli_reply(b"") is None class TestBuildSetAdvertLatlon: def test_encoding(self): frame = build_set_advert_latlon(50.123456, 14.456789) assert frame[0] == CMD_SET_ADVERT_LATLON lat = struct.unpack_from("> 6) == 1 # hash_mode = hash_size - 1 assert frame[35:] == bytes([0x05, 0x06, 0x03, 0x04, 0x01, 0x02]) def test_four_byte_hops(self): path = bytes([0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80]) frame = build_anon_req_regions(self.PUB_KEY, 2, path, path_hash_size=4) header = frame[34] assert header & 0x3F == 2 assert (header >> 6) == 3 # hash_mode = 4 - 1 assert frame[35:] == bytes([0x50, 0x60, 0x70, 0x80, 0x10, 0x20, 0x30, 0x40]) def test_hop_count_capped_at_63(self): frame = build_anon_req_regions(self.PUB_KEY, 100, bytes(100), path_hash_size=1) assert frame[34] & 0x3F == 63 def test_default_hash_size_is_one(self): path = bytes([0xAA, 0xBB]) frame = build_anon_req_regions(self.PUB_KEY, 2, path) assert (frame[34] >> 6) == 0 # hash_mode = 1 - 1 = 0 assert frame[35:] == bytes([0xBB, 0xAA]) class TestBuildAnonReqOwner: PUB_KEY = bytes(range(32)) CMD_SEND_ANON_REQ = 57 def test_no_path(self): frame = build_anon_req_owner(self.PUB_KEY, 0, b"") assert frame[0] == self.CMD_SEND_ANON_REQ assert frame[1:33] == self.PUB_KEY assert frame[33] == ANON_REQ_OWNER # 0x02 assert frame[34] == 0 # hop_count=0, hash_mode=0 assert len(frame) == 35 def test_type_byte_differs_from_regions(self): frame_owner = build_anon_req_owner(self.PUB_KEY, 0, b"") frame_regions = build_anon_req_regions(self.PUB_KEY, 0, b"") assert frame_owner[33] == ANON_REQ_OWNER # 0x02 assert frame_regions[33] == ANON_REQ_REGIONS # 0x01 # Rest of frame identical assert frame_owner[:33] == frame_regions[:33] assert frame_owner[34:] == frame_regions[34:] def test_single_byte_hops_reversed(self): path = bytes([0xAA, 0xBB, 0xCC]) frame = build_anon_req_owner(self.PUB_KEY, 3, path, path_hash_size=1) assert frame[34] == 3 # hop_count=3, hash_mode=0 assert frame[35:] == bytes([0xCC, 0xBB, 0xAA]) def test_two_byte_hops_reversed(self): path = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) frame = build_anon_req_owner(self.PUB_KEY, 3, path, path_hash_size=2) header = frame[34] assert header & 0x3F == 3 assert (header >> 6) == 1 # hash_mode = 2 - 1 def test_hop_count_capped_at_63(self): frame = build_anon_req_owner(self.PUB_KEY, 100, bytes(100), path_hash_size=1) assert frame[34] & 0x3F == 63 class TestBuildSendDiscoverReq: CMD_SEND_CONTROL_DATA = 55 def test_defaults(self): frame = build_send_discover_req(tag=0x12345678) assert frame[0] == self.CMD_SEND_CONTROL_DATA assert frame[1] == 0x81 # 0x80 (DISCOVER_REQ) | 0x01 (prefix_only) assert frame[2] == 0xFF # type_filter = all assert struct.unpack(" 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.py000066400000000000000000000306421526212641700163020ustar00rootroot00000000000000import 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 # 4 reserved zero bytes (perm_mask on responder: ~0x00 = 0xFF) assert frame[34:38] == bytes(4) # 4 random bytes for unique packet hash assert len(frame) == 42 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 def test_random_bytes_differ(self): """Each call should produce different random bytes for unique packet hash.""" pub_key = bytes(range(32)) f1 = build_get_telemetry(pub_key) f2 = build_get_telemetry(pub_key) # Random portion at bytes 38..41 should (almost certainly) differ assert f1[38:42] != f2[38:42] # ─── 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_shared_uri.py000066400000000000000000000053211526212641700171120ustar00rootroot00000000000000# Copyright 2026, Jiri Eischmann and the meshy contributors # SPDX-License-Identifier: GPL-3.0-or-later """Tests for shared channel URI parsing/formatting and region-scope handling.""" from meshy.shared_uri import ( format_shared_channel, normalize_region_scope, parse_shared_channel, ) SECRET = "ab" * 16 class TestNormalizeRegionScope: def test_none_and_empty(self): assert normalize_region_scope(None) is None assert normalize_region_scope("") is None assert normalize_region_scope(" ") is None def test_strips_leading_hash(self): assert normalize_region_scope("#cz-jmk") == "cz-jmk" assert normalize_region_scope(" #cz-jmk ") == "cz-jmk" def test_plain_name_kept(self): assert normalize_region_scope("cze") == "cze" def test_wildcard_values_are_none(self): for value in ("*", "0", "None"): assert normalize_region_scope(value) is None def test_caps_to_30_bytes(self): assert normalize_region_scope("x" * 40) == "x" * 30 def test_cap_respects_utf8_boundaries(self): # Each 'ě' is 2 UTF-8 bytes; 30 bytes fits 15 of them. assert normalize_region_scope("ě" * 20) == "ě" * 15 class TestParseSharedChannel: def test_channel_without_region(self): text = f"meshcore://channel/add?name=Ops&secret={SECRET}" assert parse_shared_channel(text) == ("Ops", SECRET, None) def test_channel_with_region(self): text = f"meshcore://channel/add?name=Ops&secret={SECRET}®ion_scope=cze" assert parse_shared_channel(text) == ("Ops", SECRET, "cze") def test_region_scope_encoded(self): text = f"meshcore://channel/add?name=Ops&secret={SECRET}®ion_scope=%23cz-jmk" assert parse_shared_channel(text) == ("Ops", SECRET, "cz-jmk") def test_wildcard_region_scope_is_none(self): text = f"meshcore://channel/add?name=Ops&secret={SECRET}®ion_scope=*" assert parse_shared_channel(text) == ("Ops", SECRET, None) def test_invalid_secret_returns_none(self): assert parse_shared_channel("meshcore://channel/add?name=Ops&secret=zz") is None class TestFormatSharedChannel: def test_no_region_omits_param(self): url = format_shared_channel("Ops", SECRET) assert url == f"meshcore://channel/add?name=Ops&secret={SECRET}" assert "region_scope" not in url def test_region_included_and_encoded(self): url = format_shared_channel("Ops", SECRET, "cz-jmk") assert "region_scope=cz-jmk" in url def test_round_trip_with_name_and_region(self): url = format_shared_channel("C++ & #1", SECRET, "#cz-jmk") assert parse_shared_channel(url) == ("C++ & #1", SECRET, "cz-jmk") meshy/tests/test_sleep_reconnect.py000066400000000000000000000103071526212641700201350ustar00rootroot00000000000000"""Tests for sleep/wake reconnect logic in ConnectionController.""" from unittest.mock import MagicMock, patch from meshy.transport_base import ConnectionState def _make_controller(): """Create a ConnectionController with mocked window and transport.""" win = MagicMock() win._transport = MagicMock() win._transport.state = ConnectionState.DISCONNECTED win._transport.is_connected = False with patch("meshy.connection_controller.create_sleep_monitor", return_value=None): from meshy.connection_controller import ConnectionController ctrl = ConnectionController(win) return ctrl, win class TestShouldAutoReconnect: def test_returns_false_when_suspended(self): ctrl, _ = _make_controller() ctrl.last_connected_address = "AA:BB:CC:DD:EE:FF" ctrl._suspended = True assert ctrl._should_auto_reconnect() is False def test_returns_true_when_not_suspended(self): ctrl, _ = _make_controller() ctrl.last_connected_address = "AA:BB:CC:DD:EE:FF" ctrl._suspended = False assert ctrl._should_auto_reconnect() def test_suspended_not_consumed_on_check(self): ctrl, _ = _make_controller() ctrl.last_connected_address = "AA:BB:CC:DD:EE:FF" ctrl._suspended = True ctrl._should_auto_reconnect() assert ctrl._suspended is True class TestOnSystemWillSleep: def test_sets_suspended_flag(self): ctrl, _ = _make_controller() ctrl._on_system_will_sleep() assert ctrl._suspended is True def test_disconnects_when_connected(self): ctrl, win = _make_controller() win._transport.is_connected = True ctrl._on_system_will_sleep() win._transport.disconnect.assert_called_once() def test_skips_disconnect_when_not_connected(self): ctrl, win = _make_controller() win._transport.is_connected = False ctrl._on_system_will_sleep() win._transport.disconnect.assert_not_called() class TestOnSystemDidWake: def test_clears_suspended_flag(self): ctrl, _ = _make_controller() ctrl._suspended = True ctrl._on_system_did_wake() assert ctrl._suspended is False def test_schedules_reconnect_when_has_address(self): ctrl, win = _make_controller() ctrl._suspended = True ctrl.last_connected_address = "AA:BB:CC:DD:EE:FF" win._transport.state = ConnectionState.DISCONNECTED ctrl._schedule_reconnect = MagicMock() ctrl._on_system_did_wake() assert ctrl.reconnect_attempts == 0 ctrl._schedule_reconnect.assert_called_once() def test_skips_reconnect_when_no_address(self): ctrl, _ = _make_controller() ctrl.last_connected_address = None ctrl._schedule_reconnect = MagicMock() ctrl._on_system_did_wake() ctrl._schedule_reconnect.assert_not_called() def test_skips_reconnect_when_already_connected(self): ctrl, win = _make_controller() ctrl.last_connected_address = "AA:BB:CC:DD:EE:FF" win._transport.state = ConnectionState.CONNECTED ctrl._schedule_reconnect = MagicMock() ctrl._on_system_did_wake() ctrl._schedule_reconnect.assert_not_called() class TestSleepWakeSequence: def test_full_sleep_wake_cycle(self): ctrl, win = _make_controller() ctrl.last_connected_address = "AA:BB:CC:DD:EE:FF" win._transport.is_connected = True ctrl._schedule_reconnect = MagicMock() ctrl._on_system_will_sleep() assert ctrl._suspended is True assert ctrl._should_auto_reconnect() is False win._transport.state = ConnectionState.DISCONNECTED win._transport.is_connected = False ctrl._on_system_did_wake() assert ctrl._suspended is False ctrl._schedule_reconnect.assert_called_once() def test_sleep_monitor_not_started_when_unavailable(self): with patch("meshy.connection_controller.create_sleep_monitor", return_value=None): from meshy.connection_controller import ConnectionController win = MagicMock() win._transport = MagicMock() ctrl = ConnectionController(win) assert ctrl._sleep_monitor is None meshy/tests/test_smart_routing.py000066400000000000000000000260071526212641700176660ustar00rootroot00000000000000"""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.models import Contact 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_seed_not_repeated_for_same_lastmod(self): """A path deleted due to low weight must not be re-seeded when device_lastmod has not changed (firmware reports the same stale path).""" self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="ccdd", lastmod=100)) history = self.storage.get_path_history(PUB_KEY_HEX) assert len(history) == 1 for _ in range(10): self.win._msg_ctrl.path_history.record_result(PUB_KEY_HEX, "ccdd", 1, 2, success=False) assert self.storage.get_path_history(PUB_KEY_HEX) == [] self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="ccdd", lastmod=100)) assert self.storage.get_path_history(PUB_KEY_HEX) == [] def test_seed_allowed_after_lastmod_change(self): """A deleted path should be re-seeded when device_lastmod changes (firmware received a fresh advert or message).""" self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="ccdd", lastmod=100)) for _ in range(10): self.win._msg_ctrl.path_history.record_result(PUB_KEY_HEX, "ccdd", 1, 2, success=False) assert self.storage.get_path_history(PUB_KEY_HEX) == [] self.fh.add_or_update_contact(_contact_dict(path_len=2, path_hex="ccdd", lastmod=200)) history = self.storage.get_path_history(PUB_KEY_HEX) assert len(history) == 1 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.py000066400000000000000000000031071526212641700157370ustar00rootroot00000000000000import 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.py000066400000000000000000001056511526212641700164400ustar00rootroot00000000000000from 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 def test_distinct_timestamps_same_text_not_duplicate(self, storage): """Messages with identical text but different timestamps must not be treated as duplicates. Regression test: clamping future timestamps to time.time() collapsed distinct messages onto the same second, causing the dedup to silently drop them.""" _insert_msg(storage, CONTACT_HEX, "hello", 1000.0) assert storage.has_duplicate_message(CONTACT_HEX, 1001.0, "hello") is False _insert_msg(storage, CONTACT_HEX, "hello", 1001.0) assert storage.has_duplicate_message(CONTACT_HEX, 1002.0, "hello") is False # ─── has_duplicate_channel_message ───────────────────────────── class TestHasDuplicateChannelMessage: """Regression tests for channel message dedup. The channel dedup uses abs(timestamp - ?) < 1, so messages within the same second with identical text+sender are duplicates. Messages more than 1 second apart must NOT be treated as duplicates even if text and sender match. """ def _insert_ch_msg(self, storage, ch_idx, sender, text, ts): storage._db.execute( "INSERT INTO channel_messages " "(channel_index, sender_name, text, timestamp, is_outgoing) " "VALUES (?, ?, ?, ?, 0)", (ch_idx, sender, text, ts), ) storage._db.commit() def test_same_second_is_duplicate(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) self._insert_ch_msg(storage, 0, "Bob", "hello", 100.0) assert storage.has_duplicate_channel_message(0, 100.5, "hello", "Bob") def test_different_second_not_duplicate(self, storage): """Two messages >1s apart with same text must both be kept.""" storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) self._insert_ch_msg(storage, 0, "Bob", "hello", 100.0) assert not storage.has_duplicate_channel_message(0, 101.5, "hello", "Bob") def test_clamped_timestamps_would_collide(self, storage): """Simulate what happens when future timestamps are clamped to the same now() value — each message must keep its own identity.""" storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) # Without clamping these have ts 2000, 2001, 2002 for i in range(3): ts = 2000.0 + i if not storage.has_duplicate_channel_message(0, ts, "hello", "Bob"): self._insert_ch_msg(storage, 0, "Bob", "hello", ts) # All three should be stored count = storage._db.execute( "SELECT COUNT(*) as c FROM channel_messages WHERE channel_index = 0" ).fetchone()["c"] assert count == 3 # ─── 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 # ─── Counter-based unread tracking ──────────────────────────── class TestCounterUnread: def test_increment_channel_unread(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) assert storage.get_channel(0).unread_count == 0 storage.increment_channel_unread(0) assert storage.get_channel(0).unread_count == 1 storage.increment_channel_unread(0) assert storage.get_channel(0).unread_count == 2 def test_zero_channel_unread(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage.increment_channel_unread(0) storage.increment_channel_unread(0) storage.zero_channel_unread(0) assert storage.get_channel(0).unread_count == 0 def test_increment_contact_unread(self, storage): c = Contact(public_key=bytes.fromhex("aa" * 32), name="Alice") storage.save_contact(c) assert storage.get_contacts()[0].unread_count == 0 storage.increment_contact_unread(c.public_key_hex) assert storage.get_contacts()[0].unread_count == 1 storage.increment_contact_unread(c.public_key_hex) assert storage.get_contacts()[0].unread_count == 2 def test_zero_contact_unread(self, storage): c = Contact(public_key=bytes.fromhex("aa" * 32), name="Alice") storage.save_contact(c) storage.increment_contact_unread(c.public_key_hex) storage.zero_contact_unread(c.public_key_hex) assert storage.get_contacts()[0].unread_count == 0 def test_has_duplicate_channel_message(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', 'hello', 100.0, 0)" ) storage._db.commit() assert storage.has_duplicate_channel_message(0, 100.0, "hello", "Bob") assert storage.has_duplicate_channel_message(0, 100.5, "hello", "Bob") assert not storage.has_duplicate_channel_message(0, 102.0, "hello", "Bob") assert not storage.has_duplicate_channel_message(0, 100.0, "other", "Bob") assert not storage.has_duplicate_channel_message(1, 100.0, "hello", "Bob") # Different sender with same text is NOT a duplicate assert not storage.has_duplicate_channel_message(0, 100.0, "hello", "Alice") def test_save_channel_preserves_unread(self, storage): ch = Channel(index=0, name="General", psk=bytes(16)) storage.save_channel(ch) storage.increment_channel_unread(0) storage.increment_channel_unread(0) ch2 = Channel(index=0, name="Renamed", psk=bytes(16)) storage.save_channel(ch2) assert storage.get_channel(0).unread_count == 2 assert storage.get_channel(0).name == "Renamed" def test_save_contact_preserves_unread(self, storage): c = Contact(public_key=bytes.fromhex("aa" * 32), name="Alice") storage.save_contact(c) storage.increment_contact_unread(c.public_key_hex) c2 = Contact(public_key=bytes.fromhex("aa" * 32), name="Alice Updated") storage.save_contact(c2) assert storage.get_contacts()[0].unread_count == 1 assert storage.get_contacts()[0].name == "Alice Updated" def test_save_contact_persists_custom_name(self, storage): key = bytes.fromhex("bb" * 32) storage.save_contact(Contact(public_key=key, name="Advert", custom_name="Alias")) loaded = storage.get_contacts()[0] assert loaded.name == "Advert" assert loaded.custom_name == "Alias" def test_save_contact_can_clear_custom_name(self, storage): key = bytes.fromhex("bb" * 32) storage.save_contact(Contact(public_key=key, name="Advert", custom_name="Alias")) storage.save_contact(Contact(public_key=key, name="Advert", custom_name="")) assert storage.get_contacts()[0].custom_name == "" def test_clear_channel_messages_zeros_unread(self, storage): storage.save_channel(Channel(index=0, name="General", psk=bytes(16))) storage.increment_channel_unread(0) storage.clear_channel_messages(0) assert storage.get_channel(0).unread_count == 0 # ─── 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", "custom_name", ): 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 # ─── remove_contact ────────────────────────────────────────── class TestRemoveContact: def test_deletes_contact_and_messages(self, storage): key = bytes.fromhex(CONTACT_HEX) storage.save_contact(Contact(public_key=key, name="Alice")) _insert_msg(storage, CONTACT_HEX, "hi", 1000.0) storage.remove_contact(CONTACT_HEX) assert storage.find_contact_by_prefix(key[:6]) is None assert storage.get_messages(CONTACT_HEX) == [] def test_deletes_path_history(self, storage): key = bytes.fromhex(CONTACT_HEX) storage.save_contact(Contact(public_key=key, name="Alice")) storage._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_HEX, "aabb", 1, 2, 5, 0, 300, 2.0, 1000.0), ) storage._db.commit() storage.remove_contact(CONTACT_HEX) rows = storage._db.execute( "SELECT COUNT(*) FROM path_history WHERE contact_key_hex = ?", (CONTACT_HEX,), ).fetchone()[0] assert rows == 0 def test_does_not_affect_other_contacts(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")) storage.remove_contact(CONTACT_HEX) assert storage.find_contact_by_prefix(bytes.fromhex(OTHER_HEX)[:6]) is not None # ─── 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 def test_close_commits_pending_data(self, tmp_path): """Data written inside a batch is committed when close() is called.""" import sqlite3 from meshy.storage import Storage db_path = str(tmp_path / "test_close.db") s = Storage(db_path=db_path) s.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) # Simulate a stuck batch (batch_depth left elevated) s._batch_depth = 1 s._db.execute( "UPDATE contacts SET name = ? WHERE public_key_hex = ?", ("Updated", CONTACT_HEX), ) # close() should force-commit before closing s.close() # Re-open and verify the update was persisted db = sqlite3.connect(db_path) row = db.execute( "SELECT name FROM contacts WHERE public_key_hex = ?", (CONTACT_HEX,) ).fetchone() db.close() assert row is not None assert row[0] == "Updated" def test_close_resets_batch_depth(self, tmp_path): """close() resets batch_depth to 0 before committing.""" from meshy.storage import Storage db_path = str(tmp_path / "test_close_reset.db") s = Storage(db_path=db_path) s._batch_depth = 3 s.close() # No assertion on batch_depth since object is closed, # but verify no exception was raised # ─── delete_device_db ────────────────────────────────────────── class TestDeleteDeviceDb: def test_removes_address_keyed_db(self, tmp_path, monkeypatch): import os from meshy.storage import Storage monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) os.makedirs(tmp_path / "meshy", exist_ok=True) path = Storage.db_path_for_device("C2:FD:D3:9B:D7:DB") Storage(db_path=path).close() assert os.path.exists(path) Storage.delete_device_db("C2:FD:D3:9B:D7:DB") assert not os.path.exists(path) def test_removes_pubkey_keyed_db_recording_address(self, tmp_path, monkeypatch): import os from meshy.storage import Storage monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) os.makedirs(tmp_path / "meshy", exist_ok=True) pubkey = "ab" * 32 pubkey_path = Storage.db_path_for_device(pubkey) db = Storage(db_path=pubkey_path) db.set_setting("device_address", "C2:FD:D3:9B:D7:DB") db.close() assert os.path.exists(pubkey_path) Storage.delete_device_db("C2:FD:D3:9B:D7:DB") assert not os.path.exists(pubkey_path) def test_keeps_unrelated_pubkey_db(self, tmp_path, monkeypatch): import os from meshy.storage import Storage monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) os.makedirs(tmp_path / "meshy", exist_ok=True) pubkey = "cd" * 32 pubkey_path = Storage.db_path_for_device(pubkey) db = Storage(db_path=pubkey_path) db.set_setting("device_address", "AA:BB:CC:DD:EE:FF") db.close() Storage.delete_device_db("C2:FD:D3:9B:D7:DB") assert os.path.exists(pubkey_path) # ─── Migrations ──────────────────────────────────────────────── class TestSmartRouteSelectionMigration: def test_explicit_zero_survives_reopen(self, tmp_path): """A contact explicitly set to 0 ('not smart') must not be reset to the global-default sentinel (-1) on the next app start.""" from meshy.storage import Storage db_path = str(tmp_path / "meshy.db") db = Storage(db_path=db_path) c = Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice") c.smart_route_selection = False # explicit "not smart" -> stored as 0 db.save_contact(c) assert db.get_contacts()[0].smart_route_selection is False db.close() db = Storage(db_path=db_path) # Must still be an explicit False, not None (-1 sentinel). assert db.get_contacts()[0].smart_route_selection is False db.close() def test_one_time_migration_runs_once_then_flagged(self, tmp_path): """Simulate a pre-migration DB: column present, no flag, a legacy 0 row. The one-time conversion should turn 0 -> -1 exactly once and set a flag.""" from meshy.storage import Storage db_path = str(tmp_path / "meshy.db") db = Storage(db_path=db_path) db.save_contact(Contact(public_key=bytes.fromhex(CONTACT_HEX), name="Alice")) # Force a legacy state: a raw 0 value and no migration flag. db._db.execute("UPDATE contacts SET smart_route_selection = 0") db._db.execute( "DELETE FROM settings WHERE key = '_migration:smart_route_selection_default'" ) db._db.commit() db.close() # Reopen -> migration runs, 0 becomes -1 (None) and flag is set. db = Storage(db_path=db_path) assert db.get_contacts()[0].smart_route_selection is None assert db._migration_done("smart_route_selection_default") db.close() meshy/tests/test_utils.py000066400000000000000000000204621526212641700161300ustar00rootroot00000000000000import math from meshy.gtk_utils import linkify from meshy.utils import ( extract_emoji, format_distance, format_number, haversine_distance, parse_shared_contact, parse_shared_location, route_distance, split_message_text, uses_metric_units, ) # ─── 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 # ─── measurement units ──────────────────────────────────────── _LOCALE_VARS = ("LC_MEASUREMENT", "LC_ALL", "LC_NUMERIC", "LANG") class TestUsesMetricUnits: @staticmethod def _clean_env(monkeypatch): for var in _LOCALE_VARS: monkeypatch.delenv(var, raising=False) def test_default_is_metric(self, monkeypatch): self._clean_env(monkeypatch) assert uses_metric_units() is True def test_czech_is_metric(self, monkeypatch): self._clean_env(monkeypatch) monkeypatch.setenv("LANG", "cs_CZ.UTF-8") assert uses_metric_units() is True def test_us_is_imperial(self, monkeypatch): self._clean_env(monkeypatch) monkeypatch.setenv("LANG", "en_US.UTF-8") assert uses_metric_units() is False def test_gb_is_imperial(self, monkeypatch): self._clean_env(monkeypatch) monkeypatch.setenv("LC_MEASUREMENT", "en_GB.UTF-8") assert uses_metric_units() is False def test_measurement_var_wins_over_lang(self, monkeypatch): self._clean_env(monkeypatch) monkeypatch.setenv("LANG", "en_US.UTF-8") monkeypatch.setenv("LC_MEASUREMENT", "cs_CZ.UTF-8") assert uses_metric_units() is True def test_c_locale_is_metric(self, monkeypatch): self._clean_env(monkeypatch) monkeypatch.setenv("LC_ALL", "C") assert uses_metric_units() is True class TestFormatDistance: def test_metric_meters(self, monkeypatch): monkeypatch.setattr("meshy.geo.uses_metric_units", lambda: True) assert format_distance(0.85) == f"{format_number(850.0, 0)} m" assert format_distance(0.0) == f"{format_number(0.0, 0)} m" def test_metric_kilometres(self, monkeypatch): monkeypatch.setattr("meshy.geo.uses_metric_units", lambda: True) assert format_distance(12.34) == f"{format_number(12.34, 1)} km" assert format_distance(235.0) == f"{format_number(235.0, 1)} km" def test_imperial_feet(self, monkeypatch): monkeypatch.setattr("meshy.geo.uses_metric_units", lambda: False) assert format_distance(0.2) == f"{format_number(0.2 * 3280.84, 0)} ft" def test_imperial_miles(self, monkeypatch): monkeypatch.setattr("meshy.geo.uses_metric_units", lambda: False) assert format_distance(10.0) == f"{format_number(10.0 / 1.609344, 1)} mi" class TestRouteDistance: def test_two_known_points(self): total, partial = route_distance([(50.0755, 14.4378), (49.1951, 16.6068)]) assert partial is False assert 180 < total < 200 # Prague → Brno def test_unknown_point_is_bridged(self): direct, _ = route_distance([(50.0755, 14.4378), (49.1951, 16.6068)]) total, partial = route_distance([(50.0755, 14.4378), (None, None), (49.1951, 16.6068)]) assert partial is True assert total == direct def test_single_known_point(self): total, partial = route_distance([(50.0, 14.0), (None, None)]) assert total == 0.0 assert partial is True def test_empty(self): assert route_distance([]) == (0.0, False) def test_all_unknown(self): assert route_distance([(None, None), (None, None)]) == (0.0, True) # ─── 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") == "🚀" # ─── linkify ────────────────────────────────────────────────── class TestLinkify: def test_plain_text(self): assert linkify("Hello world") == "Hello world" def test_escapes_markup(self): assert linkify("a < b & c") == "a < b & c" def test_url(self): assert linkify("see https://example.com now") == ( 'see https://example.com now' ) def test_url_trailing_punct(self): assert linkify("visit https://example.com.") == ( 'visit https://example.com.' ) # ─── split_message_text ────────────────────────────────────── class TestSplitMessageText: def test_plain(self): assert split_message_text("hello") == [("text", "hello")] def test_mention_only(self): assert split_message_text("@[Alice]") == [("mention", "Alice")] def test_mention_middle(self): assert split_message_text("hi @[Alice] there") == [ ("text", "hi "), ("mention", "Alice"), ("text", " there"), ] def test_multiple_mentions(self): assert split_message_text("@[Alice] and @[Bob]") == [ ("mention", "Alice"), ("text", " and "), ("mention", "Bob"), ] def test_no_mentions(self): assert split_message_text("plain text") == [("text", "plain text")] def test_mention_name_with_brackets(self): assert split_message_text("@[[GNSM] Comp 0x01]") == [("mention", "[GNSM] Comp 0x01")] def test_mention_name_with_brackets_middle(self): assert split_message_text("hi @[[GNSM] Comp 0x01] there") == [ ("text", "hi "), ("mention", "[GNSM] Comp 0x01"), ("text", " there"), ] def test_unterminated_mention_is_text(self): assert split_message_text("hi @[Alice") == [("text", "hi @[Alice")] def test_empty_mention_is_text(self): assert split_message_text("hi @[]") == [("text", "hi @[]")] meshy/uv.lock000066400000000000000000002455421526212641700135310ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.13" [[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 = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[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 = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "meshy" version = "26.6" source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "msgpack" }, { name = "pre-commit" }, { name = "pycryptodome" }, { name = "pyserial" }, { name = "pytest" }, { name = "pyzbar" }, { name = "ruff" }, { name = "segno" }, ] dev-linux = [ { name = "pyzbar" }, ] dev-macos = [ { name = "pyobjc-framework-avfoundation" }, { name = "pyobjc-framework-corebluetooth" }, { name = "pyobjc-framework-corelocation" }, { name = "pyobjc-framework-usernotifications" }, { name = "pyobjc-framework-vision" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "msgpack", specifier = ">=1.1.0" }, { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pyserial", specifier = ">=3.5" }, { name = "pytest", specifier = ">=8.0" }, { name = "pyzbar", specifier = ">=0.1.9" }, { name = "ruff", specifier = ">=0.15.15" }, { name = "segno", specifier = ">=1.6.6" }, ] dev-linux = [{ name = "pyzbar", specifier = ">=0.1.9" }] dev-macos = [ { name = "pyobjc-framework-avfoundation", specifier = ">=10.0" }, { name = "pyobjc-framework-corebluetooth", specifier = ">=10.0" }, { name = "pyobjc-framework-corelocation", specifier = ">=10.0" }, { name = "pyobjc-framework-usernotifications", specifier = ">=10.0" }, { name = "pyobjc-framework-vision", specifier = ">=10.0" }, ] [[package]] name = "msgpack" version = "1.2.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0a/e7/bb605a7bab2d8425a64b3fa762b39dc1bf1c7e3f11ba6fb5413d6db0ff8c/msgpack-1.2.3.tar.gz", hash = "sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186", size = 196517, upload-time = "2026-09-29T02:33:52.276Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8b/3824d65e912e925d09ce30d9130fa9970d6d2855d7888b13639a6604967f/msgpack-1.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8", size = 91728, upload-time = "2026-09-29T02:32:18.949Z" }, { url = "https://files.pythonhosted.org/packages/05/e6/df7f2c9ebb94760113debbcea2bd3afe5fdab88a4f7bec1b618755517460/msgpack-1.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709", size = 89955, upload-time = "2026-09-29T02:32:20.224Z" }, { url = "https://files.pythonhosted.org/packages/08/6a/e5fc57136e8bacccb2b39627dea2cd546540a06181e22fe6db90e15b3ae4/msgpack-1.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca", size = 454930, upload-time = "2026-09-29T02:32:21.771Z" }, { url = "https://files.pythonhosted.org/packages/b0/30/c394d37898db9212d1693456cdf363c7e1a097d0b63e10664007f3df3ec1/msgpack-1.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb", size = 466866, upload-time = "2026-09-29T02:32:23.742Z" }, { url = "https://files.pythonhosted.org/packages/4a/c8/1e4ddf6f6b829b3ee6c530c79dfae89cb609d2b0eedb5e0ae716851c52d1/msgpack-1.2.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5", size = 418715, upload-time = "2026-09-29T02:32:25.262Z" }, { url = "https://files.pythonhosted.org/packages/11/a5/f460ba6d7a12d4301002f3efbb8f841e8bdc9c5fc98d771689677a352885/msgpack-1.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37", size = 446489, upload-time = "2026-09-29T02:32:26.988Z" }, { url = "https://files.pythonhosted.org/packages/49/23/adface88db909bed321c85dd673655152d4a514c67e1f0800eb51c777d07/msgpack-1.2.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d", size = 416998, upload-time = "2026-09-29T02:32:28.606Z" }, { url = "https://files.pythonhosted.org/packages/36/00/5bb3a239ccfc3763c4d0fa49b13b1b7010b00182c499ab3c1fecfe6294bc/msgpack-1.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853", size = 463288, upload-time = "2026-09-29T02:32:30.375Z" }, { url = "https://files.pythonhosted.org/packages/29/8c/456df77f00d701df9d6980ffb80291bce6e4e2e112e25a4dfae216f0715a/msgpack-1.2.3-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890", size = 53347, upload-time = "2026-09-29T02:32:31.867Z" }, { url = "https://files.pythonhosted.org/packages/9d/22/ce780be666f89b77cdb855daa9ec62e87bb7f69e9f403e4a5d83a2b2208f/msgpack-1.2.3-cp313-cp313-win32.whl", hash = "sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f", size = 68258, upload-time = "2026-09-29T02:32:33.163Z" }, { url = "https://files.pythonhosted.org/packages/51/06/c3def9bc4db283103c5901b302ee2a4305cb1e69729244f94d9bd8f8e8e7/msgpack-1.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a", size = 76569, upload-time = "2026-09-29T02:32:34.412Z" }, { url = "https://files.pythonhosted.org/packages/12/9f/cef344073858b80adb92d6ea342e20b0eae7a8f6fe70281b69cf03707270/msgpack-1.2.3-cp313-cp313-win_arm64.whl", hash = "sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047", size = 71530, upload-time = "2026-09-29T02:32:35.892Z" }, { url = "https://files.pythonhosted.org/packages/3f/8e/f777f74e38731c428857933c8011596f2d2f3160c821152f23b6ffba862f/msgpack-1.2.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8", size = 92042, upload-time = "2026-09-29T02:32:37.464Z" }, { url = "https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4", size = 90578, upload-time = "2026-09-29T02:32:38.883Z" }, { url = "https://files.pythonhosted.org/packages/ea/11/6d78ce5a9a58bf9ba7b1b6a8f649173b030e6770c8019cf330b91825ee5d/msgpack-1.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220", size = 454352, upload-time = "2026-09-29T02:32:40.34Z" }, { url = "https://files.pythonhosted.org/packages/3d/08/feb9a196269ba7809f44f9117d9e4a601c41c313f6144fd0c337293a5488/msgpack-1.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58", size = 462562, upload-time = "2026-09-29T02:32:42.176Z" }, { url = "https://files.pythonhosted.org/packages/f5/77/3a674f366def24140b103d1ffd4fd27b3d912a13e47da67422afa16bebb3/msgpack-1.2.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620", size = 418134, upload-time = "2026-09-29T02:32:43.693Z" }, { url = "https://files.pythonhosted.org/packages/48/82/944e71f280577490d99a3951cbce21aa4cbe04e7ab42cb373fd668af883c/msgpack-1.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30", size = 445937, upload-time = "2026-09-29T02:32:45.739Z" }, { url = "https://files.pythonhosted.org/packages/b1/ec/feddd629c4a3edf1395313680450c525086cceab56dec0d4de9da9ccb618/msgpack-1.2.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c", size = 416450, upload-time = "2026-09-29T02:32:47.558Z" }, { url = "https://files.pythonhosted.org/packages/e4/59/263a10f8c4613ba0713f48cbda7695ac8dd6d6fab2fcbc9168f03f23a94d/msgpack-1.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207", size = 459546, upload-time = "2026-09-29T02:32:49.145Z" }, { url = "https://files.pythonhosted.org/packages/1e/21/addcfa1e583cfc8a22fbdc57526621b5decd7ad676ae12e9150b7be1be5d/msgpack-1.2.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150", size = 53462, upload-time = "2026-09-29T02:32:50.708Z" }, { url = "https://files.pythonhosted.org/packages/8d/2c/3cb5c8524a1335ee27ca952c7ab78d375a16fea8e18ae3767ba0c880416c/msgpack-1.2.3-cp314-cp314-win32.whl", hash = "sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec", size = 70294, upload-time = "2026-09-29T02:32:52.037Z" }, { url = "https://files.pythonhosted.org/packages/23/f9/9172ff3cdb85d160ad06df5e2708a5fce7682982a5eee8d31869b9f69d2e/msgpack-1.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab", size = 77778, upload-time = "2026-09-29T02:32:53.429Z" }, { url = "https://files.pythonhosted.org/packages/04/e8/b4c23178bcf605ae17cec48a75530dd69d49b0a5a6f5f4df5c47d59f746e/msgpack-1.2.3-cp314-cp314-win_arm64.whl", hash = "sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290", size = 73794, upload-time = "2026-09-29T02:32:54.763Z" }, { url = "https://files.pythonhosted.org/packages/66/b1/92704be352c4f428b7e0a0e0fb210cb1aa2b1c42c102b8dc22d34b82fac0/msgpack-1.2.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1", size = 93721, upload-time = "2026-09-29T02:32:56.342Z" }, { url = "https://files.pythonhosted.org/packages/49/78/9c91f1e86cadcbc100b3780fd429c3715648704032a612e77a00646ebe79/msgpack-1.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18", size = 94256, upload-time = "2026-09-29T02:32:58.056Z" }, { url = "https://files.pythonhosted.org/packages/91/4d/270f9725921ae88a29d37a774a77ac24f0ef1411fc960a63f5a4665e81b4/msgpack-1.2.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f", size = 471673, upload-time = "2026-09-29T02:32:59.886Z" }, { url = "https://files.pythonhosted.org/packages/48/b8/eaa8d930f72dc1d1dd79511dc2ccf965922b059f2f0ed3b30aebac8c4b11/msgpack-1.2.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a", size = 466257, upload-time = "2026-09-29T02:33:01.517Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/97adc805037bc7e24c4e2f711bbcd3b28be8ec9aea3e778f18208cfbdb46/msgpack-1.2.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc", size = 418484, upload-time = "2026-09-29T02:33:03.402Z" }, { url = "https://files.pythonhosted.org/packages/0d/7e/1c53302606fe436ab48ba539ebafafe4a6a9efe12c4f04dc7eb36912d93e/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f", size = 454064, upload-time = "2026-09-29T02:33:04.977Z" }, { url = "https://files.pythonhosted.org/packages/00/2d/9ee0170f638907b396c15c6cd26b3e54f869159efc6206683acfd8f696e1/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e", size = 417901, upload-time = "2026-09-29T02:33:06.489Z" }, { url = "https://files.pythonhosted.org/packages/cc/d2/905c84490a75cd15a27065407cd085d201f7d392e1e0411f49f03fd31ade/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db", size = 459896, upload-time = "2026-09-29T02:33:08.361Z" }, { url = "https://files.pythonhosted.org/packages/37/cd/4ce5809b9ab3b114d7cca64863e436820fa1614b49d55ccb93d49824ac2d/msgpack-1.2.3-cp314-cp314t-win32.whl", hash = "sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e", size = 75983, upload-time = "2026-09-29T02:33:10.023Z" }, { url = "https://files.pythonhosted.org/packages/8a/31/853bb580744c24be0dbd8b090c3e6987dce466a1fc840fe50c0ac2ef9044/msgpack-1.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9", size = 83757, upload-time = "2026-09-29T02:33:11.441Z" }, { url = "https://files.pythonhosted.org/packages/0d/49/9f1b2ee484414eef9e21ee2b2b23b482bb71433ab9bac1da03cbda15ebf5/msgpack-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd", size = 78128, upload-time = "2026-09-29T02:33:13.063Z" }, { url = "https://files.pythonhosted.org/packages/47/b8/50db4235407c3802f622b4ccdf65c6fe1e48d3c3eab6981fa6a9a5e53f11/msgpack-1.2.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c", size = 92111, upload-time = "2026-09-29T02:33:14.476Z" }, { url = "https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949", size = 90583, upload-time = "2026-09-29T02:33:15.924Z" }, { url = "https://files.pythonhosted.org/packages/2a/fd/8cc02f767c3bc94d2649c954d28dea935ce9398eb9c93ce2444bb9474cc1/msgpack-1.2.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5", size = 454751, upload-time = "2026-09-29T02:33:17.475Z" }, { url = "https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49", size = 463597, upload-time = "2026-09-29T02:33:19.309Z" }, { url = "https://files.pythonhosted.org/packages/4d/a5/e7c261abf75783c07dcac89951cb31dd0c123bf02fbdeda0c67303e698d8/msgpack-1.2.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab", size = 422661, upload-time = "2026-09-29T02:33:21.093Z" }, { url = "https://files.pythonhosted.org/packages/9d/8e/466d5133f9e1c2e232e15e304f715b62f6f0e28332d18e37d975fe174315/msgpack-1.2.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012", size = 445188, upload-time = "2026-09-29T02:33:22.877Z" }, { url = "https://files.pythonhosted.org/packages/d4/b4/33e7ad987ee2f4b3d449a6cbf28f574ed222987ca7f65ad277072646ac5e/msgpack-1.2.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377", size = 420451, upload-time = "2026-09-29T02:33:24.485Z" }, { url = "https://files.pythonhosted.org/packages/34/2c/9d8be0d6c16e7e6131cd7da20257dd3da65473e3e6df0c00572fb10a195c/msgpack-1.2.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd", size = 460624, upload-time = "2026-09-29T02:33:26.063Z" }, { url = "https://files.pythonhosted.org/packages/6a/e7/3a04783582c6f44f398cbfcf5f07a111192126ec4e63edf7f5640143bf64/msgpack-1.2.3-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098", size = 53474, upload-time = "2026-09-29T02:33:27.83Z" }, { url = "https://files.pythonhosted.org/packages/68/fb/db07359851644e258609d84f8e4fe0030ef448c108e20afe73f2a3bf539c/msgpack-1.2.3-cp315-cp315-win32.whl", hash = "sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0", size = 70344, upload-time = "2026-09-29T02:33:29.382Z" }, { url = "https://files.pythonhosted.org/packages/5b/e4/cf5584d2f2a2e4465d5896a855a3e75a34a20ab172360b3d42ad862dd1ce/msgpack-1.2.3-cp315-cp315-win_amd64.whl", hash = "sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a", size = 77800, upload-time = "2026-09-29T02:33:30.941Z" }, { url = "https://files.pythonhosted.org/packages/63/f9/518ad4e8a580027b507eafdd26de7aae661a714e43d7c111c212482e4a1b/msgpack-1.2.3-cp315-cp315-win_arm64.whl", hash = "sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d", size = 73871, upload-time = "2026-09-29T02:33:32.406Z" }, { url = "https://files.pythonhosted.org/packages/a4/79/254d4c9ad642b2a3ba84e646787892b34cc815eb36c9976f67a1c4f38515/msgpack-1.2.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124", size = 93370, upload-time = "2026-09-29T02:33:33.87Z" }, { url = "https://files.pythonhosted.org/packages/3d/6f/5a2ba167646a25e84eaa8894e12935351e4331b80c28a9237ce6fe8d375f/msgpack-1.2.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173", size = 93959, upload-time = "2026-09-29T02:33:35.503Z" }, { url = "https://files.pythonhosted.org/packages/e9/a1/2b44612e55f7cf5d5e4b580294959b4429bbbcb1991177888e3e18668137/msgpack-1.2.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007", size = 467921, upload-time = "2026-09-29T02:33:37.023Z" }, { url = "https://files.pythonhosted.org/packages/0b/6e/3309798ed1c11d7fcfdc7b946642685b0ff1588477925bc0d26bee7dcaae/msgpack-1.2.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e", size = 467310, upload-time = "2026-09-29T02:33:38.799Z" }, { url = "https://files.pythonhosted.org/packages/6f/79/9c799f489fa4146de4e00cfe9fee17afe33d8012f88ddffffea94f7c4700/msgpack-1.2.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6", size = 420178, upload-time = "2026-09-29T02:33:40.781Z" }, { url = "https://files.pythonhosted.org/packages/94/c6/5850dc9cafcd2ea315692e65db0e222d20923dd55f44adf35061003de27e/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0", size = 450248, upload-time = "2026-09-29T02:33:42.366Z" }, { url = "https://files.pythonhosted.org/packages/a9/d2/b4c806e3497fe21f0b353568266aec14ff735d092aea672de7b2955db03f/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471", size = 418431, upload-time = "2026-09-29T02:33:44.178Z" }, { url = "https://files.pythonhosted.org/packages/b0/f5/f4ecc3ddac4d551bf2f3cdb283ec546dcc826fe7c500074be61aa273e08a/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa", size = 457543, upload-time = "2026-09-29T02:33:45.978Z" }, { url = "https://files.pythonhosted.org/packages/a4/69/1c821d8386fae5cecc5fcaacf3de3947ff0a23f16bb481b5532b5868372a/msgpack-1.2.3-cp315-cp315t-win32.whl", hash = "sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a", size = 75820, upload-time = "2026-09-29T02:33:47.596Z" }, { url = "https://files.pythonhosted.org/packages/68/9e/41e2f7343a3764a9c1fb10c79f9a6a05db9df93dedd76401d1b511f5a685/msgpack-1.2.3-cp315-cp315t-win_amd64.whl", hash = "sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3", size = 83345, upload-time = "2026-09-29T02:33:49.325Z" }, { url = "https://files.pythonhosted.org/packages/80/cd/0c3aa439bc7a7bf24684fef3a0ad776cba170e18ed94445e723bce42fce7/msgpack-1.2.3-cp315-cp315t-win_arm64.whl", hash = "sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e", size = 77572, upload-time = "2026-09-29T02:33:50.729Z" }, ] [[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 = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[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 = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[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 = "pygments" version = "2.21.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pyobjc-core" version = "12.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a5/78/abc4ce5920305780aeb36b4067a86253378b36e29ba96673a3deb02eb03a/pyobjc_core-12.2.2.tar.gz", hash = "sha256:3906452339cd06a3bb07df103c2511d4cb0f7a22d8771c0b802eba15d9a642b6", size = 1067701, upload-time = "2026-08-11T19:43:39.059Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1b/ed/a8bf040caf3704023d74086b7fb96cf4ed2e844e24bd94e5248ba214b700/pyobjc_core-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950bd2d9c74634398c4e3d24ef2f213d4e23d705083697464fa67afedc53c1ad", size = 6427113, upload-time = "2026-08-11T15:04:39.424Z" }, { url = "https://files.pythonhosted.org/packages/e7/5a/760f8b9e116edd43c57e33844dc17619158fbdd311250d4209910192d72d/pyobjc_core-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3772b406edb3ff78171530a17cda1c4a7817f87b87ded0d8715b3fa664df16db", size = 6666817, upload-time = "2026-08-11T19:30:17.01Z" }, { url = "https://files.pythonhosted.org/packages/13/37/486d38a173b0b8dce973a3e13c74cf402ed1b8621586b5963bc9efd49a48/pyobjc_core-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2062e8ad30a310441cd022544a897553408bebeaa7820d5edba3c96fd7fd693b", size = 6421184, upload-time = "2026-08-11T19:30:21.081Z" }, { url = "https://files.pythonhosted.org/packages/04/f1/d138fd9b9a66ea8db56a8138b77d3413b85da3defe13363a19f364f85529/pyobjc_core-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2c7ef3d2f865b4b3ebb14ec3556f7a3e8abb6d130c67275cd9daa08dbd6e4e4e", size = 6671824, upload-time = "2026-08-11T19:30:25.005Z" }, { url = "https://files.pythonhosted.org/packages/d5/85/577e2265cccf59daf48c460f0a8deeaf7dbe2991227a8859ab1eeab4945e/pyobjc_core-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:89acc6bc13aaa6e3f52b0ce652ede7e201edb6bf062741b246b0c5a44582f25f", size = 6477694, upload-time = "2026-08-11T19:30:28.821Z" }, { url = "https://files.pythonhosted.org/packages/77/0a/bd9f830c64c6f334530831e75c01bfe0a770a3fbb00fddc70329223118b3/pyobjc_core-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59a77038ebe0ab1240f61c341e7fb67b8674f2b4cd41bc71a6472511a12b50f7", size = 6712233, upload-time = "2026-08-11T19:30:33.032Z" }, ] [[package]] name = "pyobjc-framework-avfoundation" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-coreaudio" }, { name = "pyobjc-framework-coremedia" }, { name = "pyobjc-framework-quartz" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/48/917494d86ea02372e922bc96ffb76a6161f847db21ae7885fe7e0c3d7755/pyobjc_framework_avfoundation-12.2.2.tar.gz", hash = "sha256:5b9f33bf382af9a84b2394c98e3603a8abf03f29b0d2036e29698806ea22851a", size = 411684, upload-time = "2026-08-11T19:43:51.652Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/90/ef/ab3b86852704afd33de70f803c814af80a1d84b7dfafc51404d315d0921f/pyobjc_framework_avfoundation-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:73f34a9e7a5cc0ec6cc496ee10c133d14c4ddc768e17f09c684f95ac5b127408", size = 85625, upload-time = "2026-08-11T19:31:38.576Z" }, { url = "https://files.pythonhosted.org/packages/ad/c1/eda88125b7e4ae32dea2c831c5ab92705502355c80ce3d35e8eb8615d555/pyobjc_framework_avfoundation-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ca4976b18bee82142655b9dc1d6c0cd665627d9ef413694434e2ba5a187443f4", size = 86073, upload-time = "2026-08-11T19:31:39.533Z" }, { url = "https://files.pythonhosted.org/packages/cf/61/a0969bfebcdddcaf1f8ccf721bf747e4e6ec803c742c6afd17faf0397f68/pyobjc_framework_avfoundation-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21a3c326455e5dce805e7440a3b7808c1e833e6d961279de88ab39cba70b821f", size = 85834, upload-time = "2026-08-11T19:31:40.455Z" }, { url = "https://files.pythonhosted.org/packages/81/dc/14e479e8f6159d69d5fac942b2e0d0335529f43da0b9b0dac7a80662b46d/pyobjc_framework_avfoundation-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:45b915a35d7fddb6c29a7a43e8b221bd2f2fe73eb0b1c174e843cfa6cbba57ac", size = 86175, upload-time = "2026-08-11T19:31:41.338Z" }, { url = "https://files.pythonhosted.org/packages/17/ec/ffb940584befa2d3a42e8169b1269ab492f77b40ed0183d37089f97626f6/pyobjc_framework_avfoundation-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:60199c8271f28dbf3f1b1f4fcaec3fbd7d6787571be95b0a291e70f1dbd13807", size = 85895, upload-time = "2026-08-11T19:31:42.161Z" }, { url = "https://files.pythonhosted.org/packages/ea/64/7b789e97ce5675e43628166a2ff17bf39ef081e62a5725d51fbfc11c197a/pyobjc_framework_avfoundation-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9f9394180a7891085f8981ba2fcb2da292ee0608b46fa53d231d8a646a6a6bb8", size = 86241, upload-time = "2026-08-11T19:31:43.258Z" }, ] [[package]] name = "pyobjc-framework-cocoa" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/e1/5d9b04ebb60042b9cb49adc2d33115e2f2c2e4ff7d548017bfaff8b7f536/pyobjc_framework_cocoa-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:600b1723184ca094931330e79355274949965460e23de38628d601b5a967baf9", size = 388181, upload-time = "2026-08-11T19:32:44.537Z" }, { url = "https://files.pythonhosted.org/packages/b4/25/2a343357d5fe09bbe9c0e294dc03450866a0d6c1792fad36b6bcc00174c0/pyobjc_framework_cocoa-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:875f2aad73963faa81a6b36ae674fd494a4658d6d999e1075e0e2aca3d2391df", size = 392275, upload-time = "2026-08-11T19:32:45.631Z" }, { url = "https://files.pythonhosted.org/packages/1f/1a/b99521999b9f54b89aad928ddff0faad507abfe33bc46599454bfa48a4b2/pyobjc_framework_cocoa-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:889d7bbd4ba2d4941078bfbbfb882138e51dbead27df006abfe0f2e0d49b5b2e", size = 388368, upload-time = "2026-08-11T19:32:46.781Z" }, { url = "https://files.pythonhosted.org/packages/6d/26/0c697dbc73dcc76bc0f68ea5aeed25bf7b05217df5102659e878501b2d5f/pyobjc_framework_cocoa-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:de69c5933750f3a4599ed962eccd92b6a71914c7e4318dacc7895738a8ae60d7", size = 392404, upload-time = "2026-08-11T19:32:47.918Z" }, { url = "https://files.pythonhosted.org/packages/df/82/502f740fd8f4e9ef741c9d40ba67467ab2c8196f2c09dcba12936d28a4fd/pyobjc_framework_cocoa-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0e8ace0d44a00d281281a723d17fcd05eea7544a38a6a512e1fd018ddb7aece2", size = 388585, upload-time = "2026-08-11T19:32:49.171Z" }, { url = "https://files.pythonhosted.org/packages/7d/3b/07ce3c0ab8d1e9e1bed74fea1bf1cce73527a365a7a23c755051d3be9865/pyobjc_framework_cocoa-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8fe5b2e79c9530f667b4e58a87a3a15ea62f86a5d19eec405517ecbd4f454868", size = 392693, upload-time = "2026-08-11T19:32:50.283Z" }, ] [[package]] name = "pyobjc-framework-coreaudio" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/71/bfbf1dffe9f08f31b444685e9d194e25cc3506db9f7a9d6ffdb56cc4408e/pyobjc_framework_coreaudio-12.2.2.tar.gz", hash = "sha256:880214f7328d6502a0c60ee744fa1eabe77a0d0c9ae276009453b69474eacf38", size = 79573, upload-time = "2026-08-11T19:44:07.04Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ac/2a/d16c2f043c4e12274aa9c4444f9e00abc3fb2c5155a3b12b097eeffb6cdd/pyobjc_framework_coreaudio-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a19cc902a1eadb1b30d191d008e960768aaeeb7c027001820ef0cc01a4791422", size = 35271, upload-time = "2026-08-11T19:33:13.053Z" }, { url = "https://files.pythonhosted.org/packages/63/84/3c05b951ff16525bf32d6df1d4fe436df25ea93581ff257d4693205b4030/pyobjc_framework_coreaudio-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4cdc2be16ee481dea8ba1bad43c657bc02fcaeeb4a502d64e6bc28719b7cc103", size = 38026, upload-time = "2026-08-11T19:33:13.851Z" }, { url = "https://files.pythonhosted.org/packages/7e/87/7bb04033a75abecc03642bcac7c23175e8c1d7a6c14be561a18378f23046/pyobjc_framework_coreaudio-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4e332b0acb976e84b3730d90f1bce8c92f1e52009a07d35ee7d3f8d494055d8a", size = 36520, upload-time = "2026-08-11T19:33:14.656Z" }, { url = "https://files.pythonhosted.org/packages/95/68/36436a40e253dd8005bed9d6ffb31e5be1fd4acccacdfd8e36b1af1b4542/pyobjc_framework_coreaudio-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:29b6d2b0d415246348717e501b424da5ad4cfadc8bb3c465c16851446e6ff590", size = 38131, upload-time = "2026-08-11T19:33:15.462Z" }, { url = "https://files.pythonhosted.org/packages/23/14/424a6b90658b5ac597986b6b8dcb2ae2f9452840a28149c400f458f33d5a/pyobjc_framework_coreaudio-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:97ef7efc7f5c5d66ecb114050f76f029803a203b953103859bdfe1d4756de50d", size = 36552, upload-time = "2026-08-11T19:33:16.339Z" }, { url = "https://files.pythonhosted.org/packages/f5/a1/f276ed48473dd8d7f070517a5dbba40d689af3261127799b41ef17861464/pyobjc_framework_coreaudio-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:75bfc0de2b8f94521c9becb55d5dcf749b6ff479b6163cd1355b1f7c578166e4", size = 38162, upload-time = "2026-08-11T19:33:17.1Z" }, ] [[package]] name = "pyobjc-framework-corebluetooth" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/f6/424a21392b3290dab0e8a78bddbe4e71754b9f1cf70d0e1584c03d6df987/pyobjc_framework_corebluetooth-12.2.2.tar.gz", hash = "sha256:75aa13f5355be549252d3192864bb3b82eb74c1f667d0527c1388117efffd688", size = 37806, upload-time = "2026-08-11T19:44:08.594Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/16/76/044737646efadf7aed6e92160bb52d37927c2729f5de8558abc423963e0a/pyobjc_framework_corebluetooth-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0c483a90fb7ebf269af5f235fed82e437d8ff892c133f064af25623bf43efe16", size = 13236, upload-time = "2026-08-11T19:33:27.614Z" }, { url = "https://files.pythonhosted.org/packages/5b/ae/5bec176e086eabd9a36cf2acfec98207586215dbfc9d924453a707e8d6f8/pyobjc_framework_corebluetooth-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ff95d28591e1b83b4c3e835279194ca03cb6fa6e0b7fa224c143311cc71ed66b", size = 13418, upload-time = "2026-08-11T19:33:28.592Z" }, { url = "https://files.pythonhosted.org/packages/39/f8/592c2fc205d5d2a80f49960c4576ef52f89bcd43afb481de0a7f0f85d2c7/pyobjc_framework_corebluetooth-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c8d748b3ece787c750cdfc0ab820bd0316a084015453ca48b7885fcf56d3f782", size = 13225, upload-time = "2026-08-11T19:33:29.393Z" }, { url = "https://files.pythonhosted.org/packages/40/70/48680ab2e817b805db661d17fa172d704541f873be593a6f4e9e56f3f030/pyobjc_framework_corebluetooth-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:289ea13ba2b7746235ef47e2d7e7606752d08384022197ccc0e6943b91d4c62d", size = 13416, upload-time = "2026-08-11T19:33:30.12Z" }, { url = "https://files.pythonhosted.org/packages/8c/4a/0116aae535191482e6f110a878b0004c7d4d381764b66c5e09337c4d605c/pyobjc_framework_corebluetooth-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:e8a964e4d32335fd77dd6e9ec0e510208884988c6d63360f80c699405f0beef2", size = 13217, upload-time = "2026-08-11T19:33:30.87Z" }, { url = "https://files.pythonhosted.org/packages/fd/d6/960d2ea2e55bf3856211d07176ff228721ce1863777594065129353c9331/pyobjc_framework_corebluetooth-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:61f4ac689b3a505218b470866931e8ce67e59b70f0877085f9332b169b96d991", size = 13419, upload-time = "2026-08-11T19:33:31.612Z" }, ] [[package]] name = "pyobjc-framework-corelocation" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/d9/ce6e1e697f6f2830c8ac416951a3f7eb6a6c28d1ca4ddeda029da3511b31/pyobjc_framework_corelocation-12.2.2.tar.gz", hash = "sha256:f14b44d96b56dd16f8c40dc067f492668e929fec542e78e9c27c9c9e90f21ba8", size = 60568, upload-time = "2026-08-11T19:44:10.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/a0/063aafbf0821a6d4e4937410b1a313a97cda391ba8d76eb65f56fb43deab/pyobjc_framework_corelocation-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e3ed852ed1e0196333d892d6a646230caf8f3be902e8e476b9bfb7305f72670", size = 12872, upload-time = "2026-08-11T19:33:43.219Z" }, { url = "https://files.pythonhosted.org/packages/71/80/1b43705d092dbda5af62de786c09e9488f1282135f409ac3b01f46ca13fc/pyobjc_framework_corelocation-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:479e615d65b768cfd2f11e6aee94bded7e8fe7a92b3808ddb33b5ec9770bbe09", size = 12998, upload-time = "2026-08-11T19:33:44.053Z" }, { url = "https://files.pythonhosted.org/packages/5a/50/f21a0040a3471a175bc5c298c224145fdd427e390544b3340656358e8fcc/pyobjc_framework_corelocation-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:78fcb8aa3c951251786bc96f6cb7594433e07aad5d3a48a4e84ebbf86871a3d3", size = 12849, upload-time = "2026-08-11T19:33:44.809Z" }, { url = "https://files.pythonhosted.org/packages/66/01/7ced40a5dccb333d8cba400a7e27133eadc206252e67b6b4658e362183ba/pyobjc_framework_corelocation-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:75cb22ae0a8661d34d615d36a04475eb1ae282218fd6c4d86f38a871071f86a6", size = 13002, upload-time = "2026-08-11T19:33:45.733Z" }, { url = "https://files.pythonhosted.org/packages/d7/5f/c1b40a6a75b358a7a0901bb499acd45739a87f55b72b633faed403d71402/pyobjc_framework_corelocation-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bef7efd8f072663293fdf8bc249e43ce7d74e8ba8402646e38bc2e595a85f623", size = 12836, upload-time = "2026-08-11T19:33:46.545Z" }, { url = "https://files.pythonhosted.org/packages/81/2c/09a0d4b9876be2734af2b0fcc93887d4871906f184098d7a154598a70885/pyobjc_framework_corelocation-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e62270352fb3b63e969ba57db2f0e8087032e326541d00eb02832962f237a142", size = 12991, upload-time = "2026-08-11T19:33:47.288Z" }, ] [[package]] name = "pyobjc-framework-coremedia" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/12/0b3896ea04f0fbbe3f2cb37802cadce334f9aef204d911a81934013eba8c/pyobjc_framework_coremedia-12.2.2.tar.gz", hash = "sha256:fe9f972438674893e941e9db5b62f8ba99a33f94a7f1c2ce49b14416f154cd10", size = 98580, upload-time = "2026-08-11T19:44:11.651Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/36/65/6fe4c97d3442abda385f8798becd5bc82b73b4dfd979194e5850c5246dea/pyobjc_framework_coremedia-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4430bda640c5427bf572f8b2a3281d8f4f16b962a499251cf553a0979d88a4e", size = 29431, upload-time = "2026-08-11T19:33:50.621Z" }, { url = "https://files.pythonhosted.org/packages/5f/32/3c023f6b26fffb8c7d07cd6327e892bd546da7cfa8b3bbdaed113f5a59d3/pyobjc_framework_coremedia-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b80285a65465a5340f5806e9e293b46e22ac6f9c3132e79c8139a79b53b3f8c2", size = 29496, upload-time = "2026-08-11T19:33:51.396Z" }, { url = "https://files.pythonhosted.org/packages/74/bb/0676dabebfbf9a89ea268ca26bd978a6c0e5dcf9b2baca86709bd8228708/pyobjc_framework_coremedia-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:edc9d03e5230123c92d4f0413c48449a7f08fa747b9f304b2cc396239acfefe3", size = 29465, upload-time = "2026-08-11T19:33:52.149Z" }, { url = "https://files.pythonhosted.org/packages/17/33/1e2ab0438a546cc33ac18dcdb939c0a7cbbfcc070744f6451d08ccd80d6b/pyobjc_framework_coremedia-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:bb4c56edd411a5c0152a0a9eeb0138f9c173820041beff0cfaff291b9376bade", size = 29525, upload-time = "2026-08-11T19:33:52.943Z" }, { url = "https://files.pythonhosted.org/packages/99/7f/1a5e92a337e924f4cf3781f61b30a0e9fba84eb6fbf2c377959bb842d1e8/pyobjc_framework_coremedia-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:49b53bbe1eea893f94bbf05f1f5220fcef3ccac7310a96d78434c0df6318dc79", size = 29503, upload-time = "2026-08-11T19:33:53.724Z" }, { url = "https://files.pythonhosted.org/packages/db/f6/09e85dc8bffbafdcf74c53d8495db1411f9cc03b32a10593832f4fa81318/pyobjc_framework_coremedia-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf4cd65f87bd073e74b3ede2381dc9355529e6f1b12184da22090f016e68bf7", size = 29560, upload-time = "2026-08-11T19:33:54.712Z" }, ] [[package]] name = "pyobjc-framework-coreml" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/3b/c835535e7aef41afc953e5597ea41e693b7bae80d753ab74b9721e07cba5/pyobjc_framework_coreml-12.2.2.tar.gz", hash = "sha256:3e6abe134634adbcc0c1e843826e42df86e63beb3bc7e8e4a914e93179ae1e75", size = 49750, upload-time = "2026-08-11T19:44:14.011Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/21/7a/46cd02ceb310f2f20e4a5fb78b4f2e456ea0c54799c773343ca575ccfb04/pyobjc_framework_coreml-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:70ae22934f2432677bbbcee04a21db2b0301056cd8390e61101fb59e1d374174", size = 11993, upload-time = "2026-08-11T19:34:13.151Z" }, { url = "https://files.pythonhosted.org/packages/65/80/7212482d1ce6c163c1335f1ec11ebc819d35a90b3ac898e56d2fea9b575c/pyobjc_framework_coreml-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:47b635a2ccc1c2b365b3e0d5a7ff1c5b03c351a9f350418a2d458dad00114429", size = 12211, upload-time = "2026-08-11T19:34:13.876Z" }, { url = "https://files.pythonhosted.org/packages/c5/2a/e24b588906a6cec690cf8147358290e793d8b616aa508f743f52ddfa86e1/pyobjc_framework_coreml-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7b263a80158d4380b208ffb8cbf3b276ed4daf3753723dbbd7e561a25ee2048b", size = 12031, upload-time = "2026-08-11T19:34:14.593Z" }, { url = "https://files.pythonhosted.org/packages/5a/15/bb1c7476a050f9759827dafc8c7294a37a8ecf74728f003ea2ccbe9502c1/pyobjc_framework_coreml-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5971bdf03ed6002ab6e913ac8003077172d8423cc13206246f343518d1515e10", size = 12209, upload-time = "2026-08-11T19:34:15.306Z" }, { url = "https://files.pythonhosted.org/packages/6c/90/49fa6d98787da09f9ebd498f7e7252c3243c7e294a975bf77b860304ec11/pyobjc_framework_coreml-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:20dba373eac775cb487d28a92d02e78903d464367d67dd69aa17543515234183", size = 12025, upload-time = "2026-08-11T19:34:16.043Z" }, { url = "https://files.pythonhosted.org/packages/0d/ee/2253e698cd41ee7ea9b3f1fd796cad822722d6e351f55731809c52005421/pyobjc_framework_coreml-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b3711abe3380c3343d57dfa490578ef3c87ca0cdc8a403d81bc35e750ef48d49", size = 12201, upload-time = "2026-08-11T19:34:17.221Z" }, ] [[package]] name = "pyobjc-framework-quartz" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/b1/426a37c7ae37280b3ffca2571fb48f211946aee2f4ca31a603ed1943c4a7/pyobjc_framework_quartz-12.2.2.tar.gz", hash = "sha256:810f97b210cfd93704d240860286dfd6df09f9f1c52525fc5c2166723aea3f9e", size = 3218295, upload-time = "2026-08-11T19:45:15.189Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bb/ae/b515852dbe491171f2f2e2eb7739588a5eb7f36720a739545337b8c0d706/pyobjc_framework_quartz-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0ec9751904ef975bf0789d760dc4fadcb400edc4ffe4a736eb54971968babe5c", size = 219404, upload-time = "2026-08-11T19:40:25.461Z" }, { url = "https://files.pythonhosted.org/packages/ff/5f/c7ee66f4483385396d91f65036c62f3dd20bccaa07354643ef17b259aa75/pyobjc_framework_quartz-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:63f6f0f3233dcf650aac1374781e78961b0b17b33e3351953bacf8bd0c430593", size = 224462, upload-time = "2026-08-11T19:40:26.647Z" }, { url = "https://files.pythonhosted.org/packages/b1/33/230ae7777b0909fe2c24f28413c51c860faebf762d824b097a0e2fb48304/pyobjc_framework_quartz-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f7f3d9010e38f03ea1fa266664c10ea349cd7492bd603b403584f49d713dbed", size = 219773, upload-time = "2026-08-11T19:40:27.64Z" }, { url = "https://files.pythonhosted.org/packages/25/eb/7482fdd384521916e98a6164be220494b1f7794792b1d60fa3f5207d84a4/pyobjc_framework_quartz-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ebf8167ca2096cf3a05199decfa517be0df4c56048f49cf132bd6b1a6ab9c086", size = 224726, upload-time = "2026-08-11T19:40:28.718Z" }, { url = "https://files.pythonhosted.org/packages/33/67/b4b0ffc486b08492bfef4b33731d044b295f1696c8fb7d119c8367079139/pyobjc_framework_quartz-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cee63b891c2b6b7ccf98f233175411529f3e80286f58438793b3634af79858f1", size = 219827, upload-time = "2026-08-11T19:40:29.764Z" }, { url = "https://files.pythonhosted.org/packages/aa/eb/5fb627c2457046883c6fd12d25c44db40c12bcde4622dc0f30851108e106/pyobjc_framework_quartz-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8f58c589b5a76ba98f186b1f3b19fb1c8b730e82351f81fb62e6194f64a71622", size = 224767, upload-time = "2026-08-11T19:40:30.991Z" }, ] [[package]] name = "pyobjc-framework-usernotifications" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/55/c96dc372855b50fc40f8ded5666ce7f07d62b508b83ea73ac19d6cdfe601/pyobjc_framework_usernotifications-12.2.2.tar.gz", hash = "sha256:ef2432463ae8c3ae75c6e434df07534bc9c79c0911e1125aaa58d9f51b32406b", size = 34160, upload-time = "2026-08-11T19:45:38.322Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/66/20/d1cbbb58c3ce011c0f66ce79beabae135ac40fc887aea61f0707a6cceb89/pyobjc_framework_usernotifications-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e56cd2e57d63b91eb7585803386fe7cab8c8f889c499b838b32f9f4ff3bd828", size = 10224, upload-time = "2026-08-11T19:43:00.16Z" }, { url = "https://files.pythonhosted.org/packages/ca/b2/ebd5d7f07326f53abd3081ccb42ace6a316cd984c65cc3632c851a9c1e88/pyobjc_framework_usernotifications-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fc685f6dc874d3bd47d28ced736e5835aa2026e0d912548c460e8f1f77c12d15", size = 10382, upload-time = "2026-08-11T19:43:00.866Z" }, { url = "https://files.pythonhosted.org/packages/f4/0c/735585bf18bee99ef237078d8c45dafc813eb4de4b95617404f9e9b992ec/pyobjc_framework_usernotifications-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:70ae6dd2a8f5c3c6dda340f68fd46591a288257c0f0baac1ca457c9d83dccd40", size = 10291, upload-time = "2026-08-11T19:43:01.6Z" }, { url = "https://files.pythonhosted.org/packages/e6/05/2bc66bb51279c518b137d87ed705ae828c3bfa7ed5deadd8e0dee5ce3bd6/pyobjc_framework_usernotifications-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e996f1c5328ef7c23959c9bc1d901811c85ce6537746788b7d4f0005a755ead", size = 10449, upload-time = "2026-08-11T19:43:02.338Z" }, { url = "https://files.pythonhosted.org/packages/85/6b/3a13df80f58ae15a2aeb0a2557c12159c7827999461424939e8293f6a117/pyobjc_framework_usernotifications-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:dd1dff373af91ad313a6a26ab182ec37494793fed2208f52e9a8569c1010f0de", size = 10281, upload-time = "2026-08-11T19:43:03.099Z" }, { url = "https://files.pythonhosted.org/packages/b9/a5/4dcf4ecbc47f0db32ed7c6d47ae001f2fb8511ffffa993a536bfaac6d1f6/pyobjc_framework_usernotifications-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:4bf980e4b120c326fa59fd668f244ffdaa3cb09671659d1bb73d2f6b1223b0ac", size = 10446, upload-time = "2026-08-11T19:43:03.813Z" }, ] [[package]] name = "pyobjc-framework-vision" version = "12.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-coreml" }, { name = "pyobjc-framework-quartz" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ea/bf/31e8bcae94047b365592ab7533096a4025c0306a8a312b65bcbf57e073ec/pyobjc_framework_vision-12.2.2.tar.gz", hash = "sha256:a6bf78e8dca145c6a78fd8d5f925b6da54649a60a1464b81ff405bca4a08406b", size = 73289, upload-time = "2026-08-11T19:45:41.877Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c9/f6/e3cb58a12ab51ff9ac159988fc3c6410a60234dc7b448223fc090cd3d1f7/pyobjc_framework_vision-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:306e536e12d3565e53649818e3324e801fbb1696ea75ba751852dffbacba8b14", size = 16961, upload-time = "2026-08-11T19:43:24.668Z" }, { url = "https://files.pythonhosted.org/packages/03/16/2791ee2985b3ce4dde1e05e355d890583a4db19729f1b8dda3549510ef91/pyobjc_framework_vision-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bd02315e62b16397f5fbcd0dcc23744bdf86c9ce5ff94970a2befbf14a8c2ea7", size = 17105, upload-time = "2026-08-11T19:43:25.722Z" }, { url = "https://files.pythonhosted.org/packages/28/c1/2951e9182e2c077d2c23c3f0772a1cfbf6bd627247388ac795a53888b08a/pyobjc_framework_vision-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:66eab9ecdc741f4ee72cc287c5ffccfe6f81fcdd8544bec98e5cc6c50b901865", size = 16940, upload-time = "2026-08-11T19:43:26.513Z" }, { url = "https://files.pythonhosted.org/packages/57/fc/7431c063eeb9f44b75fcdf213dbfd50c7822af1eba8e40df31799a095ac3/pyobjc_framework_vision-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6dd4c8a804a89585ae170f0b5eb5265538d75fc76092e9f33a67386304d19aed", size = 17096, upload-time = "2026-08-11T19:43:27.272Z" }, { url = "https://files.pythonhosted.org/packages/6b/90/85f349d583f691bf64473b52ae96ba52320226816ae3e54f4d4eeeb7ba27/pyobjc_framework_vision-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:60b61390c250640b0dbc1b88861bb80edf6dfa699e90b91a5b4bfd37d4a71b99", size = 16930, upload-time = "2026-08-11T19:43:28.029Z" }, { url = "https://files.pythonhosted.org/packages/6f/9d/fa24a7234a7d1100225582968ae29ad3aeb2616f1528fc877384895a3e9d/pyobjc_framework_vision-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c323455ddabc5fab868c00797fedd5e338688054c776cd6a7801d36f045e9d26", size = 17096, upload-time = "2026-08-11T19:43:28.828Z" }, ] [[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 = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[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/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" }, ]