Repository: pdxlocations/contact Branch: main Commit: 69cb568d2cd9 Files: 68 Total size: 481.3 KB Directory structure: gitextract_t_q0hbte/ ├── .github/ │ └── workflows/ │ ├── contact-buildx.yml │ └── release.yaml ├── .gitignore ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── contact/ │ ├── __main__.py │ ├── localisations/ │ │ ├── en.ini │ │ ├── fr.ini │ │ └── ru.ini │ ├── message_handlers/ │ │ ├── bot_handler.py │ │ ├── rx_handler.py │ │ └── tx_handler.py │ ├── settings.py │ ├── ui/ │ │ ├── colors.py │ │ ├── contact_ui.py │ │ ├── control_ui.py │ │ ├── default_config.py │ │ ├── dialog.py │ │ ├── menus.py │ │ ├── nav_utils.py │ │ ├── splash.py │ │ ├── ui_state.py │ │ └── user_config.py │ └── utilities/ │ ├── arg_parser.py │ ├── config_io.py │ ├── control_utils.py │ ├── db_handler.py │ ├── demo_data.py │ ├── emoji_utils.py │ ├── i18n.py │ ├── ini_utils.py │ ├── input_handlers.py │ ├── interfaces.py │ ├── save_to_radio.py │ ├── singleton.py │ ├── telemetry_beautifier.py │ ├── utils.py │ └── validation_rules.py ├── pyproject.toml ├── requirements.txt └── tests/ ├── __init__.py ├── test_arg_parser.py ├── test_bot_handler.py ├── test_config_io.py ├── test_contact_ui.py ├── test_control_ui.py ├── test_control_utils.py ├── test_db_handler.py ├── test_default_config.py ├── test_demo_data.py ├── test_dialog.py ├── test_emoji_utils.py ├── test_i18n.py ├── test_ini_utils.py ├── test_interfaces.py ├── test_main.py ├── test_menus.py ├── test_nav_utils.py ├── test_rx_handler.py ├── test_save_to_radio.py ├── test_settings.py ├── test_support.py ├── test_telemetry_beautifier.py ├── test_tx_handler.py ├── test_utils.py └── test_validation_rules.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/contact-buildx.yml ================================================ name: contact-buildx on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" workflow_dispatch: jobs: build-and-push-contact: runs-on: ubuntu-latest steps: - name: clone https://github.com/pdxlocations/contact.git uses: actions/checkout@master with: name: pdxlocations/contact repository: pdxlocations/contact path: ./contact - name: Set up QEMU uses: docker/setup-qemu-action@master - name: Set up Docker Buildx uses: docker/setup-buildx-action@master - name: Login to DockerHub uses: docker/login-action@master with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get current commit run: | echo version=$(git -C ./contact rev-parse HEAD) >> $GITHUB_ENV - name: Build and push pdxlocations/contact uses: docker/build-push-action@master with: context: ./contact file: ./contact/Dockerfile platforms: linux/amd64,linux/arm64,linux/armhf push: true tags: pdxlocations/contact:latest,pdxlocations/contact:${{ env.version }} ================================================ FILE: .github/workflows/release.yaml ================================================ name: release on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" env: PACKAGE_NAME: "contact" OWNER: "pdxlocations" jobs: details: runs-on: ubuntu-latest outputs: new_version: ${{ steps.release.outputs.new_version }} suffix: ${{ steps.release.outputs.suffix }} tag_name: ${{ steps.release.outputs.tag_name }} steps: - uses: actions/checkout@v2 - name: Extract tag and Details id: release run: | if [ "${{ github.ref_type }}" = "tag" ]; then TAG_NAME=${GITHUB_REF#refs/tags/} NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" echo "Version is $NEW_VERSION" echo "Suffix is $SUFFIX" echo "Tag name is $TAG_NAME" else echo "No tag found" exit 1 fi check_pypi: needs: details runs-on: ubuntu-latest steps: - name: Fetch information from PyPI run: | response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") if [ -z "$latest_previous_version" ]; then echo "Package not found on PyPI." latest_previous_version="0.0.0" fi echo "Latest version on PyPI: $latest_previous_version" echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV - name: Compare versions and exit if not newer run: | NEW_VERSION=${{ needs.details.outputs.new_version }} LATEST_VERSION=$latest_previous_version if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." exit 1 else echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." fi setup_and_build: needs: [details, check_pypi] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.12" - name: Install Poetry run: | curl -sSL https://install.python-poetry.org | python3 - echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Set project version with Poetry run: | poetry version ${{ needs.details.outputs.new_version }} - name: Install dependencies run: poetry install --sync --no-interaction - name: Build source and wheel distribution run: | poetry build - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ pypi_publish: name: Upload release to PyPI needs: [setup_and_build, details] runs-on: ubuntu-latest environment: name: release permissions: id-token: write steps: - name: Download artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github_release: name: Create GitHub Release needs: [setup_and_build, details] runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout Code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Download artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Create GitHub Release id: create_release env: GH_TOKEN: ${{ github.token }} run: | gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes ================================================ FILE: .gitignore ================================================ venv/ .venv/ __pycache__/ node-configs/ client.db .DS_Store client.log settings.log config.json default_config.log dist/ .vscode/launch.json ================================================ FILE: .vscode/settings.json ================================================ { "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true } } ================================================ FILE: Dockerfile ================================================ FROM docker.io/python:3.14 COPY . /app WORKDIR /data # Install contact RUN python -m pip install /app && rm -rf /app VOLUME /data ENTRYPOINT [ "contact" ] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU 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. Copyright (C) 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: Copyright (C) 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 . ================================================ FILE: README.md ================================================ ## Contact - A Console UI for Meshtastic #### Powered by Meshtastic.org ### Install with: ```bash pip install contact ``` > [!NOTE] > Windows users must also install: > > ```powershell > pip install windows-curses > ``` > because the built-in curses module is not available on Windows. This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores. Contact - Main UI Screenshot

The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c` Screenshot 2025-04-08 at 6 10 06 PM ### Docker install Install with Docker: ``` docker build -t contact . # Change /tmp/data to a directory you'd like to persist the database in export DATA_DIR="/tmp/contact" mkdir -p "$DATA_DIR" docker run -it --rm -v $DATA_DIR:/data --workdir /data --device=/dev/ttyUSB0 contact --port /dev/ttyUSB0 ``` ## Message Persistence All messages will saved in a SQLite DB and restored upon relaunch of the app. You may delete `client.db` if you wish to erase all stored messages and node data. If multiple nodes are used, each will independently store data in the database, but the data will not be shared or viewable between nodes. ## Client Configuration By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more! For smaller displays you may wish to enable `single_pane_mode`: Screenshot 2025-08-22 at 11 15 54 PM ## Commands - `CTRL` + `k` = display a list of commands. - `↑→↓←` = Navigate around the UI. - `F1/F2/F3` = Jump to Channel/Messages/Nodes - `ENTER` = Send a message typed in the Input Window, or with the Node List highlighted, select a node to DM - `` ` `` or `F12` = Open the Settings dialogue - `CTRL` + `p` = Hide/show a log of raw received packets. - `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node - `F5` = Display a node's info - `CTRL` + `f` = With the Node List highlighted, favorite the selected node - `CTRL` + `b` = Enable/Disable Autoresponder Bot (ping / pong) - `CTRL` + `g` = With the Node List highlighted, ignore the selected node - `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user. - `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb. - `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed. ### Search - Press `CTRL` + `/` while the nodes or channels window is highlighted to start search - Type text to search as you type, first matching item will be selected, starting at current selected index - Press Tab to find next match starting from the current index - search wraps around if necessary - Press Esc or Enter to exit search mode ## Arguments You can pass the following arguments to the client: ### Connection Arguments Optional arguments to specify a device to connect to and how. - `--port`, `--serial`, `-s`: The port to connect to via serial, e.g. `/dev/ttyUSB0`. - `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed. - `--ble`, `-b`: The BLE device MAC address or name to connect to. - `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings. If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost. ### Example Usage ```sh contact --port /dev/ttyUSB0 contact --host 192.168.1.1 contact --ble BlAddressOfDevice contact --port COM3 ``` To quickly connect to localhost, use: ```sh contact -t ``` ## Install in development (editable) mode: ```bash git clone https://github.com/pdxlocations/contact.git cd contact python3 -m venv .venv source .venv/bin/activate pip install -e . ``` ================================================ FILE: contact/__main__.py ================================================ #!/usr/bin/env python3 """ Contact - A Console UI for Meshtastic by http://github.com/pdxlocations Powered by Meshtastic.org Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses—see GitHub for details. No warranty is provided. Use at your own risk. """ # Standard library import contextlib import curses import io import logging import os import subprocess import sys import threading import traceback from typing import Optional # Third-party from pubsub import pub # Local application import contact.ui.default_config as config from contact.message_handlers.rx_handler import on_receive from contact.settings import set_region from contact.ui.colors import setup_colors from contact.ui.contact_ui import main_ui from contact.ui.splash import draw_splash from contact.utilities.arg_parser import setup_parser from contact.utilities.db_handler import init_nodedb, load_messages_from_db from contact.utilities.demo_data import build_demo_interface, configure_demo_database, seed_demo_messages from contact.utilities.input_handlers import get_list_input from contact.utilities.i18n import t from contact.ui.dialog import dialog from contact.utilities.interfaces import initialize_interface, reconnect_interface from contact.utilities.utils import get_channels, get_nodeNum, get_node_list from contact.utilities.singleton import ui_state, interface_state, app_state # ------------------------------------------------------------------------------ # Environment & Logging Setup # ------------------------------------------------------------------------------ os.environ["NCURSES_NO_UTF8_ACS"] = "1" os.environ["LANG"] = "C.UTF-8" os.environ.setdefault("TERM", "xterm-256color") if os.environ.get("COLORTERM") == "gnome-terminal": os.environ["TERM"] = "xterm-256color" logging.basicConfig( filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) app_state.lock = threading.Lock() DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0 # ------------------------------------------------------------------------------ # Main Program Logic # ------------------------------------------------------------------------------ def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None) -> None: """Prompt user to set region if it is unset.""" confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": set_region(interface_state.interface) close_interface(interface_state.interface) if stdscr is not None: draw_splash(stdscr) interface_state.interface = reconnect_interface(args) def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool: if interface is None: return True close_errors = [] def _close_target() -> None: try: interface.close() except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries. close_errors.append(error) close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True) close_thread.start() close_thread.join(timeout_seconds) if close_thread.is_alive(): logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds) return False if not close_errors: return True error = close_errors[0] if isinstance(error, KeyboardInterrupt): logging.info("Interrupted while closing interface; continuing shutdown") return True logging.warning("Ignoring error while closing interface: %r", error) return True def interface_is_ready(interface: object) -> bool: try: return getattr(interface, "localNode", None) is not None and interface.localNode.localConfig is not None except Exception: return False def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object): while True: interface = initialize_runtime_interface(args) if getattr(args, "demo_screenshot", False) or interface_is_ready(interface): return interface choice = get_list_input( t("ui.prompt.node_not_found", default="No node found. Retry connection?"), "Retry", ["Retry", "Close"], mandatory=True, ) close_interface(interface) if choice == "Close": return None draw_splash(stdscr) def initialize_globals(seed_demo: bool = False) -> None: """Initializes interface and shared globals.""" ui_state.channel_list = [] ui_state.all_messages = {} ui_state.notifications = [] ui_state.packet_buffer = [] ui_state.node_list = [] ui_state.selected_channel = 0 ui_state.selected_message = 0 ui_state.selected_node = 0 ui_state.start_index = [0, 0, 0] interface_state.myNodeNum = get_nodeNum() ui_state.channel_list = get_channels() ui_state.node_list = get_node_list() ui_state.single_pane_mode = config.single_pane_mode.lower() == "true" pub.subscribe(on_receive, "meshtastic.receive") init_nodedb() if seed_demo: seed_demo_messages() load_messages_from_db() def initialize_runtime_interface(args: object): if getattr(args, "demo_screenshot", False): configure_demo_database() return build_demo_interface() return initialize_interface(args) def main(stdscr: curses.window) -> None: """Main entry point for the curses UI.""" output_capture = io.StringIO() try: setup_colors() ensure_min_rows(stdscr) draw_splash(stdscr) args = setup_parser().parse_args() if getattr(args, "settings", False): subprocess.run([sys.executable, "-m", "contact.settings"], check=True) return logging.info("Initializing interface...") with app_state.lock: interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args) if interface_state.interface is None: return if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0: prompt_region_if_unset(args, stdscr) initialize_globals(seed_demo=getattr(args, "demo_screenshot", False)) logging.info("Starting main UI") stdscr.clear() stdscr.refresh() try: with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture): main_ui(stdscr) except Exception: console_output = output_capture.getvalue() logging.error("Uncaught exception inside main_ui") logging.error("Traceback:\n%s", traceback.format_exc()) logging.error("Console output:\n%s", console_output) return except Exception: raise def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None: while True: rows, _ = stdscr.getmaxyx() if rows >= min_rows: return dialog( t("ui.dialog.resize_title", default="Resize Terminal"), t( "ui.dialog.resize_body", default="Please resize the terminal to at least {rows} rows.", rows=min_rows, ), ) curses.update_lines_cols() stdscr.clear() stdscr.refresh() def start() -> None: """Entry point for the application.""" if "--help" in sys.argv or "-h" in sys.argv: setup_parser().print_help() sys.exit(0) interrupted = False fatal_error = None try: curses.wrapper(main) except KeyboardInterrupt: interrupted = True logging.info("User exited with Ctrl+C") except Exception as e: fatal_error = e logging.critical("Fatal error", exc_info=True) try: curses.endwin() except Exception: pass finally: close_interface(interface_state.interface) if fatal_error is not None: print("Fatal error:", fatal_error) traceback.print_exc() sys.exit(1) if interrupted: sys.exit(0) if __name__ == "__main__": start() ================================================ FILE: contact/localisations/en.ini ================================================ #field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks." Main Menu, "Main Menu", "" User Settings, "User Settings", "" Channels, "Channels", "" Radio Settings, "Radio Settings", "" Module Settings, "Module Settings", "" App Settings, "App Settings", "" Export Config File, "Export Config File", "" Load Config File, "Load Config File", "" Config URL, "Config URL", "" Reboot, "Reboot", "" Reset Node DB, "Reset Node DB", "" Shutdown, "Shutdown", "" Factory Reset, "Factory Reset", "" factory_reset_config, "Factory Reset Config", "" Exit, "Exit", "" Yes, "Yes", "" No, "No", "" Cancel, "Cancel", "" [ui] save_changes, "Save Changes", "" dialog.invalid_input, "Invalid Input", "" prompt.enter_new_value, "Enter new value: ", "" error.value_empty, "Value cannot be empty.", "" error.value_exact_length, "Value must be exactly {length} characters long.", "" error.value_min_length, "Value must be at least {length} characters long.", "" error.value_max_length, "Value must be no more than {length} characters long.", "" error.digits_only, "Only numeric digits (0-9) allowed.", "" error.number_range, "Enter a number between {min_value} and {max_value}.", "" error.float_invalid, "Must be a valid floating point number.", "" prompt.edit_admin_keys, "Edit up to 3 Admin Keys:", "" label.admin_key, "Admin Key", "" error.admin_key_invalid, "Error: Each key must be valid Base64 and 32 bytes long!", "" prompt.edit_values, "Edit up to 3 Values:", "" label.value, "Value", "" prompt.enter_ip, "Enter an IP address (xxx.xxx.xxx.xxx):", "" label.current, "Current", "" label.new_value, "New value", "" label.editing, "Editing {label}", "" label.current_value, "Current Value:", "" error.ip_invalid, "Invalid IP address. Try again.", "" prompt.select_foreground_color, "Select Foreground Color for {label}", "" prompt.select_background_color, "Select Background Color for {label}", "" prompt.select_value, "Select {label}", "" confirm.save_before_exit, "You have unsaved changes. Save before exiting?", "" prompt.config_filename, "Enter a filename for the config file", "" confirm.overwrite_file, "{filename} already exists. Overwrite?", "" dialog.config_saved_title, "Config File Saved:", "" dialog.no_config_files, " No config files found. Export a config first.", "" prompt.choose_config_file, "Choose a config file", "" confirm.load_config_file, "Are you sure you want to load {filename}?", "" prompt.config_url_current, "Config URL is currently: {value}", "" confirm.load_config_url, "Are you sure you want to load this config?", "" confirm.reboot, "Are you sure you want to Reboot?", "" confirm.reset_node_db, "Are you sure you want to Reset Node DB?", "" confirm.shutdown, "Are you sure you want to Shutdown?", "" confirm.factory_reset, "Are you sure you want to Factory Reset?", "" confirm.factory_reset_config, "Are you sure you want to Factory Reset Config?", "" confirm.save_before_exit_section, "You have unsaved changes in {section}. Save before exiting?", "" prompt.select_region, "Select your region:", "" dialog.slow_down_title, "Slow down", "" dialog.slow_down_body, "Please wait 2 seconds between messages.", "" dialog.node_details_title, "📡 Node Details: {name}", "" dialog.traceroute_not_sent_title, "Traceroute Not Sent", "" dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending another traceroute.", "" dialog.traceroute_sent_title, "Traceroute Sent To: {name}", "" dialog.traceroute_sent_body, "Results will appear in messages window.", "" dialog.help_title, "Help - Shortcut Keys", "" help.scroll, "Up/Down = Scroll", "" help.switch_window, "Left/Right = Switch window", "" help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", "" help.enter, "ENTER = Send / Select", "" help.settings, "` or F12 = Settings", "" help.quit, "ESC = Quit", "" help.packet_log, "Ctrl+P = Toggle Packet Log", "" help.traceroute, "Ctrl+T or F4 = Traceroute", "" help.node_info, "F5 = Full node info", "" help.archive_chat, "Ctrl+D = Archive chat / remove node", "" help.favorite, "Ctrl+F = Favorite", "" help.bot_responder, "Ctrl+B = Toggle Bot Responder", "" help.ignore, "Ctrl+G = Ignore", "" help.search, "Ctrl+/ or / = Search", "" help.help, "Ctrl+K = Help", "" help.no_help, "No help available.", "" confirm.remove_from_nodedb, "Remove {name} from nodedb?", "" confirm.set_favorite, "Set {name} as Favorite?", "" confirm.remove_favorite, "Remove {name} from Favorites?", "" confirm.set_ignored, "Set {name} as Ignored?", "" confirm.remove_ignored, "Remove {name} from Ignored?", "" confirm.region_unset, "Your region is UNSET. Set it now?", "" dialog.resize_title, "Resize Terminal", "" dialog.resize_body, "Please resize the terminal to at least {rows} rows.", "" bot.status.enabled, "Enabled", "" bot.status.disabled, "Disabled", "" bot.dialog.title, "Bot Responder", "" bot.dialog.body, "Bot responder is now {status}.", "" bot.status.message, "Bot responder is now {status}.", "" [User Settings] user, "User" longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign." shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis." isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling." [app_settings] title, "App Settings", "" channel_list_16ths, "Channel list width", "Width of channel list in sixteenths of the screen." node_list_16ths, "Node list width", "Width of node list in sixteenths of the screen." single_pane_mode, "Single pane mode", "Show a single-pane layout." db_file_path, "Database file path", "" log_file_path, "Log file path", "" node_configs_file_path, "Node configs path", "" language, "Language", "UI language for labels and help text." message_prefix, "Message prefix", "" sent_message_prefix, "Sent message prefix", "" notification_symbol, "Notification symbol", "" notification_sound, "Notification sound", "" ack_implicit_str, "ACK (implicit)", "" ack_str, "ACK", "" nak_str, "NAK", "" ack_unknown_str, "ACK (unknown)", "" node_sort, "Node sort", "" theme, "Theme", "" ping_bot, "Ping Bot", "" COLOR_CONFIG_DARK, "Theme colors (dark)", "" COLOR_CONFIG_LIGHT, "Theme colors (light)", "" COLOR_CONFIG_GREEN, "Theme colors (green)", "" [app_settings.ping_bot] title, "Ping Bot", "" catch_words, "Catch words", "Semicolon-separated bot trigger words." response_word, "Response word", "Bot response word." [app_settings.color_config] default, "Default", "" background, "Background", "" splash_logo, "Splash logo", "" splash_text, "Splash text", "" input, "Input", "" node_list, "Node list", "" channel_list, "Channel list", "" channel_selected, "Channel selected", "" rx_messages, "Received messages", "" tx_messages, "Sent messages", "" timestamps, "Timestamps", "" commands, "Commands", "" window_frame, "Window frame", "" window_frame_selected, "Window frame selected", "" log_header, "Log header", "" log, "Log", "" settings_default, "Settings default", "" settings_sensitive, "Settings sensitive", "" settings_save, "Settings save", "" settings_breadcrumbs, "Settings breadcrumbs", "" settings_warning, "Settings warning", "" settings_note, "Settings note", "" node_favorite, "Node favorite", "" node_ignored, "Node ignored", "" [Channels.channel] title, "Channels" channel_num, "Channel number", "The index number of this channel." psk, "PSK", "The channel's encryption key." name, "Name", "The channel's name." id, "", "" uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT server configured on this node." downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel." module_settings, "Module settings", "Position precision and Client Mute." module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel." module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable." [config.device] title, "Device" role, "Role", "For the vast majority of users, the correct choice is CLIENT. See Meshtastic docs for more information." serial_enabled, "Enable serial console", "Serial Console over the Stream API." button_gpio, "Button GPIO", "GPIO pin for user button." buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer." rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast." node_info_broadcast_secs, "Nodeinfo broadcast interval", "This is the number of seconds between NodeInfo message broadcasts. Will also send a nodeinfo in response to new nodes on the mesh." double_tap_as_button_press, "Double tap as button press", "This option will enable a double tap, when a supported accelerometer is attached to the device, to be treated as a button press." is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note] Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" disable_triple_click, "Disable triple button press", "" tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs." led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED." buzzer_mode, "Buzzer Mode", "Controls buzzer behavior for audio feedback." [config.position] title, "Position" position_broadcast_secs, "Position broadcast interval", "If smart broadcast is off we should send our position this often." position_broadcast_smart_enabled, "Smart position broadcast enabled", "Smart broadcast will send out your position at an increased frequency only if your location has changed enough for a position update to be useful." fixed_position, "Fixed position", "If set, this use a fixed position. The device will generate GPS updates but use whatever the last lat/lon/alt it saved for the node. Position can be set by an internal GPS or with smartphone GPS." latitude, "Latitude", "" longitude, "Longitude", "" altitude, "Altitude", "" gps_enabled, "GPS enabled", "" gps_update_interval, "GPS update interval", "How often we should try to get GPS position (in seconds), or zero for the default of once every 2 minutes, or a very large value (maxint) to update only once at boot." gps_attempt_time, "GPS attempt time", "" position_flags, "Position flags", "See Meshtastic docs for more information." rx_gpio, "GPS RX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the RX pin of a GPS module." tx_gpio, "GPS TX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the TX pin of a GPS module." broadcast_smart_minimum_distance, "GPS smart position min distance", "The minimum distance in meters traveled (since the last send) before we can send a position to the mesh if smart broadcast is enabled." broadcast_smart_minimum_interval_secs, "GPS smart position min interval", "The minimum number of seconds (since the last send) before we can send a position to the mesh if smart broadcast is enabled." gps_en_gpio, "GPS enable GPIO", "" gps_mode, "GPS mode", "Configures whether the GPS functionality is enabled, disabled, or not present on the node." [config.power] title, "Power" is_power_saving, "Enable power saving mode", "Automatically shut down a device after this many seconds if power is lost." on_battery_shutdown_after_secs, "Battery shutdown interval", "" adc_multiplier_override, "ADC multiplier override", "Ratio of voltage divider for battery pin. Overrides the ADC_MULTIPLIER defined in the firmware device variant file for battery voltage calculation. See Meshtastic docs for more info." wait_bluetooth_secs, "Bluetooth", "How long to wait before turning off BLE when no bluetooth device is connected." sds_secs, "Super deep sleep interval", "While in Light Sleep if mesh_sds_timeout_secs is exceeded we will lower into super deep sleep for this value or a button press. 0 for default of one year" ls_secs, "Light sleep interval", "ESP32 only. In light sleep the CPU is suspended, LoRa radio is on, BLE is off and GPS is on." min_wake_secs, "Minimum wake interval", "While in light sleep when we receive packets on the LoRa radio we will wake and handle them and stay awake in no Bluetooth mode for this interval in seconds." device_battery_ina_address, "Device battery INA2xx address", "If an INA-2XX device is auto-detected on one of the I2C buses at the specified address, it will be used as the authoritative source for reading device battery level voltage. Setting is ignored for devices with PMUs (e.g. T-beams)" powermon_enables, "Power monitor enables", "If non-zero, we want powermon log outputs. With the particular (bitfield) sources enabled." [config.network] title, "Network" wifi_enabled, "Wi-Fi enabled", "Enables or Disables Wi-Fi." wifi_ssid, "Wi-Fi SSID", "This is your Wi-Fi Network's SSID." wifi_psk, "Wi-Fi PSK", "This is your Wi-Fi Network's password." ntp_server, "NTP server", "The network time server used if IP networking is available." eth_enabled, "Ethernet enabled", "Enables or Disables Ethernet on some hardware models." address_mode, "IPv4 networking mode", "Set to DHCP by default. Change to STATIC to use a static IP address. Applies to both Ethernet and Wi-Fi." ipv4_config, "IPv4 configuration", "Advanced network settings" ip, "IPv4 static address", "" gateway, "IPv4 gateway", "" subnet, "IPv4 subnet", "" dns, "IPv4 DNS server", "" rsyslog_server, "RSyslog server", "" enabled_protocols, "Enabled protocols", "" ipv6_enabled, "IPv6 enabled", "Enables or Disables IPv6 networking." [config.network.ipv4_config] title, "IPv4 Config", "" ip, "IP", "" gateway, "Gateway", "" subnet, "Subnet", "" dns, "DNS", "" [config.display] title, "Display" screen_on_secs, "Screen on duration", "How long the screen remains on in seconds after the user button is pressed or messages are received." gps_format, "GPS format", "The format used to display GPS coordinates on the device screen." auto_screen_carousel_secs, "Auto carousel interval", "Automatically toggles to the next page on the screen like a carousel, based on the specified interval in seconds." compass_north_top, "Always point north", "If set, compass heading on screen outside of the circle will always point north. This feature is off by default and the top of display represents your heading direction, the North indicator will move around the circle." flip_screen, "Flip screen", "Whether to flip the screen vertically." units, "Preferred display units", "Switch between METRIC (default) and IMPERIAL units." oled, "OLED definition", "The type of OLED Controller is auto-detected by default, but can be defined with this setting if the auto-detection fails. For the SH1107, we assume a square display with 128x128 Pixels like the GME128128-1." displaymode, "Display mode", "DEFAULT, TWOCOLOR, INVERTED or COLOR. TWOCOLOR: intended for OLED displays with first line a different color. INVERTED: will invert bicolor area, resulting in white background headline on monochrome displays." heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' or 'TWOCOLOR' display mode is used. This setting will make the heading bold, so it is easier to read." wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button." compass_orientation, "Compass orientation", "Whether to rotate the compass." use_12h_clock, "Use 12 hour clock" [config.device_ui] title, "Device UI" version, "Version", "" screen_brightness, "Screen brightness", "" screen_timeout, "Screen timeout", "" screen_lock, "Screen lock", "" settings_lock, "Settings lock", "" pin_code, "PIN code", "" theme, "Theme", "" alert_enabled, "Alert enabled", "" banner_enabled, "Banner enabled", "" ring_tone_id, "Ring tone ID", "" language, "Language", "" node_filter, "Node Filter", "" node_highlight, "Node Highlight", "" calibration_data, "Calibration Data", "" map_data, "Map Data", "" [config.device_ui.node_filter] title, "Node Filter" unknown_switch, "Unknown Switch", "" offline_switch, "Offline Switch", "" public_key_switch, "Public Key Switch", "" hops_away, "Hops Away", "" position_switch, "Position Switch", "" node_name, "Node Name", "" channel, "Channel", "" [config.device_ui.node_highlight] title, "Node Highlight" chat_switch, "Chat Switch", "" position_switch, "Position Switch", "" telemetry_switch, "Telemetry Switch", "" iaq_switch, "IAQ Switch", "" node_name, "Node Name", "" [config.device_ui.map_data] title, "Map Data" home, "Home", "" style, "Style", "" follow_gps, "Follow GPS", "" [config.lora] title, "LoRa" use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset." modem_preset, "Preset", "The default preset will provide a strong mixture of speed and range, for most users." bandwidth, "Bandwidth", "Width of the frequency 'band' used around the calculated center frequency. Only used if modem preset is disabled." spread_factor, "Spread factor", "Indicates the number of chirps per symbol. Only used if modem preset is disabled." coding_rate, "Coding rate", "The proportion of each LoRa transmission that contains actual data - the rest is used for error correction." frequency_offset, "Frequency offset", "This parameter is for advanced users with advanced test equipment." region, "Region", "Sets the region for your node. As long as this is not set, the node will display a message and not transmit any packets." hop_limit, "Hop limit", "The maximum number of intermediate nodes between our node and a node it is sending to. Does not impact received messages.\n[warning]Excessive hop limit increases congestion![/warning]\nMust be between 0-7." tx_enabled, "Enable TX", "Enables/disables the radio chip. Useful for hot-swapping antennas." tx_power, "TX power in dBm", "[warning]Setting a 33db radio above 8db will permanently damage it. ERP above 27db violates EU law. ERP above 36db violates US (unlicensed) law.[/warning] If 0, will use the max continuous power legal in region. Must be 0-30 (0=automatic)." channel_num, "Frequency slot", "Determines the exact frequency the radio transmits and receives. If unset or set to 0, determined automatically by the primary channel name." override_duty_cycle, "Override duty cycle", "Override the legal transmit time limit to allow unlimited transmit time. [warning]May have legal ramifications.[/warning]" sx126x_rx_boosted_gain, "Enable SX126X RX boosted gain", "This is an option specific to the SX126x chip series which allows the chip to consume a small amount of additional power to increase RX (receive) sensitivity." override_frequency, "Override frequency in MHz", "Overrides frequency slot. May have legal ramifications." pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN" ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device." config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers." [config.bluetooth] title, "Bluetooth" enabled, "Enabled", "Enables bluetooth. Duh!" mode, "Pairing mode", "RANDOM_PIN generates a random PIN during runtime. FIXED_PIN uses the fixed PIN that should then be additionally specified. Finally, NO_PIN disables PIN authentication." fixed_pin, "Fixed PIN", "If your pairing mode is set to FIXED_PIN, the default value is 123456. For all other pairing modes, this number is ignored. A custom integer (6 digits) can be set via the Bluetooth config options." [config.security] title, "Security" public_key, "Public key", "The public key of the device, shared with other nodes on the mesh to allow them to compute a shared secret key for secure communication. Generated automatically to match private key.\n[warning]Don't change this if you don't know what you're doing.[/warning]" private_key, "Private key", "The private key of the device, used to create a shared key with a remote device for secure communication.\n[warning]This key should be kept confidential.[/warning]\n[note]Setting an invalid key will lead to unexpected behaviors.[/note]" is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" serial_enabled, "Enable serial console", "" debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active." admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes." admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3." [module.mqtt] title, "MQTT" enabled, "Enabled", "Enables the MQTT module." address, "Server address", "The server to use for MQTT. If not set, the default public server will be used." username, "Username", "MQTT Server username to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default public server, this will only be honored if set, otherwise the device will use the default username." password, "Password", "MQTT password to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default server, this will only be honored if set, otherwise the device will use the default password." encryption_enabled, "Encryption enabled", "Whether to send encrypted or unencrypted packets to the MQTT server. Unencrypted packets may be useful for external systems that want to consume meshtastic packets. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set." json_enabled, "JSON enabled", "Enable the sending / consumption of JSON packets on MQTT. These packets are not encrypted, but offer an easy way to integrate with systems that can read JSON. JSON is not supported on the nRF52 platform." tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connection using TLS." root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs." proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings." map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet." map_report_settings, "Map report settings", "Settings for the map report module." map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)." map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m." map_report_settings.should_report_location, "Should report location", "Whether we have opted-in to report our location to the map." [module.serial] title, "Serial" enabled, "Enabled", "Enables the module." echo, "Echo", "If set, any packets you send will be echoed back to your device." rxd, "Receive GPIO pin", "Set the GPIO pin to the RXD pin you have set up." txd, "Transmit GPIO pin", "Set the GPIO pin to the TXD pin you have set up." baud, "Baud rate", "The serial baud rate." timeout, "Timeout", "The amount of time to wait before we consider your packet as 'done'." mode, "Mode", "See Meshtastic docs for more information." override_console_serial_port, "Override console serial port", "If set to true, this will allow Serial Module to control (set baud rate) and use the primary USB serial bus for output. This is only useful for NMEA and CalTopo modes and may behave strangely or not work at all in other modes. Setting TX/RX pins in the Serial Module config will cause this setting to be ignored." [module.external_notification] title, "External Notification" enabled, "Enabled", "Enables the module." output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause." output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI." output_vibra, "Vibra GPIO", "Optional: Define a secondary output pin for a vibra motor. This is used in standalone devices to match the UI." output_buzzer, "Buzzer GPIO", "Optional: Define a tertiary output pin for an active buzze. This is used in standalone devices to to match the UI." active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low." alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message." alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message." alert_message_buzzer, "Alert buzzer on message", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character." alert_bell, "Alert when receiving a bell (general)", "Specifies if an alert should be triggered when receiving an incoming bell with an alert bell character." alert_bell_vibra, "Alert vibration on bell", "Specifies if a vibration alert should be triggered when receiving an incoming message with an alert bell character." alert_bell_buzzer, "Alert buzzer on bell", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character." use_pwm, "Use PWM for buzzer", "" nag_timeout, "Repeat (nag timeout)", "Specifies if the alert should be repeated. If set to a value greater than zero, the alert will be repeated until the user button is pressed or 'value' number of seconds have past." use_i2s_as_buzzer, "Use i2s as buzzer", "" [module.store_forward] title, "Store & Forward" enabled, "Enabled", "Enables the module." heartbeat, "Heartbeat", "The Store & Forward Server sends a periodic message onto the network. This allows connected devices to know that a server is in range and listening to received messages. A client like Android, iOS, or Web can (if supported) indicate to the user whether a Store & Forward Server is available." records, "Records", "Set this to the maximum number of records the server will save. Best to leave this at the default (0) where the module will use 2/3 of your device's available PSRAM. This is about 11,000 records." history_return_max, "History return max", "Sets the maximum number of messages to return to a client device when it requests the history." history_return_window, "History return window", "Limits the time period (in minutes) a client device can request." is_server, "Is server", "Set to true to configure your node with PSRAM as a Store & Forward Server for storing and forwarding messages. This is an alternative to setting the node as a ROUTER and only available since 2.4." [module.range_test] title, "Range Test" enabled, "Enabled", "Enables the module." sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages." save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]" [module.telemetry] title, "Telemetry" device_update_interval, "Device metrics update interval", "How often we should send Device Metrics over the mesh in seconds." environment_update_interval, "Environment metrics update interval", "How often we should send environment (sensor) Metrics over the mesh in seconds." environment_measurement_enabled, "Environment telemetry enabled", "Enable the Environment Telemetry (Sensors)." environment_screen_enabled, "Environment screen enabled", "Show the environment telemetry data on the device display." environment_display_fahrenheit, "Display fahrenheit", "The sensor is always read in Celsius, but the user can opt to display in Fahrenheit (on the device display only) using this setting." air_quality_enabled, "Air quality enabled", "This option is used to enable/disable the sending of air quality metrics from an attached supported sensor over the mesh network." air_quality_interval, "Air quality interval", "This option is used to configure the interval in seconds that should be used to send air quality metrics from an attached supported sensor over the mesh network in seconds." power_measurement_enabled, "Power metrics enabled", "This option is used to enable/disable the sending of power telemetry as gathered by an attached supported voltage/current sensor. Note that this does not need to be enabled to monitor the voltage of the battery." power_update_interval, "Power metrics interval", "This option is used to configure the interval in seconds that should be used to send power metrics from an attached supported sensor over the mesh network in seconds." power_screen_enabled, "Power screen enabled", "Show the power telemetry data on the device display." health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds." health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network." health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display." device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry" [module.canned_message] title, "Canned Message" rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder." inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A." inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B." inputbroker_pin_press, "Input broker pin press", "GPIO Pin Value (1-39) For encoder Press port." inputbroker_event_cw, "Input broker event clockwise", "Generate the rotary clockwise event." inputbroker_event_ccw, "Input broker event counter clockwise", "Generate the rotary counter clockwise event." inputbroker_event_press, "Input broker event press", "Generate input event on Press of this kind." updown1_enabled, "Up down encoder enabled", "Enable the up / down encoder." enabled, "Enabled", "Enables the module." allow_input_source, "Input source", "Input event sources accepted by the canned message module." send_bell, "Send bell", "Sends a bell character with each message." [module.audio] title, "Audio" codec2_enabled, "Enabled", "Enables the module." ptt_pin, "PTT GPIO", "The GPIO to use for the Push-To-Talk button. The default is GPIO 39 on the ESP32." bitrate, "Audio bitrate/codec mode", "The bitrate to use for audio." i2s_ws, "I2S word select", "The GPIO to use for the WS signal in the I2S interface." i2s_sd, "I2S data IN", "The GPIO to use for the SD signal in the I2S interface." i2s_din, "I2S data OUT", "The GPIO to use for the DIN signal in the I2S interface." i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface." [module.remote_hardware] title, "Remote Hardware" enabled, "Enabled", "Enables the module." allow_undefined_pin_access, "Allow undefined pin access", "Whether the Module allows consumers to read / write to pins not defined in available_pins" available_pins, "Available pins", "Exposes the available pins to the mesh for reading and writing." [module.neighbor_info] title, "Neighbor Info" enabled, "Enabled", "Enables the module." update_interval, "Update interval", "How often in seconds the neighbor info is sent to the mesh. This cannot be set lower than 4 hours (14400 seconds). The default is 6 hours (21600 seconds)." transmit_over_lora, "Transmit over LoRa", "Available from firmware 2.5.13 and higher. By default, neighbor info will only be sent to MQTT and a connected app. If enabled, the neighbor info will be sent on the primary channel over LoRa. Only available when the primary channel is not the public channel with default key and name." [module.ambient_lighting] title, "Ambient Lighting" led_state, "LED state", "Sets the LED to on or Off." current, "Current", "Sets the current for the LED output. Default is 10." red, "Red", "Sets the red LED level. Values are 0-255." green, "Green", "Sets the green LED level. Values are 0-255." blue, "Blue", "Sets the blue LED level. Values are 0-255." [module.detection_sensor] title, "Detection Sensor" enabled, "Enabled", "Enables the module." minimum_broadcast_secs, "Minimum broadcast interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected." state_broadcast_secs, "State broadcast interval", "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes, When set to 0, only state changes will be broadcasted, Works as a sort of status heartbeat for peace of mind." send_bell, "Send bell", "Send ASCII bell with alert message. Useful for triggering ext. notification on bell name." name, "Friendly name", "Used to format the message sent to mesh. Example: A name 'Motion' would result in a message 'Motion detected'. Maximum length of 20 characters." monitor_pin, "Monitor pin", "The GPIO pin to monitor for state changes." detection_trigger_type, "Detection triggered high", "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)." use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin." [module.paxcounter] title, "Paxcounter" enabled, "Enabled", "Enables the module." paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected." Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80" ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80" ================================================ FILE: contact/localisations/fr.ini ================================================ ##field_name, "Nom du champ lisible avec première lettre en majuscule", "Texte d'aide avec [warning]avertissements[/warning], [note]notes[/note], [underline]soulignements[/underline], \033[31mcodes couleur ANSI\033[0m et \nsauts de ligne." Main Menu, "Menu principal", "" User Settings, "Paramètres utilisateur", "" Channels, "Canaux", "" Radio Settings, "Paramètres radio", "" Module Settings, "Paramètres des modules", "" App Settings, "Paramètres de l'application", "" Export Config File, "Exporter le fichier de configuration", "" Load Config File, "Charger le fichier de configuration", "" Config URL, "URL de configuration", "" Reboot, "Redémarrer", "" Reset Node DB, "Réinitialiser la base de données des nœuds", "" Shutdown, "Éteindre", "" Factory Reset, "Réinitialisation d'usine", "" factory_reset_config, "Réinitialiser la configuration d'usine", "" Exit, "Quitter", "" Yes, "Oui", "" No, "Non", "" Cancel, "Annuler", "" [ui] save_changes, "Enregistrer les modifications", "" dialog.invalid_input, "Entrée invalide", "" prompt.enter_new_value, "Entrer une nouvelle valeur : ", "" error.value_empty, "La valeur ne peut pas être vide.", "" error.value_exact_length, "La valeur doit comporter exactement {length} caractères.", "" error.value_min_length, "La valeur doit comporter au moins {length} caractères.", "" error.value_max_length, "La valeur ne doit pas dépasser {length} caractères.", "" error.digits_only, "Seuls les chiffres (0-9) sont autorisés.", "" error.number_range, "Entrez un nombre entre {min_value} et {max_value}.", "" error.float_invalid, "Doit être un nombre à virgule flottante valide.", "" prompt.edit_admin_keys, "Modifier jusqu'à 3 clés administrateur :", "" label.admin_key, "Clé administrateur", "" error.admin_key_invalid, "Erreur : chaque clé doit être en Base64 valide et faire 32 octets !", "" prompt.edit_values, "Modifier jusqu'à 3 valeurs :", "" label.value, "Valeur", "" prompt.enter_ip, "Entrez une adresse IP (xxx.xxx.xxx.xxx) :", "" label.current, "Actuel", "" label.new_value, "Nouvelle valeur", "" label.editing, "Modification de {label}", "" label.current_value, "Valeur actuelle :", "" error.ip_invalid, "Adresse IP invalide. Réessayez.", "" prompt.select_foreground_color, "Sélectionner la couleur de premier plan pour {label}", "" prompt.select_background_color, "Sélectionner la couleur d'arrière-plan pour {label}", "" prompt.select_value, "Sélectionner {label}", "" confirm.save_before_exit, "Vous avez des modifications non enregistrées. Enregistrer avant de quitter ?", "" prompt.config_filename, "Entrez un nom de fichier pour le fichier de configuration", "" confirm.overwrite_file, "{filename} existe déjà. Écraser ?", "" dialog.config_saved_title, "Fichier de configuration enregistré :", "" dialog.no_config_files, " Aucun fichier de configuration trouvé. Exportez-en un d'abord.", "" prompt.choose_config_file, "Choisissez un fichier de configuration", "" confirm.load_config_file, "Êtes-vous sûr de vouloir charger {filename} ?", "" prompt.config_url_current, "L'URL de configuration est actuellement : {value}", "" confirm.load_config_url, "Êtes-vous sûr de vouloir charger cette configuration ?", "" confirm.reboot, "Êtes-vous sûr de vouloir redémarrer ?", "" confirm.reset_node_db, "Êtes-vous sûr de vouloir réinitialiser la base de données des nœuds ?", "" confirm.shutdown, "Êtes-vous sûr de vouloir éteindre ?", "" confirm.factory_reset, "Êtes-vous sûr de vouloir effectuer une réinitialisation d'usine ?", "" confirm.factory_reset_config, "Êtes-vous sûr de vouloir réinitialiser la configuration d'usine ?", "" confirm.save_before_exit_section, "Vous avez des modifications non enregistrées dans {section}. Enregistrer avant de quitter ?", "" prompt.select_region, "Sélectionnez votre région :", "" dialog.slow_down_title, "Ralentissez", "" dialog.slow_down_body, "Veuillez attendre 2 secondes entre les messages.", "" dialog.node_details_title, "📡 Détails du nœud : {name}", "" dialog.traceroute_not_sent_title, "Traceroute non envoyé", "" dialog.traceroute_not_sent_body, "Veuillez attendre {seconds} secondes avant d'envoyer un autre traceroute.", "" dialog.traceroute_sent_title, "Traceroute envoyé à : {name}", "" dialog.traceroute_sent_body, "Les résultats apparaîtront dans la fenêtre des messages.", "" dialog.help_title, "Aide - Raccourcis clavier", "" help.scroll, "Haut/Bas = Défilement", "" help.switch_window, "Gauche/Droite = Changer de fenêtre", "" help.jump_windows, "F1/F2/F3 = Aller à Canal/Messages/Nœuds", "" help.enter, "ENTRÉE = Envoyer / Sélectionner", "" help.settings, "` ou F12 = Paramètres", "" help.quit, "ESC = Quitter", "" help.packet_log, "Ctrl+P = Activer/désactiver le journal des paquets", "" help.traceroute, "Ctrl+T ou F4 = Traceroute", "" help.node_info, "F5 = Informations complètes du nœud", "" help.archive_chat, "Ctrl+D = Archiver la discussion / supprimer le nœud", "" help.favorite, "Ctrl+F = Favori", "" help.bot_responder, "Ctrl+B = Activer/désactiver le bot répondeur", "" help.ignore, "Ctrl+G = Ignorer", "" help.search, "Ctrl+/ ou / = Rechercher", "" help.help, "Ctrl+K = Aide", "" help.no_help, "Aucune aide disponible.", "" bot.status.enabled, "Activé", "" bot.status.disabled, "Désactivé", "" bot.dialog.title, "Bot répondeur", "" bot.dialog.body, "Le bot répondeur est maintenant {status}.", "" bot.status.message, "Le bot répondeur est maintenant {status}.", "" [User Settings] user, "Utilisateur", "" longName, "Nom long du nœud", "Si vous êtes un opérateur radioamateur agréé et avez activé le mode HAM, cela doit être votre indicatif." shortName, "Nom court du nœud", "Doit contenir jusqu'à 4 octets." isLicensed, "Activer le mode radioamateur (HAM)", "IMPORTANT : lire la documentation Meshtastic avant d'activer." [app_settings] title, "Paramètres de l'application", "" channel_list_16ths, "Largeur de la liste des canaux", "" node_list_16ths, "Largeur de la liste des nœuds", "" single_pane_mode, "Mode panneau unique", "" db_file_path, "Chemin du fichier de base de données", "" log_file_path, "Chemin du fichier journal", "" node_configs_file_path, "Chemin des configurations des nœuds", "" language, "Langue", "" message_prefix, "Préfixe des messages", "" sent_message_prefix, "Préfixe des messages envoyés", "" notification_symbol, "Symbole de notification", "" notification_sound, "Son de notification", "" ack_implicit_str, "ACK (implicite)", "" ack_str, "ACK", "" nak_str, "NAK", "" ack_unknown_str, "ACK (inconnu)", "" node_sort, "Tri des nœuds", "" theme, "Thème", "" ping_bot, "Bot Ping", "" [app_settings.ping_bot] title, "Bot Ping", "" catch_words, "Mots déclencheurs", "Mots déclencheurs du bot séparés par des points-virgules." response_word, "Mot de réponse", "Mot de réponse du bot." [config.device] title, "Appareil", "" role, "Rôle", "" serial_enabled, "Activer la console série", "" button_gpio, "GPIO bouton", "" buzzer_gpio, "GPIO buzzer", "" rebroadcast_mode, "Mode de rediffusion", "" node_info_broadcast_secs, "Intervalle diffusion infos nœud", "" double_tap_as_button_press, "Double tap = bouton", "" is_managed, "Activer mode géré", "" disable_triple_click, "Désactiver triple clic", "" tzdef, "Fuseau horaire", "" led_heartbeat_disabled, "Désactiver LED heartbeat", "" buzzer_mode, "Mode buzzer", "" [config.network] title, "Réseau", "" wifi_enabled, "Wi-Fi activé", "" wifi_ssid, "SSID Wi-Fi", "" wifi_psk, "Mot de passe Wi-Fi", "" ntp_server, "Serveur NTP", "" eth_enabled, "Ethernet activé", "" address_mode, "Mode IPv4", "" ip, "Adresse IP", "" gateway, "Passerelle", "" subnet, "Sous-réseau", "" dns, "DNS", "" [config.display] title, "Affichage", "" screen_on_secs, "Durée écran actif", "" gps_format, "Format GPS", "" auto_screen_carousel_secs, "Intervalle carrousel", "" compass_north_top, "Toujours nord en haut", "" flip_screen, "Retourner écran", "" units, "Unités préférées", "" [config.bluetooth] title, "Bluetooth", "" enabled, "Activé", "" mode, "Mode d'appairage", "" fixed_pin, "Code PIN fixe", "" [module.mqtt] title, "MQTT", "" enabled, "Activé", "" address, "Adresse serveur", "" username, "Nom d'utilisateur", "" password, "Mot de passe", "" encryption_enabled, "Chiffrement activé", "" json_enabled, "JSON activé", "" tls_enabled, "TLS activé", "" [module.serial] title, "Série", "" enabled, "Activé", "" echo, "Écho", "" rxd, "GPIO réception", "" txd, "GPIO transmission", "" baud, "Débit en bauds", "" timeout, "Délai", "" [module.telemetry] title, "Télémétrie", "" device_update_interval, "Intervalle métriques appareil", "" environment_update_interval, "Intervalle métriques environnement", "" environment_measurement_enabled, "Télémétrie environnement activée", "" [module.audio] title, "Audio", "" codec2_enabled, "Activé", "" ptt_pin, "GPIO PTT", "" bitrate, "Débit audio", "" [module.remote_hardware] title, "Matériel distant", "" enabled, "Activé", "" available_pins, "Broches disponibles", "" [module.ambient_lighting] title, "Éclairage ambiant", "" led_state, "État LED", "" current, "Courant", "" red, "Rouge", "" green, "Vert", "" blue, "Bleu", "" ================================================ FILE: contact/localisations/ru.ini ================================================ #field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks." Main Menu, "Главное меню", "" User Settings, "Настройки пользователя", "" Channels, "Каналы", "" Radio Settings, "Настройки радио", "" Module Settings, "Настройки модулей", "" App Settings, "Настройки приложения", "" Export Config File, "Экспорт конфигурации", "" Load Config File, "Загрузить конфигурацию", "" Config URL, "URL конфигурации", "" Reboot, "Перезагрузить", "" Reset Node DB, "Сбросить БД узлов", "" Shutdown, "Выключить", "" Factory Reset, "Сброс до заводских", "" factory_reset_config, "Сбросить только конфигурацию", "" Exit, "Выход", "" Yes, "Да", "" No, "Нет", "" Cancel, "Отмена", "" [ui] save_changes, "Сохранить изменения", "" dialog.invalid_input, "Некорректный ввод", "" prompt.enter_new_value, "Введите новое значение: ", "" error.value_empty, "Значение не может быть пустым.", "" error.value_exact_length, "Значение должно быть длиной ровно {length} символов.", "" error.value_min_length, "Значение должно быть не короче {length} символов.", "" error.value_max_length, "Значение должно быть не длиннее {length} символов.", "" error.digits_only, "Разрешены только цифры (0-9).", "" error.number_range, "Введите число между {min_value} и {max_value}.", "" error.float_invalid, "Введите корректное число с плавающей точкой.", "" prompt.edit_admin_keys, "Редактировать до 3 ключей администратора:", "" label.admin_key, "Ключ администратора", "" error.admin_key_invalid, "Ошибка: каждый ключ должен быть Base64 и длиной 32 байта.", "" prompt.edit_values, "Редактировать до 3 значений:", "" label.value, "Значение", "" prompt.enter_ip, "Введите IP-адрес (xxx.xxx.xxx.xxx):", "" label.current, "Текущее", "" label.new_value, "Новое значение", "" label.editing, "Редактирование {label}", "" label.current_value, "Текущее значение:", "" error.ip_invalid, "Неверный IP-адрес. Попробуйте еще раз.", "" prompt.select_foreground_color, "Выберите цвет текста для {label}", "" prompt.select_background_color, "Выберите цвет фона для {label}", "" prompt.select_value, "Выберите {label}", "" confirm.save_before_exit, "Есть несохраненные изменения. Сохранить перед выходом?", "" prompt.config_filename, "Введите имя файла конфигурации", "" confirm.overwrite_file, "Файл {filename} уже существует. Перезаписать?", "" dialog.config_saved_title, "Файл конфигурации сохранен:", "" dialog.no_config_files, " Нет файлов конфигурации. Сначала экспортируйте конфигурацию.", "" prompt.choose_config_file, "Выберите файл конфигурации", "" confirm.load_config_file, "Загрузить файл {filename}?", "" prompt.config_url_current, "Текущий URL конфигурации: {value}", "" confirm.load_config_url, "Загрузить эту конфигурацию?", "" confirm.reboot, "Перезагрузить устройство?", "" confirm.reset_node_db, "Сбросить БД узлов?", "" confirm.shutdown, "Выключить устройство?", "" confirm.factory_reset, "Сбросить до заводских настроек?", "" confirm.factory_reset_config, "Сбросить только конфигурацию?", "" confirm.save_before_exit_section, "Есть несохраненные изменения в {section}. Сохранить перед выходом?", "" prompt.select_region, "Выберите ваш регион:", "" dialog.slow_down_title, "Подождите", "" dialog.slow_down_body, "Подождите 2 секунды между сообщениями.", "" dialog.node_details_title, "📡 Информация об узле: {name}", "" dialog.traceroute_not_sent_title, "Traceroute не отправлен", "" dialog.traceroute_not_sent_body, "Подождите {seconds} секунд перед повторной отправкой traceroute.", "" dialog.traceroute_sent_title, "Traceroute отправлен: {name}", "" dialog.traceroute_sent_body, "Результаты появятся в окне сообщений.", "" dialog.help_title, "Справка - горячие клавиши", "" help.scroll, "Вверх/Вниз = Прокрутка", "" help.switch_window, "Влево/Вправо = Переключить окно", "" help.jump_windows, "F1/F2/F3 = Каналы/Сообщения/Узлы", "" help.enter, "ENTER = Отправить / Выбрать", "" help.settings, "` или F12 = Настройки", "" help.quit, "ESC = Выход", "" help.packet_log, "Ctrl+P = Журнал пакетов", "" help.traceroute, "Ctrl+T или F4 = Traceroute", "" help.node_info, "F5 = Полная информация об узле", "" help.archive_chat, "Ctrl+D = Архив чата / удалить узел", "" help.favorite, "Ctrl+F = Избранное", "" help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", "" help.ignore, "Ctrl+G = Игнорировать", "" help.search, "Ctrl+/ или / = Поиск", "" help.help, "Ctrl+K = Справка", "" help.no_help, "Нет справки.", "" confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", "" confirm.set_favorite, "Добавить {name} в избранное?", "" confirm.remove_favorite, "Удалить {name} из избранного?", "" confirm.set_ignored, "Игнорировать {name}?", "" confirm.remove_ignored, "Убрать {name} из игнорируемых?", "" confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", "" dialog.resize_title, "Увеличьте окно", "" dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", "" bot.status.enabled, "Включен", "" bot.status.disabled, "Выключен", "" bot.dialog.title, "Автоответчик", "" bot.dialog.body, "Автоответчик теперь {status}.", "" bot.status.message, "Автоответчик теперь {status}.", "" [User Settings] user, "Пользователь" longName, "Полное имя ноды", "Если вы являетесь лицензированным оператором HAM и включили режим HAM, этот режим должен быть установлен в качестве позывного вашего оператора HAM." shortName, "Краткое имя ноды", "Должно быть не более 4 байт. Обычно это 4 символа, если используются латинские символы и без эмодзи." isLicensed, "Включите лицензионный любительский режим (HAM)", "ВАЖНО: перед включением ознакомьтесь со справочной документацией Meshtastic." [app_settings] title, "Настройки приложения", "" channel_list_16ths, "Ширина списка каналов", "Ширина списка каналов в шестнадцатых долях экрана." node_list_16ths, "Ширина списка нод", "Ширина списка нод в шестнадцатых долях экрана." single_pane_mode, "Однопанельный режим", "Показывать интерфейс в одной панели." db_file_path, "Путь к базе данных", "" log_file_path, "Путь к файлу журнала", "" node_configs_file_path, "Путь к конфигурациям нод", "" language, "Язык", "Язык интерфейса для подписей и справки." message_prefix, "Префикс сообщений", "" sent_message_prefix, "Префикс отправленных", "" notification_symbol, "Символ уведомления", "" notification_sound, "Звук уведомления", "" ack_implicit_str, "ACK (неявный)", "" ack_str, "ACK", "" nak_str, "NAK", "" ack_unknown_str, "ACK (неизвестный)", "" node_sort, "Сортировка нод", "" theme, "Тема", "" ping_bot, "Пинг-бот", "" COLOR_CONFIG_DARK, "Цвета темы (темная)", "" COLOR_CONFIG_LIGHT, "Цвета темы (светлая)", "" COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", "" [app_settings.ping_bot] title, "Пинг-бот", "" catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой." response_word, "Ответное слово", "Ответное слово бота." [app_settings.color_config] default, "По умолчанию", "" background, "Фон", "" splash_logo, "Логотип заставки", "" splash_text, "Текст заставки", "" input, "Ввод", "" node_list, "Список нод", "" channel_list, "Список каналов", "" channel_selected, "Выбранный канал", "" rx_messages, "Входящие сообщения", "" tx_messages, "Отправленные сообщения", "" timestamps, "Временные метки", "" commands, "Команды", "" window_frame, "Рамка окна", "" window_frame_selected, "Выбранная рамка окна", "" log_header, "Заголовок лога", "" log, "Лог", "" settings_default, "Настройки по умолчанию", "" settings_sensitive, "Чувствительные настройки", "" settings_save, "Сохранение настроек", "" settings_breadcrumbs, "Хлебные крошки", "" settings_warning, "Предупреждения настроек", "" settings_note, "Примечания настроек", "" node_favorite, "Избранная нода", "" node_ignored, "Игнорируемая нода", "" [Channels.channel] title, "Каналы" channel_num, "Номер канала", "Номер индекса этого канала." psk, "PSK", "Ключи шифрования каналов." name, "Name", "Имена каналов." id, "", "" uplink_enabled, "Восходящая линия вклюена", "Пусть данные этого канала отправляются на сервер MQTT, настроенный на этом узле." downlink_enabled, "Входящая линия включена", "Пусть данные с сервера MQTT, настроенного на этом узле, отправляются на этот канал." module_settings, "Настройки модуля", "Точность позиционирования и отключение звука клиента." module_settings.position_precision, "Точность позиционирования", "Уровень точности данных о местоположении, передаваемых по этому каналу." module_settings.is_client_muted, "Приглушен ли клиент", "Определяет, должен ли телефон/клиенты приглушать звук текущего канала. Полезно для общих каналов с шумом, которые вы не хотите отключать." [config.device] title, "Устройство" role, "Роль", "Для подавляющего большинства пользователей правильным выбором является клиент. Дополнительную информацию смотрите в документации Meshtastic." serial_enabled, "Включить последовательную консоль", "Последовательная консоль через Stream API." button_gpio, "Кнопка GPIO", "Пин-код GPIO для пользовательской кнопки." buzzer_gpio, "Зуммер GPIO", "Пин-код GPIO для пользовательского зуммера." rebroadcast_mode, "Режим ретрансляции", "Этот параметр определяет поведение устройства при ретрансляции сообщений." node_info_broadcast_secs, "Интервал широковещательной передачи Nodeinfo", "Это количество секунд между передачами сообщения NodeInfo. Также будет отправлено сообщение nodeinfo в ответ на появление новых узлов в сети." double_tap_as_button_press, "Двойной тап как нажатие кнопки", "Эта опция позволяет использовать двойной тап, когда к устройству подключен поддерживаемый акселерометр, как нажатие кнопки." is_managed, "Включить управляемый режим", "Включение управляемого режима блокирует изменение конфигурации приложений для смартфонов и веб-интерфейса. [note]Этот параметр не требуется для удаленного администрирования узла.[/note] Перед включением убедитесь, что узлом можно управлять с помощью удаленного администратора, чтобы [warning]предотвратить его блокировку.[/warning]" disable_triple_click, "Отключить тройное нажатие кнопки", "" tzdef, "Часовой пояс", "Использует формат базы данных ЧП для отображения правильного местного времени на дисплее устройства и в его журналах." led_heartbeat_disabled, "Отключить LED пульс", "На некоторых моделях оборудования это отключает мигающий индикатор пульса." buzzer_mode, "Режим зуммера", "Управляет поведением зуммера для получения звуковой обратной связи." [config.position] title, "Позиционирование" position_broadcast_secs, "Интервал широковещательной передачи местоположения", "Если умная трансляция отключена - мы должны сообщать о своем местоположении так часто." position_broadcast_smart_enabled, "Включена умная трансляция местоположения", "Умная трансляция будет передавать информацию о вашем местоположении с увеличенной частотой только в том случае, если оно изменилось настолько, что его обновление будет полезным." fixed_position, "Фиксированное местоположение", "Если этот параметр установлен - используется фиксированное положение. Устройство будет генерировать обновления GPS, но использовать последние значения широты/долготы/высоты, сохраненные для ноды. Положение может быть задано с помощью встроенного GPS или GPS смартфона." latitude, "Широта", "" longitude, "Долгота", "" altitude, "Высота", "" gps_enabled, "GPS включен", "" gps_update_interval, "Интервал обновления GPS", "Как часто мы должны пытаться определить местоположение по GPS (в секундах), или нулевое значение по умолчанию - раз в 2 минуты, или очень большое значение (maxint) для обновления только один раз при загрузке." gps_attempt_time, "Время попытки GPS", "" position_flags, "Флаги позиционирования", "Смотрите документацию Meshtastic для подробностей." rx_gpio, "GPS RX GPIO pin", "Если на вашем устройстве нет встроенного GPS-чипа, то можете определить контакты GPIO для RX-контакта GPS-модуля." tx_gpio, "GPS TX GPIO pin", "Если на вашем устройстве нет встроенного GPS-чипа, то можете определить контакты GPIO для TX-контакта GPS-модуля." broadcast_smart_minimum_distance, "Минимальное расстояние умного позиционирования по GPS", "Минимальное пройденное расстояние в метрах (с момента последней отправки), прежде чем мы сможем отправить местоположение в сеть, если включена умная трансляция." broadcast_smart_minimum_interval_secs, "Минимальный интервал умного позиционирования по GPS", "Минимальное количество секунд (с момента последней отправки), прежде чем мы сможем отправить позицию в сеть, если включена умная трансляция." gps_en_gpio, "GPIO включения GPS", "" gps_mode, "Режим GPS", "Определяет, включена ли функция GPS, отключена или отсутствует на узле." [config.power] title, "Мощность" is_power_saving, "Включить режим энергосбережения", "Автоматическое выключение устройства по истечении этого времени в случае отключения питания." on_battery_shutdown_after_secs, "Интервал отключения батареи", "" adc_multiplier_override, "Переопределение множителя АЦП", "Коэффициент делителя напряжения для вывода батареи. Переопределяет значение ADC_MULTIPLIER, определенное в файле вариантов встроенного устройства, для расчета напряжения батареи. Дополнительную информацию смотрите в документации Meshtastic." wait_bluetooth_secs, "Bluetooth", "Как долго нужно ждать, прежде чем выключать BLE, если устройство Bluetooth не подключено." sds_secs, "Интервал сверхглубокого сна", "Находясь в режиме легкого сна, если значение mesh_sds_timeout_secs превышено, мы перейдем в режим сверхглубокого сна на это значение или нажмем кнопку. 0 по умолчанию - один год." ls_secs, "Интервал легкого сна", "Только ESP32. В режиме легкого сна процессор приостанавливает работу, передатчик LoRa включен, BLE выключен и GPS включен." min_wake_secs, "Минимальный интервал пробуждения", "Находясь в состоянии легкого сна, когда мы получаем пакеты по LoRa, мы просыпаемся, обрабатываем их и остаемся бодрствовать в режиме без Bluetooth в течение этого интервала в секундах." device_battery_ina_address, "Батарея устройства по адресу INA2xx", "Если устройство INA-2XX автоматически обнаруживается на одной из шин I2C по указанному адресу, оно будет использоваться в качестве надежного источника для считывания уровня заряда батареи устройства. Для устройств с PMU (например, T-beams) настройка игнорируется" powermon_enables, "Включение монитора мощности", "Если значение не равно нулю - нам нужны выходные данные журнала powermon. С включенными конкретными источниками (битовое поле)." [config.network] title, "Сеть" wifi_enabled, "Wi-Fi включен", "Включает или отключает Wi-Fi." wifi_ssid, "Wi-Fi SSID", "SSID вашей Wi-Fi сети." wifi_psk, "Wi-Fi PSK", "Пароль вашей Wi-Fi сети." ntp_server, "NTP-сервер", "Сервер времени, используемый при наличии IP-сети." eth_enabled, "Ethernet включен", "Включает или отключает Ethernet на некоторых моделях оборудования." address_mode, "Сетевой режим IPv4", "По умолчанию установлен DHCP. Измените значение на STATIC для использования статического IP-адреса. Применяется как к Ethernet, так и к Wi-Fi." ipv4_config, "Настройка IPv4", "Расширенные настройки сети" ip, "Статический адрес IPv4", "" gateway, "IPv4 шлюз", "" subnet, "IPv4 подсеть", "" dns, "IPv4 DNS-сервер", "" rsyslog_server, "RSyslog сервер", "" enabled_protocols, "Включенные протоколы", "" ipv6_enabled, "Включить IPv6", "Включает или отключает подключение к сети IPv6." [config.network.ipv4_config] title, "Конфигурация IPv4", "" ip, "IP", "" gateway, "Шлюз", "" subnet, "Подсеть", "" dns, "DNS", "" [config.display] title, "Дисплей" screen_on_secs, "Длительность включения экрана", "Как долго экран остается включенным в секундах после нажатия пользовательской кнопки или получения сообщений." gps_format, "Формат GPS", "Формат, используемый для отображения GPS-координат на экране устройства." auto_screen_carousel_secs, "Интервал автокарусели", "Автоматическое переключение на следующую страницу на экране, как в карусели, в зависимости от заданного интервала в секундах." compass_north_top, "Всегда указывать на север", "Если этот параметр установлен, направление по компасу на экране всегда будет указывать на север. По умолчанию эта функция отключена, и в верхней части дисплея отображается направление вашего движения, индикатор Севера будет перемещаться по кругу." flip_screen, "Перевернуть экран", "Следует ли перевернуть экран по вертикали." units, "Предпочитаемые единицы измерения", "Выбор между метрической (по умолчанию) и британской системами измерений." oled, "Определение OLED", "Тип OLED-контроллера определяется автоматически по умолчанию, но может быть определен с помощью этого параметра, если автоматическое определение не удается. Для SH1107 мы предполагаем квадратный дисплей с разрешением 128x128 пикселей, как у GME128128-1." displaymode, "Режим дисплея", "DEFAULT, TWOCOLOR, INVERTED или COLOR. TWOCOLOR: предназначен для OLED-дисплеев с другой цветовой гаммой первой строки. INVERTED: инвертирует двухцветную область, в результате чего заголовок на монохромном дисплее будет отображаться на белом фоне." heading_bold, "Жирные заголовки", "Заголовок может быть трудно читаем, если используется INVERTED или TWOCOLOR режим отображения. При этой настройке заголовок будет выделен жирным шрифтом, что облегчит его чтение." wake_on_tap_or_motion, "Пробуждение при нажатии или движении", "Эта опция позволяет активировать экран устройства при обнаружении движения, например, прикосновения к устройству, с помощью подключенного акселерометра или емкостной сенсорной кнопки." compass_orientation, "Ориентация компаса", "Следует ли поворачивать компас." use_12h_clock, "Использовать 12-часовой формат часов" [config.device_ui] title, "UI устройства" version, "Версия", "" screen_brightness, "Яркость экрана", "" screen_timeout, "Тайм-аут подсветки", "" screen_lock, "Блокировка экрана", "" settings_lock, "Настройка блокировки", "" pin_code, "PIN-код", "" theme, "Тема", "" alert_enabled, "Оповещение включено", "" banner_enabled, "Баннер включен", "" ring_tone_id, "ID рингтона", "" language, "Язык", "" node_filter, "Фильтр нод", "" node_highlight, "Подсветка ноды", "" calibration_data, "Калибровочные данные", "" map_data, "Данные карты", "" [config.device_ui.node_filter] title, "Фильтр ноды" unknown_switch, "Неизвестный переключатель", "" offline_switch, "Offline Switch", "" public_key_switch, "Public Key Switch", "" hops_away, "Hops Away", "" position_switch, "Переключатель позиционирования", "" node_name, "Имя ноды", "" channel, "Канал", "" [config.device_ui.node_highlight] title, "Подстветка ноды" chat_switch, "Переключатель чата", "" position_switch, "Переключатель позицонирования", "" telemetry_switch, "Переключатель телеметрии", "" iaq_switch, "Переключатель IAQ", "" node_name, "Имя ноды", "" [config.device_ui.map_data] title, "Данные карты" home, "Домой", "" style, "Стиль", "" follow_gps, "Следовать GPS", "" [config.lora] title, "LoRa" use_preset, "Использовать предустановку модема", "Предустановки - это заранее определенные настройки модема (пропускная способность, коэффициент распространения и скорость кодирования), которые влияют как на скорость передачи сообщений, так и на дальность действия. Подавляющее большинство пользователей используют предустановки." modem_preset, "Предустановка", "Предустановка по умолчанию обеспечит оптимальное сочетание скорости и диапазона для большинства пользователей." bandwidth, "Пропускная способность", "Ширина частотного 'диапазона', используемого вокруг расчетной центральной частоты. Используется только в том случае, если предустановка модема отключена." spread_factor, "Коэффициент распространения", "Указывает количество chirps на символ. Используется только в том случае, если предустановка модема отключена." coding_rate, "Скорость кодирования", "Доля каждой передачи LoRa, содержащая фактические данные, - остальное используется для коррекции ошибок." frequency_offset, "Смещение частоты", "Этот параметр предназначен для опытных пользователей с современным испытательным оборудованием." region, "Регион", "Задает регион для вашей ноды. Если этот параметр не задан, нода будет отображать сообщение и не будет передавать никаких пакетов." hop_limit, "Лимит хопов", "Максимальное количество промежуточных узлов между нашей нодой и нодой, на которую отправляется пакет. Не влияет на принимаемые сообщения.\n[warning]Превышение лимита хопов увеличивает перегрузку![/warning]\n Должно быть в диапазоне от 0 до 7." tx_enabled, "Включить TX", "Включает/выключает радиочип. Полезно для 'горячей' замены антенн." tx_power, "Мощность TX в dBm", "[warning]Установка радиоприемника мощностью 33 дБ выше 8 дБ приведет к его необратимому повреждению. ERP выше 27 дБ нарушает законодательство ЕС. ERP выше 36 дБ нарушает законодательство США (нелицензионное).[/warning] Если значение равно 0, будет использоваться максимальная постоянная мощность, действующая в регионе. Должно быть 0-30 (0=автоматически)." channel_num, "Частотный слот", "Определяет точную частоту, которую радиостанция передает и принимает. Если параметр не задан или установлен на 0, он автоматически определяется по названию основного канала." override_duty_cycle, "Изменить рабочий цикл", "Отменитm установленное законом ограничение по времени передачи, чтобы разрешить неограниченное время передачи. [warning]Может иметь юридические последствия.[/warning]" sx126x_rx_boosted_gain, "Включить усиление SX126X RX", "Эта опция, характерная для чипов серии SX126x, позволяет чипу потреблять небольшое количество дополнительной энергии для повышения чувствительности приемника." override_frequency, "Переопределение частоты в MHz", "Переопределяет частотный диапазон. Может иметь юридические последствия." pa_fan_disabled, "Отключение PA Fan", "Если значение равно true, отключает встроенный PA FAN, используя pin-код, указанный в RF95_FAN_EN" ignore_mqtt, "Игнорировать MQTT", "Игнорировать все сообщения, получаемые через LoRa и которые пришли через MQTT где-то на пути к устройству." config_ok_to_mqtt, "OK для MQTT", "Указывает, что пользователь одобряет передачу своих пакетов брокеру MQTT." [config.bluetooth] title, "Bluetooth" enabled, "Включен", "Включает Bluetooth. Еще бы!" mode, "Режим сопряжения", "RANDOM_PIN генерирует случайный PIN-код во время выполнения. В FIXED_PIN используется фиксированный PIN-код, который затем должен быть указан дополнительно. Наконец, NO_PIN отключает аутентификацию с помощью PIN-кода." fixed_pin, "Фиксированный PIN", "Если для вашего режима сопряжения задано значение FIXED_PIN, значение по умолчанию 123456. Для всех других режимов сопряжения это число игнорируется. Пользовательское целое число (6 цифр) можно задать с помощью параметров настройки Bluetooth." [config.security] title, "Безопасность" public_key, "Открытый ключ", "Открытый ключ устройства, используемый совместно с другими узлами сети, чтобы они могли вычислить общий секретный ключ для безопасной связи. Генерируется автоматически в соответствии с закрытым ключом.\n[warning]Не меняйте его, если не знаете что делаете.[/warning]" private_key, "Закрытый ключ", "Закрытый ключ устройства, используемый для создания общего ключа с удаленным устройством для безопасной связи.\n[warning]Этот ключ должен храниться в тайне.[/warning]\n[note]Установка неверного ключа приведет к непредвиденным последствиям.[/note] is_managed, "Включить управляемый режим", "Включение управляемого режима блокирует изменение конфигурации приложений для смартфонов и веб-интерфейса. [note]Этот параметр не требуется для удаленного администрирования узла.[/note] Перед включением убедитесь, что узлом можно управлять с помощью удаленного администрирования, чтобы [warning]предотвратить его блокировку.[/warning]" serial_enabled, "Включить последовательную консоль", "" debug_log_api_enabled, "Включить лог дебага", "Установите для этого параметра значение true, чтобы продолжить вывод журналов отладки в реальном времени по последовательному каналу или Bluetooth, когда API активен." admin_channel_enabled, "Включить устаревший канал админа", "Если узел, который вы хотите администрировать или которым вы будете управлять, работает под управлением 2.4.x или более ранней версии, вам следует установить для этого значения включено. Требуется, чтобы на обоих узлах присутствовал дополнительный канал с именем 'admin'." admin_key, "Админский ключ", "Открытый ключ(и), разрешающий администрирование этого узла. Только сообщения, подписанные этими ключами, будут приниматься для администрирования. Не более 3." [module.mqtt] title, "MQTT" enabled, "Включен", "Включает модуль MQTT." address, "Адрес сервера", "The server to use for MQTT. If not set, the default public server will be used." username, "Имя пользователя", "MQTT Server username to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default public server, this will only be honored if set, otherwise the device will use the default username." password, "Пароль", "MQTT password to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default server, this will only be honored if set, otherwise the device will use the default password." encryption_enabled, "Encryption enabled", "Whether to send encrypted or unencrypted packets to the MQTT server. Unencrypted packets may be useful for external systems that want to consume meshtastic packets. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set." json_enabled, "JSON включен", "Enable the sending / consumption of JSON packets on MQTT. These packets are not encrypted, but offer an easy way to integrate with systems that can read JSON. JSON is not supported on the nRF52 platform." tls_enabled, "TLS включен", "If true, we attempt to establish a secure connection using TLS." root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs." proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings." map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet." map_report_settings, "Map report settings", "Settings for the map report module." map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)." map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m." map_report_settings.should_report_location, "Should report location", "Whether we have opted-in to report our location to the map." [module.serial] title, "Serial" enabled, "Включен", "Включает модуль." echo, "Эхо", "Если установлено - все отправляемые вами пакеты будут отправляться обратно на ваше устройство." rxd, "Получение пина GPIO", "Установите pin-код GPIO на заданный вами RXD-код." txd, "Передача пина GPIO", "Установите pin-код GPIO на заданный вами TXD-код." baud, "Скорость передачи в бодах", "Последовательная скорость передачи данных в бодах." timeout, "Тайм-аут", "Количество времени, которое необходимо подождать, прежде чем мы сочтем ваш пакет отправленным." mode, "Режим", "Смотрите документацию Meshtastic для получения дополнительной информации." override_console_serial_port, "Переопределение последовательного порта консоли", "Если установлено true, это позволит последовательному модулю управлять (устанавливать скорость передачи данных в бодах) и использовать основную последовательную шину USB для вывода данных. Это полезно только для режимов NMEA и CalTopo и может вести себя странно или вообще не работать в других режимах. Установка контактов TX/RX в конфигурации последовательного модуля приведет к игнорированию этой настройки." [module.external_notification] title, "Внешния уведомления" enabled, "Включен", "Включает модуль." output_ms, "Длина", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause." output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI." output_vibra, "Vibra GPIO", "Optional: Define a secondary output pin for a vibra motor. This is used in standalone devices to match the UI." output_buzzer, "Buzzer GPIO", "Optional: Define a tertiary output pin for an active buzze. This is used in standalone devices to to match the UI." active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low." alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message." alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message." alert_message_buzzer, "Alert buzzer on message", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character." alert_bell, "Alert when receiving a bell (general)", "Specifies if an alert should be triggered when receiving an incoming bell with an alert bell character." alert_bell_vibra, "Alert vibration on bell", "Specifies if a vibration alert should be triggered when receiving an incoming message with an alert bell character." alert_bell_buzzer, "Alert buzzer on bell", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character." use_pwm, "Use PWM for buzzer", "" nag_timeout, "Repeat (nag timeout)", "Specifies if the alert should be repeated. If set to a value greater than zero, the alert will be repeated until the user button is pressed or 'value' number of seconds have past." use_i2s_as_buzzer, "Use i2s as buzzer", "" [module.store_forward] title, "Store & Forward" enabled, "Включен", "Включает модуль." heartbeat, "Heartbeat", "The Store & Forward Server sends a periodic message onto the network. This allows connected devices to know that a server is in range and listening to received messages. A client like Android, iOS, or Web can (if supported) indicate to the user whether a Store & Forward Server is available." records, "Records", "Set this to the maximum number of records the server will save. Best to leave this at the default (0) where the module will use 2/3 of your device's available PSRAM. This is about 11,000 records." history_return_max, "History return max", "Sets the maximum number of messages to return to a client device when it requests the history." history_return_window, "History return window", "Limits the time period (in minutes) a client device can request." is_server, "Is server", "Set to true to configure your node with PSRAM as a Store & Forward Server for storing and forwarding messages. This is an alternative to setting the node as a ROUTER and only available since 2.4." [module.range_test] title, "Range Test" enabled, "Включен", "Включает модуль." sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages." save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]" [module.telemetry] title, "Телеметрия" device_update_interval, "Device metrics update interval", "How often we should send Device Metrics over the mesh in seconds." environment_update_interval, "Environment metrics update interval", "How often we should send environment (sensor) Metrics over the mesh in seconds." environment_measurement_enabled, "Environment telemetry enabled", "Enable the Environment Telemetry (Sensors)." environment_screen_enabled, "Environment screen enabled", "Show the environment telemetry data on the device display." environment_display_fahrenheit, "Display fahrenheit", "The sensor is always read in Celsius, but the user can opt to display in Fahrenheit (on the device display only) using this setting." air_quality_enabled, "Air quality enabled", "This option is used to enable/disable the sending of air quality metrics from an attached supported sensor over the mesh network." air_quality_interval, "Air quality interval", "This option is used to configure the interval in seconds that should be used to send air quality metrics from an attached supported sensor over the mesh network in seconds." power_measurement_enabled, "Power metrics enabled", "This option is used to enable/disable the sending of power telemetry as gathered by an attached supported voltage/current sensor. Note that this does not need to be enabled to monitor the voltage of the battery." power_update_interval, "Power metrics interval", "This option is used to configure the interval in seconds that should be used to send power metrics from an attached supported sensor over the mesh network in seconds." power_screen_enabled, "Power screen enabled", "Show the power telemetry data on the device display." health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds." health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network." health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display." device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry" [module.canned_message] title, "Canned Message" rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder." inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A." inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B." inputbroker_pin_press, "Input broker pin press", "GPIO Pin Value (1-39) For encoder Press port." inputbroker_event_cw, "Input broker event clockwise", "Generate the rotary clockwise event." inputbroker_event_ccw, "Input broker event counter clockwise", "Generate the rotary counter clockwise event." inputbroker_event_press, "Input broker event press", "Generate input event on Press of this kind." updown1_enabled, "Up down encoder enabled", "Enable the up / down encoder." enabled, "Включен", "Включает модуль." allow_input_source, "Источник ввода", "Введите источники событий, принятые модулем сохраненных сообщений." send_bell, "Послать колокольчик", "Отправляет символ колокольчика с каждым сообщением." [module.audio] title, "Аудио" codec2_enabled, "Включено", "Включает модуль." ptt_pin, "PTT GPIO", "The GPIO to use for the Push-To-Talk button. The default is GPIO 39 on the ESP32." bitrate, "Audio bitrate/codec mode", "The bitrate to use for audio." i2s_ws, "I2S word select", "The GPIO to use for the WS signal in the I2S interface." i2s_sd, "I2S data IN", "The GPIO to use for the SD signal in the I2S interface." i2s_din, "I2S data OUT", "The GPIO to use for the DIN signal in the I2S interface." i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface." [module.remote_hardware] title, "Remote Hardware" enabled, "Включен", "Включает модуль." allow_undefined_pin_access, "Allow undefined pin access", "Whether the Module allows consumers to read / write to pins not defined in available_pins" available_pins, "Available pins", "Exposes the available pins to the mesh for reading and writing." [module.neighbor_info] title, "Информация о соседях" enabled, "Включен", "Включает модуль." update_interval, "Update interval", "How often in seconds the neighbor info is sent to the mesh. This cannot be set lower than 4 hours (14400 seconds). The default is 6 hours (21600 seconds)." transmit_over_lora, "Transmit over LoRa", "Available from firmware 2.5.13 and higher. By default, neighbor info will only be sent to MQTT and a connected app. If enabled, the neighbor info will be sent on the primary channel over LoRa. Only available when the primary channel is not the public channel with default key and name." [module.ambient_lighting] title, "Ambient Lighting" led_state, "LED state", "Sets the LED to on or Off." current, "Current", "Sets the current for the LED output. Default is 10." red, "Red", "Sets the red LED level. Values are 0-255." green, "Green", "Sets the green LED level. Values are 0-255." blue, "Blue", "Sets the blue LED level. Values are 0-255." [module.detection_sensor] title, "Detection Sensor" enabled, "Включен", "Включает модуль." minimum_broadcast_secs, "Minimum broadcast interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected." state_broadcast_secs, "State broadcast interval", "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes, When set to 0, only state changes will be broadcasted, Works as a sort of status heartbeat for peace of mind." send_bell, "Send bell", "Send ASCII bell with alert message. Useful for triggering ext. notification on bell name." name, "Friendly name", "Used to format the message sent to mesh. Example: A name 'Motion' would result in a message 'Motion detected'. Maximum length of 20 characters." monitor_pin, "Monitor pin", "The GPIO pin to monitor for state changes." detection_trigger_type, "Detection triggered high", "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)." use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin." [module.paxcounter] title, "Счетчик посещений" enabled, "Включен", "Включает модуль." paxcounter_update_interval, "Интервал обновления", "Интервал в секундах, с которым мы можем отправлять сообщение в сеть при обнаружении изменения состояния." Wi-Fi_threshold, "Порог Wi-Fi", "Порог WiFi RSSI. По умолчанию -80" ble_threshold, "Порог BLE", "Порог BLE RSSI. По умолчанию -80" ================================================ FILE: contact/message_handlers/bot_handler.py ================================================ # A basic auto-responder bot that replies to specific messages when bot mode is enabled. import logging import threading import time from typing import Any, Dict import contact.ui.default_config as config from contact.utilities.singleton import app_state, interface_state, ui_state from contact.message_handlers.tx_handler import send_message BOT_RESPONSE_DELAY_SECONDS = 2.3 def _get_bot_catch_words() -> set[str]: """Return normalized bot trigger words from app settings.""" raw_words = getattr(config, "ping_bot_catch_words", "ping; test") words = { word.strip().casefold() for word in raw_words.replace(";", ",").split(",") if word.strip() } return words or {"ping"} def is_bot_message(message: str) -> bool: """Return True when the incoming message should trigger an automatic response.""" return message.strip().casefold() in _get_bot_catch_words() def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool: """Send a basic response when bot mode is enabled.""" if not ui_state.bot_mode_enabled: return False if not is_bot_message(message): """ Only respond to specific messages. """ return False from_node = packet.get("from") if from_node is None: return False if from_node == interface_state.myNodeNum: return False snr = packet.get('rxSnr', -128) rssi = packet.get('rxRssi', -128) replyIDset = packet.get('replyId', False) hop_start = packet.get('hopStart', 0) hop_limit = packet.get('hopLimit', 0) transport_type = packet.get('transportMechanism', None) hops = hop_start - hop_limit details = [] if snr != -128: details.append(f"SNR: {snr}") if rssi != -128: details.append(f"RSSI: {rssi}") if hops != 0: details.append(f"Hops: {hops}") if replyIDset: details.append(f"Relay: {replyIDset}") transport_text = str(transport_type).upper() if transport_type is not None else "" for transport_name in ("UDP", "MQTT"): if transport_name in transport_text: details.append(f"Via: {transport_name}") response_data_string = getattr(config, "ping_bot_response_word", "Pong!") if details: response_data_string += f" {', '.join(details)}" def send_response_delayed() -> None: try: time.sleep(BOT_RESPONSE_DELAY_SECONDS) with app_state.lock: if not ui_state.bot_mode_enabled: return send_message(response_data_string,channel=send_channel) # Import locally to avoid circular import at module import time. from contact.ui.contact_ui import request_ui_redraw request_ui_redraw(channels=True, messages=True, scroll_messages_to_bottom=True) logging.info("Bot response sent to %s on channel index %s", from_node, send_channel) except Exception: logging.exception("Bot response send failed for destination %s", from_node) threading.Thread(target=send_response_delayed, name="bot-response", daemon=True).start() return True ================================================ FILE: contact/message_handlers/rx_handler.py ================================================ import logging import os import platform import shutil import time import subprocess import threading from typing import Any, Dict, Optional # Debounce notification sounds so a burst of queued messages only plays once. _SOUND_DEBOUNCE_SECONDS = 0.8 _sound_timer: Optional[threading.Timer] = None _sound_timer_lock = threading.Lock() _last_sound_request = 0.0 def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None: """Schedule a notification sound after a short quiet period. If more messages arrive before the delay elapses, the timer is reset. This prevents playing a sound for each message when a backlog flushes. """ global _sound_timer, _last_sound_request now = time.monotonic() with _sound_timer_lock: _last_sound_request = now # Cancel any previously scheduled sound. if _sound_timer is not None: try: _sound_timer.cancel() except Exception: pass _sound_timer = None def _fire(expected_request_time: float) -> None: # Only play if nothing newer has been scheduled. with _sound_timer_lock: if expected_request_time != _last_sound_request: return play_sound() _sound_timer = threading.Timer(delay, _fire, args=(now,)) _sound_timer.daemon = True _sound_timer.start() from contact.utilities.utils import ( refresh_node_list, add_new_message, ) from contact.ui.contact_ui import ( add_notification, request_ui_redraw, ) from contact.utilities.db_handler import ( save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db, ) import contact.ui.default_config as config from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state from contact.message_handlers.bot_handler import bot_respond def play_sound(): try: system = platform.system() sound_path = None executable = None if system == "Darwin": # macOS sound_path = "/System/Library/Sounds/Ping.aiff" executable = "afplay" elif system == "Linux": ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga" wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback if shutil.which("paplay") and os.path.exists(ogg_path): executable = "paplay" sound_path = ogg_path elif shutil.which("ffplay") and os.path.exists(ogg_path): executable = "ffplay" sound_path = ogg_path elif shutil.which("aplay") and os.path.exists(wav_path): executable = "aplay" sound_path = wav_path else: logging.warning("No suitable sound player or sound file found on Linux") if executable and sound_path: cmd = [executable, sound_path] if executable == "ffplay": cmd = [executable, "-nodisp", "-autoexit", sound_path] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return except subprocess.CalledProcessError as e: logging.error(f"Sound playback failed: {e}") except Exception as e: logging.error(f"Unexpected error: {e}") def on_receive(packet: Dict[str, Any], interface: Any) -> None: """ Handles an incoming packet from a Meshtastic interface. Args: packet: The received Meshtastic packet as a dictionary. interface: The Meshtastic interface instance that received the packet. """ with app_state.lock: # Update packet log ui_state.packet_buffer.append(packet) if len(ui_state.packet_buffer) > 20: # Trim buffer to 20 packets ui_state.packet_buffer = ui_state.packet_buffer[-20:] if ui_state.display_log: request_ui_redraw(packetlog=True) if ui_state.current_window == 4: menu_state.need_redraw = True try: if "decoded" not in packet: return # Assume any incoming packet could update the last seen time for a node changed = refresh_node_list() if changed: request_ui_redraw(nodes=True) if packet["decoded"]["portnum"] == "NODEINFO_APP": if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]: maybe_store_nodeinfo_in_db(packet) elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP": hop_start = packet.get('hopStart', 0) hop_limit = packet.get('hopLimit', 0) hops = hop_start - hop_limit if config.notification_sound == "True": schedule_notification_sound() message_bytes = packet["decoded"]["payload"] message_string = message_bytes.decode("utf-8") refresh_channels = False refresh_messages = False if packet.get("channel"): channel_number = packet["channel"] else: channel_number = 0 if packet["to"] == interface_state.myNodeNum: if packet["from"] in ui_state.channel_list: pass else: ui_state.channel_list.append(packet["from"]) if packet["from"] not in ui_state.all_messages: ui_state.all_messages[packet["from"]] = [] update_node_info_in_db(packet["from"], chat_archived=False) refresh_channels = True channel_number = ui_state.channel_list.index(packet["from"]) bot_respond(packet, message_string, channel_number) channel_id = ui_state.channel_list[channel_number] if channel_id != ui_state.channel_list[ui_state.selected_channel]: add_notification(channel_number) refresh_channels = True else: refresh_messages = True # Add received message to the messages list message_from_id = packet["from"] message_from_string = get_name_from_database(message_from_id, type="short") + ":" add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string) if refresh_channels: request_ui_redraw(channels=True) if refresh_messages: request_ui_redraw(messages=True, scroll_messages_to_bottom=True) save_message_to_db(channel_id, message_from_id, message_string) except KeyError as e: logging.error(f"Error processing packet: {e}") ================================================ FILE: contact/message_handlers/tx_handler.py ================================================ import time from typing import Any, Dict import google.protobuf.json_format from meshtastic import BROADCAST_NUM from meshtastic.protobuf import mesh_pb2, portnums_pb2 from contact.utilities.db_handler import ( save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db, ) import contact.ui.default_config as config from contact.utilities.singleton import ui_state, interface_state, app_state from contact.utilities.utils import add_new_message ack_naks: Dict[str, Dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp} # Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention # See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 def onAckNak(packet: Dict[str, Any]) -> None: """ Handles incoming ACK/NAK response packets. """ from contact.ui.contact_ui import request_ui_redraw with app_state.lock: request = packet["decoded"]["requestId"] if request not in ack_naks: return acknak = ack_naks.pop(request) message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1] confirm_string = " " ack_type = None if packet["decoded"]["routing"]["errorReason"] == "NONE": if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK confirm_string = config.ack_implicit_str ack_type = "Implicit" else: confirm_string = config.ack_str ack_type = "Ack" else: confirm_string = config.nak_str ack_type = "Nak" ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = ( time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ", message, ) update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type) channel_number = ui_state.channel_list.index(acknak["channel"]) if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]: request_ui_redraw(messages=True) def on_response_traceroute(packet: Dict[str, Any]) -> None: """ Handle traceroute response packets and render the route visually in the UI. """ from contact.ui.contact_ui import add_notification, request_ui_redraw with app_state.lock: refresh_channels = False refresh_messages = False UNK_SNR = -128 # Value representing unknown SNR route_discovery = mesh_pb2.RouteDiscovery() route_discovery.ParseFromString(packet["decoded"]["payload"]) msg_dict = google.protobuf.json_format.MessageToDict(route_discovery) msg_str = "Traceroute to:\n" route_str = ( get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}" ) # Start with destination of response lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"]) snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1 if lenTowards > 0: for idx, node_num in enumerate(msg_dict["route"]): route_str += ( " --> " + (get_name_from_database(node_num, "short") or f"{node_num:08x}") + " (" + ( str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?" ) + "dB)" ) route_str += ( " --> " + (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}") + " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)" ) msg_str += route_str + "\n" lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"]) backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1 if backValid: msg_str += "Back:\n" route_str = get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}" if lenBack > 0: for idx, node_num in enumerate(msg_dict["routeBack"]): route_str += ( " --> " + (get_name_from_database(node_num, "short") or f"{node_num:08x}") + " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)" ) route_str += ( " --> " + (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}") + " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)" ) msg_str += route_str + "\n" if packet["from"] not in ui_state.channel_list: ui_state.channel_list.append(packet["from"]) refresh_channels = True if is_chat_archived(packet["from"]): update_node_info_in_db(packet["from"], chat_archived=False) channel_number = ui_state.channel_list.index(packet["from"]) channel_id = ui_state.channel_list[channel_number] if channel_id == ui_state.channel_list[ui_state.selected_channel]: refresh_messages = True else: add_notification(channel_number) refresh_channels = True message_from_string = get_name_from_database(packet["from"], type="short") + ":\n" add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str) if refresh_channels: request_ui_redraw(channels=True) if refresh_messages: request_ui_redraw(messages=True, scroll_messages_to_bottom=True) save_message_to_db(channel_id, packet["from"], msg_str) def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None: """ Sends a chat message using the selected channel. """ myid = interface_state.myNodeNum send_on_channel = 0 channel_id = ui_state.channel_list[channel] if isinstance(channel_id, int): send_on_channel = 0 destination = channel_id elif isinstance(channel_id, str): send_on_channel = channel sent_message_data = interface_state.interface.sendText( text=message, destinationId=destination, wantAck=True, wantResponse=False, onResponse=onAckNak, channelIndex=send_on_channel, ) add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message) timestamp = save_message_to_db(channel_id, myid, message) ack_naks[sent_message_data.id] = { "channel": channel_id, "messageIndex": len(ui_state.all_messages[channel_id]) - 1, "timestamp": timestamp, } def send_traceroute() -> None: """ Sends a RouteDiscovery protobuf to the selected node. """ channel_id = ui_state.node_list[ui_state.selected_node] add_new_message(channel_id, f"{config.message_prefix} Sent Traceroute", "") r = mesh_pb2.RouteDiscovery() interface_state.interface.sendData( r, destinationId=channel_id, portNum=portnums_pb2.PortNum.TRACEROUTE_APP, wantResponse=True, onResponse=on_response_traceroute, channelIndex=0, hopLimit=3, ) ================================================ FILE: contact/settings.py ================================================ import contextlib import curses import io import logging import sys import traceback import contact.ui.default_config as config from contact.ui.colors import setup_colors from contact.ui.control_ui import set_region, settings_menu from contact.ui.dialog import dialog from contact.ui.splash import draw_splash from contact.utilities.arg_parser import setup_parser from contact.utilities.i18n import t from contact.utilities.input_handlers import get_list_input from contact.utilities.interfaces import initialize_interface, reconnect_interface def close_interface(interface: object) -> None: if interface is None: return with contextlib.suppress(Exception): interface.close() def main(stdscr: curses.window) -> None: output_capture = io.StringIO() interface = None try: with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture): setup_colors() ensure_min_rows(stdscr) draw_splash(stdscr) curses.curs_set(0) stdscr.keypad(True) parser = setup_parser() args = parser.parse_args() interface = initialize_interface(args) if interface.localNode.localConfig.lora.region == 0: confirmation = get_list_input( t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"), "Yes", ["Yes", "No"], ) if confirmation == "Yes": set_region(interface) close_interface(interface) draw_splash(stdscr) interface = reconnect_interface(args) stdscr.clear() stdscr.refresh() settings_menu(stdscr, interface) except Exception as e: console_output = output_capture.getvalue() logging.error("An error occurred: %s", e) logging.error("Traceback: %s", traceback.format_exc()) logging.error("Console output before crash:\n%s", console_output) raise finally: close_interface(interface) def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None: while True: rows, _ = stdscr.getmaxyx() if rows >= min_rows: return dialog( t("ui.dialog.resize_title", default="Resize Terminal"), t( "ui.dialog.resize_body", default="Please resize the terminal to at least {rows} rows.", rows=min_rows, ), ) curses.update_lines_cols() stdscr.clear() stdscr.refresh() logging.basicConfig( # Run `tail -f client.log` in another terminal to view live filename=config.log_file_path, level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL) format="%(asctime)s - %(levelname)s - %(message)s", ) if __name__ == "__main__": log_file = config.log_file_path log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes sys.stdout = log_f sys.stderr = log_f with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f): try: curses.wrapper(main) except KeyboardInterrupt: logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging sys.exit(0) # Ensure a clean exit except Exception as e: logging.error("Fatal error in curses wrapper: %s", e) logging.error("Traceback: %s", traceback.format_exc()) sys.exit(1) # Exit with an error code ================================================ FILE: contact/ui/colors.py ================================================ import curses import contact.ui.default_config as config COLOR_MAP = { "black": curses.COLOR_BLACK, "red": curses.COLOR_RED, "green": curses.COLOR_GREEN, "yellow": curses.COLOR_YELLOW, "blue": curses.COLOR_BLUE, "magenta": curses.COLOR_MAGENTA, "cyan": curses.COLOR_CYAN, "white": curses.COLOR_WHITE, } def setup_colors(reinit: bool = False) -> None: """ Initialize curses color pairs based on the COLOR_CONFIG. """ curses.start_color() if reinit: conf = config.initialize_config() config.assign_config_variables(conf) for idx, (category, (fg_name, bg_name)) in enumerate(config.COLOR_CONFIG.items(), start=1): fg = COLOR_MAP.get(fg_name.lower(), curses.COLOR_WHITE) bg = COLOR_MAP.get(bg_name.lower(), curses.COLOR_BLACK) curses.init_pair(idx, fg, bg) config.COLOR_CONFIG[category] = idx print() def get_color(category: str, bold: bool = False, reverse: bool = False, underline: bool = False) -> int: """ Retrieve a curses color pair with optional attributes. """ color = curses.color_pair(config.COLOR_CONFIG[category]) if bold: color |= curses.A_BOLD if reverse: color |= curses.A_REVERSE if underline: color |= curses.A_UNDERLINE return color ================================================ FILE: contact/ui/contact_ui.py ================================================ import curses import logging import time import traceback from numbers import Real from typing import Union from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list, add_new_message from contact.settings import settings_menu from contact.message_handlers.tx_handler import send_message, send_traceroute from contact.utilities.utils import parse_protobuf from contact.ui.colors import get_color from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived from contact.utilities.input_handlers import get_list_input from contact.utilities.i18n import t from contact.utilities.emoji_utils import normalize_message_text import contact.ui.default_config as config import contact.ui.dialog from contact.ui.nav_utils import ( move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text, truncate_with_ellipsis, pad_to_width, ) from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state MIN_COL = 1 # "effectively zero" without breaking curses RESIZE_DEBOUNCE_MS = 250 root_win = None nodes_pad = None def request_ui_redraw( *, channels: bool = False, messages: bool = False, nodes: bool = False, packetlog: bool = False, full: bool = False, scroll_messages_to_bottom: bool = False, ) -> None: ui_state.redraw_channels = ui_state.redraw_channels or channels ui_state.redraw_messages = ui_state.redraw_messages or messages ui_state.redraw_nodes = ui_state.redraw_nodes or nodes ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog ui_state.redraw_full_ui = ui_state.redraw_full_ui or full ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom def process_pending_ui_updates(stdscr: curses.window) -> None: if ui_state.redraw_full_ui: ui_state.redraw_full_ui = False ui_state.redraw_channels = False ui_state.redraw_messages = False ui_state.redraw_nodes = False ui_state.redraw_packetlog = False ui_state.scroll_messages_to_bottom = False handle_resize(stdscr, False) return if ui_state.redraw_channels: ui_state.redraw_channels = False draw_channel_list() if ui_state.redraw_nodes: ui_state.redraw_nodes = False draw_node_list() if ui_state.redraw_messages: scroll_to_bottom = ui_state.scroll_messages_to_bottom ui_state.redraw_messages = False ui_state.scroll_messages_to_bottom = False draw_messages_window(scroll_to_bottom) if ui_state.redraw_packetlog: ui_state.redraw_packetlog = False draw_packetlog_win() # Draw arrows for a specific window id (0=channel,1=messages,2=nodes). def draw_window_arrows(window_id: int) -> None: if window_id == 0: draw_main_arrows(channel_win, len(ui_state.channel_list), window=0) channel_win.refresh() elif window_id == 1: msg_line_count = messages_pad.getmaxyx()[0] draw_main_arrows( messages_win, msg_line_count, window=1, log_height=packetlog_win.getmaxyx()[0], ) messages_win.refresh() elif window_id == 2: draw_main_arrows(nodes_win, len(ui_state.node_list), window=2) nodes_win.refresh() def compute_widths(total_w: int, focus: int): # focus: 0=channel, 1=messages, 2=nodes if total_w < 3 * MIN_COL: # tiny terminals: allocate something, anything return max(1, total_w), 0, 0 if focus == 0: return total_w - 2 * MIN_COL, MIN_COL, MIN_COL if focus == 1: return MIN_COL, total_w - 2 * MIN_COL, MIN_COL return MIN_COL, MIN_COL, total_w - 2 * MIN_COL def paint_frame(win, selected: bool) -> None: win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame")) win.box() win.attrset(get_color("window_frame")) win.refresh() def get_channel_row_color(index: int) -> int: if index == ui_state.selected_channel: if ui_state.current_window == 0: return get_color("channel_list", reverse=True) return get_color("channel_selected") return get_color("channel_list") def get_node_row_color(index: int, highlight: bool = False) -> int: node_num = ui_state.node_list[index] node = interface_state.interface.nodesByNum.get(node_num, {}) color = "node_list" if node.get("isFavorite"): color = "node_favorite" if node.get("isIgnored"): color = "node_ignored" reverse = index == ui_state.selected_node and (ui_state.current_window == 2 or highlight) return get_color(color, reverse=reverse) def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None: if nodes_pad is None or not ui_state.node_list: return width = max(0, nodes_pad.getmaxyx()[1] - 4) if 0 <= old_index < len(ui_state.node_list): try: nodes_pad.chgat(old_index, 1, width, get_node_row_color(old_index, highlight=highlight)) except curses.error: pass if 0 <= ui_state.selected_node < len(ui_state.node_list): try: nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node, highlight=highlight)) except curses.error: pass ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3)) refresh_pad(2) draw_window_arrows(2) def refresh_main_window(window_id: int, selected: bool) -> None: if window_id == 0: paint_frame(channel_win, selected=selected) if ui_state.channel_list: width = max(0, channel_pad.getmaxyx()[1] - 4) channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel)) refresh_pad(0) elif window_id == 1: paint_frame(messages_win, selected=selected) refresh_pad(1) elif window_id == 2: paint_frame(nodes_win, selected=selected) if ui_state.node_list and nodes_pad is not None: width = max(0, nodes_pad.getmaxyx()[1] - 4) nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node)) refresh_pad(2) def get_node_display_name(node_num: int, node: dict) -> str: user = node.get("user") or {} return user.get("longName") or get_name_from_database(node_num, "long") def get_selected_channel_title() -> str: if not ui_state.channel_list: return "" channel = ui_state.channel_list[min(ui_state.selected_channel, len(ui_state.channel_list) - 1)] if isinstance(channel, int): return get_name_from_database(channel, "long") or get_name_from_database(channel, "short") or str(channel) return str(channel) def get_window_title(window: int) -> str: if window == 2: return f"Nodes: {len(ui_state.node_list)}" if ui_state.single_pane_mode and window == 1: return get_selected_channel_title() return "" def draw_frame_title(box: curses.window, title: str) -> None: if not title: return _, box_w = box.getmaxyx() max_title_width = max(0, box_w - 6) if max_title_width <= 0: return clipped_title = truncate_with_ellipsis(title, max_title_width).rstrip() if not clipped_title: return try: box.addstr(0, 2, f" {clipped_title} ", curses.A_BOLD) except curses.error: pass def handle_resize(stdscr: curses.window, firstrun: bool) -> None: """Handle terminal resize events and redraw the UI accordingly.""" global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win # Calculate window max dimensions height, width = stdscr.getmaxyx() if ui_state.single_pane_mode: channel_width = width messages_width = width nodes_width = width channel_x = 0 messages_x = 0 nodes_x = 0 else: channel_width = int(config.channel_list_16ths) * (width // 16) nodes_width = int(config.node_list_16ths) * (width // 16) messages_width = width - channel_width - nodes_width channel_x = 0 messages_x = channel_width nodes_x = channel_width + messages_width channel_width = max(MIN_COL, channel_width) messages_width = max(MIN_COL, messages_width) nodes_width = max(MIN_COL, nodes_width) # Ensure the three widths sum exactly to the terminal width by adjusting the focused pane total = channel_width + messages_width + nodes_width if not ui_state.single_pane_mode and total != width: delta = total - width if ui_state.current_window == 0: channel_width = max(MIN_COL, channel_width - delta) elif ui_state.current_window == 1: messages_width = max(MIN_COL, messages_width - delta) else: nodes_width = max(MIN_COL, nodes_width - delta) entry_height = 3 y_pad = entry_height content_h = max(1, height - y_pad) pkt_h = max(1, int(height / 3)) if firstrun: entry_win = curses.newwin(entry_height, width, height - entry_height, 0) channel_win = curses.newwin(content_h, channel_width, 0, channel_x) messages_win = curses.newwin(content_h, messages_width, 0, messages_x) nodes_win = curses.newwin(content_h, nodes_width, 0, nodes_x) packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, messages_x) # Will be resized to what we need when drawn messages_pad = curses.newpad(1, 1) nodes_pad = curses.newpad(1, 1) channel_pad = curses.newpad(1, 1) # Set background colors for windows for win in [entry_win, channel_win, messages_win, nodes_win, packetlog_win]: win.bkgd(get_color("background")) # Set background colors for pads for pad in [messages_pad, nodes_pad, channel_pad]: pad.bkgd(get_color("background")) # Set colors for window frames for win in [channel_win, entry_win, nodes_win, messages_win]: win.attrset(get_color("window_frame")) else: for win in [entry_win, channel_win, messages_win, nodes_win, packetlog_win]: win.erase() entry_win.resize(entry_height, width) entry_win.mvwin(height - entry_height, 0) channel_win.resize(content_h, channel_width) channel_win.mvwin(0, channel_x) messages_win.resize(content_h, messages_width) messages_win.mvwin(0, messages_x) nodes_win.resize(content_h, nodes_width) nodes_win.mvwin(0, nodes_x) packetlog_win.resize(pkt_h, messages_width) packetlog_win.mvwin(height - pkt_h - entry_height, messages_x) # Draw window borders windows_to_draw = [entry_win] if ui_state.single_pane_mode: windows_to_draw.append([channel_win, messages_win, nodes_win][ui_state.current_window]) else: windows_to_draw.extend([channel_win, nodes_win, messages_win]) for win in windows_to_draw: win.box() win.refresh() entry_win.keypad(True) entry_win.timeout(200) curses.curs_set(1) try: draw_channel_list() draw_messages_window(True) draw_node_list() draw_window_arrows(ui_state.current_window) except: # Resize events can come faster than we can re-draw, which can cause a curses error. # In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later. pass def drain_resize_events(input_win: curses.window) -> Union[str, int, None]: """Wait for resize events to settle and preserve one queued non-resize key.""" input_win.timeout(RESIZE_DEBOUNCE_MS) try: while True: try: next_char = input_win.get_wch() except curses.error: return None if next_char == curses.KEY_RESIZE: continue return next_char finally: input_win.timeout(-1) def main_ui(stdscr: curses.window) -> None: """Main UI loop for the curses interface.""" global input_text global root_win root_win = stdscr input_text = "" queued_char = None stdscr.keypad(True) get_channels() handle_resize(stdscr, True) while True: with app_state.lock: process_pending_ui_updates(stdscr) draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) # Get user input from entry window try: if queued_char is None: char = entry_win.get_wch() else: char = queued_char queued_char = None except curses.error: continue # draw_debug(f"Keypress: {char}") if char == curses.KEY_UP: handle_up() elif char == curses.KEY_DOWN: handle_down() elif char == curses.KEY_HOME: handle_home() elif char == curses.KEY_END: handle_end() elif char == curses.KEY_PPAGE: handle_pageup() elif char == curses.KEY_NPAGE: handle_pagedown() elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT: handle_leftright(char) elif char in (curses.KEY_F1, curses.KEY_F2, curses.KEY_F3): handle_function_keys(char) elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)): input_text = handle_enter(input_text) elif char in (curses.KEY_F4, chr(20)): # Ctrl + t and F4 for Traceroute handle_ctrl_t(stdscr) elif char == curses.KEY_F5: handle_f5_key(stdscr) elif char in (curses.KEY_BACKSPACE, chr(127)): input_text = handle_backspace(entry_win, input_text) elif char in (curses.KEY_F12, "`"): # ` Launch the settings interface handle_backtick(stdscr) elif char == chr(16): # Ctrl + P for Packet Log handle_ctrl_p() elif char == curses.KEY_RESIZE: input_text = "" queued_char = drain_resize_events(entry_win) handle_resize(stdscr, False) continue elif char == chr(4): # Ctrl + D to delete current channel or node handle_ctrl_d() elif char == chr(31) or ( char == "/" and not input_text and ui_state.current_window in (0, 2) ): # Ctrl + / or / to search in channel/node lists handle_ctrl_fslash() elif char == chr(11): # Ctrl + K for Help handle_ctrl_k(stdscr) elif char == chr(6): # Ctrl + F to toggle favorite handle_ctrl_f(stdscr) elif char == chr(2): # Ctrl + B to toggle bot responder handle_ctrl_b(stdscr) elif char == chr(7): # Ctrl + G to toggle ignored handle_ctlr_g(stdscr) elif char == chr(27): # Escape to exit break else: # Append typed character to input text if isinstance(char, str): input_text += char else: input_text += chr(char) def handle_up() -> None: """Handle key up events to scroll the current window.""" if ui_state.current_window == 0: scroll_channels(-1) elif ui_state.current_window == 1: scroll_messages(-1) elif ui_state.current_window == 2: scroll_nodes(-1) def handle_down() -> None: """Handle key down events to scroll the current window.""" if ui_state.current_window == 0: scroll_channels(1) elif ui_state.current_window == 1: scroll_messages(1) elif ui_state.current_window == 2: scroll_nodes(1) def handle_home() -> None: """Handle home key events to select the first item in the current window.""" if ui_state.current_window == 0: select_channel(0) elif ui_state.current_window == 1: ui_state.selected_message = 0 refresh_pad(1) elif ui_state.current_window == 2: select_node(0) draw_window_arrows(ui_state.current_window) def handle_end() -> None: """Handle end key events to select the last item in the current window.""" if ui_state.current_window == 0: select_channel(len(ui_state.channel_list) - 1) elif ui_state.current_window == 1: msg_line_count = messages_pad.getmaxyx()[0] ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0) refresh_pad(1) elif ui_state.current_window == 2: select_node(len(ui_state.node_list) - 1) draw_window_arrows(ui_state.current_window) def handle_pageup() -> None: """Handle page up key events to scroll the current window by a page.""" if ui_state.current_window == 0: select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)) elif ui_state.current_window == 1: ui_state.selected_message = max( ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0 ) refresh_pad(1) elif ui_state.current_window == 2: select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) draw_window_arrows(ui_state.current_window) def handle_pagedown() -> None: """Handle page down key events to scroll the current window down.""" if ui_state.current_window == 0: select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)) elif ui_state.current_window == 1: msg_line_count = messages_pad.getmaxyx()[0] ui_state.selected_message = min( ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win), msg_line_count - get_msg_window_lines(messages_win, packetlog_win), ) refresh_pad(1) elif ui_state.current_window == 2: select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) draw_window_arrows(ui_state.current_window) def handle_leftright(char: int) -> None: """Handle left/right key events to switch between windows.""" delta = -1 if char == curses.KEY_LEFT else 1 old_window = ui_state.current_window ui_state.current_window = (ui_state.current_window + delta) % 3 if ui_state.single_pane_mode: handle_resize(root_win, False) return refresh_main_window(old_window, selected=False) if not ui_state.single_pane_mode: draw_window_arrows(old_window) refresh_main_window(ui_state.current_window, selected=True) draw_window_arrows(ui_state.current_window) def handle_function_keys(char: int) -> None: """Switch windows using F1/F2/F3.""" if char == curses.KEY_F1: target = 0 elif char == curses.KEY_F2: target = 1 elif char == curses.KEY_F3: target = 2 else: return old_window = ui_state.current_window if target == old_window: return ui_state.current_window = target if ui_state.single_pane_mode: handle_resize(root_win, False) return refresh_main_window(old_window, selected=False) if not ui_state.single_pane_mode: draw_window_arrows(old_window) refresh_main_window(ui_state.current_window, selected=True) draw_window_arrows(ui_state.current_window) def handle_enter(input_text: str) -> str: """Handle Enter key events to send messages or select channels.""" if ui_state.current_window == 2: node_list = ui_state.node_list if node_list[ui_state.selected_node] not in ui_state.channel_list: ui_state.channel_list.append(node_list[ui_state.selected_node]) if node_list[ui_state.selected_node] not in ui_state.all_messages: ui_state.all_messages[node_list[ui_state.selected_node]] = [] ui_state.selected_channel = ui_state.channel_list.index(node_list[ui_state.selected_node]) if is_chat_archived(ui_state.channel_list[ui_state.selected_channel]): update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=False) ui_state.selected_node = 0 ui_state.current_window = 0 handle_resize(root_win, False) draw_node_list() draw_channel_list() draw_messages_window(True) draw_window_arrows(ui_state.current_window) return input_text elif len(input_text) > 0: # TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node. now = time.monotonic() if now - ui_state.last_sent_time < 2.5: contact.ui.dialog.dialog( t("ui.dialog.slow_down_title", default="Slow down"), t("ui.dialog.slow_down_body", default="Please wait 2 seconds between messages."), ) return input_text # Enter key pressed, send user input as message send_message(input_text, channel=ui_state.selected_channel) draw_messages_window(True) ui_state.last_sent_time = now entry_win.erase() if ui_state.current_window == 0: ui_state.current_window = 1 handle_resize(root_win, False) return "" return input_text def handle_f5_key(stdscr: curses.window) -> None: if not ui_state.node_list: return def build_node_details() -> tuple[str, list[str]]: node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]] message_parts = [] message_parts.append("**📋 Basic Information:**") message_parts.append(f"• Device: {node.get('user', {}).get('longName', 'Unknown')}") message_parts.append(f"• Short name: {node.get('user', {}).get('shortName', 'Unknown')}") message_parts.append(f"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}") message_parts.append(f"• Role: {node.get('user', {}).get('role', 'Unknown')}") message_parts.append(f"Public key: {node.get('user', {}).get('publicKey')}") message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}") if "position" in node: pos = node["position"] has_coords = pos.get("latitude") and pos.get("longitude") if has_coords: message_parts.append(f"• Position: {pos['latitude']:.4f}, {pos['longitude']:.4f}") if pos.get("altitude"): message_parts.append(f"• Altitude: {pos['altitude']}m") if has_coords: message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}") if any(node.get(key) is not None for key in ["snr", "hopsAway", "lastHeard"]): message_parts.append("") message_parts.append("**🌐 Network Metrics:**") if node.get("snr") is not None: snr = node["snr"] if isinstance(snr, Real): snr_status = ( "🟢 Excellent" if snr > 10 else ( "🟡 Good" if snr > 3 else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor" ) ) message_parts.append(f"• SNR: {snr}dB {snr_status}") else: message_parts.append(f"• SNR: {snr}dB") if node.get("hopsAway") is not None: hops = node["hopsAway"] hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩" message_parts.append(f"• Hops away: {hop_emoji} {hops}") if node.get("lastHeard"): message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}") if node.get("deviceMetrics"): metrics = node["deviceMetrics"] message_parts.append("") message_parts.append("**📊 Device Metrics:**") if metrics.get("batteryLevel") is not None: battery = metrics["batteryLevel"] battery_emoji = "🔴" if isinstance(battery, Real): battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴" voltage_info = f" ({metrics['voltage']}v)" if "voltage" in metrics else "" message_parts.append(f"• Battery: {battery_emoji} {battery}%{voltage_info}") if metrics.get("uptimeSeconds") is not None: message_parts.append(f"• Uptime: ⏱️ {get_readable_duration(metrics['uptimeSeconds'])}") if metrics.get("channelUtilization") is not None: util = metrics["channelUtilization"] if isinstance(util, Real): util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢" message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%") else: message_parts.append(f"• Channel utilization: {util}%") if metrics.get("airUtilTx") is not None: air_util = metrics["airUtilTx"] if isinstance(air_util, Real): air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢" message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%") else: message_parts.append(f"• Air utilization TX: {air_util}%") title = t( "ui.dialog.node_details_title", default="📡 Node Details: {name}", name=node.get("user", {}).get("shortName", "Unknown"), ) return title, message_parts previous_window = ui_state.current_window ui_state.current_window = 4 scroll_offset = 0 dialog_win = None curses.curs_set(0) refresh_node_selection(highlight=True) try: while True: curses.update_lines_cols() height, width = curses.LINES, curses.COLS title, message_lines = build_node_details() max_line_length = max(len(title), *(len(line) for line in message_lines)) dialog_width = min(max(max_line_length + 4, 20), max(10, width - 2)) dialog_height = min(max(len(message_lines) + 4, 6), max(6, height - 2)) x = max(0, (width - dialog_width) // 2) y = max(0, (height - dialog_height) // 2) viewport_h = max(1, dialog_height - 4) max_scroll = max(0, len(message_lines) - viewport_h) scroll_offset = max(0, min(scroll_offset, max_scroll)) if dialog_win is None: dialog_win = curses.newwin(dialog_height, dialog_width, y, x) else: dialog_win.erase() dialog_win.refresh() dialog_win.resize(dialog_height, dialog_width) dialog_win.mvwin(y, x) dialog_win.keypad(True) dialog_win.bkgd(get_color("background")) dialog_win.attrset(get_color("window_frame")) dialog_win.border(0) try: dialog_win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default")) hint = f" {ui_state.selected_node + 1}/{len(ui_state.node_list)} " dialog_win.addstr(0, max(2, dialog_width - len(hint) - 2), hint, get_color("commands")) except curses.error: pass msg_win = dialog_win.derwin(viewport_h + 2, dialog_width - 2, 1, 1) msg_win.erase() for row, line in enumerate(message_lines[scroll_offset : scroll_offset + viewport_h], start=1): trimmed = line[: max(0, dialog_width - 6)] try: msg_win.addstr(row, 1, trimmed, get_color("settings_default")) except curses.error: pass if len(message_lines) > viewport_h: old_index = ui_state.start_index[4] if len(ui_state.start_index) > 4 else 0 while len(ui_state.start_index) <= 4: ui_state.start_index.append(0) ui_state.start_index[4] = scroll_offset draw_main_arrows(msg_win, len(message_lines) - 1, window=4) ui_state.start_index[4] = old_index try: ok_text = " Up/Down: Nodes PgUp/PgDn: Scroll Esc: Close " dialog_win.addstr( dialog_height - 2, max(1, (dialog_width - len(ok_text)) // 2), ok_text[: max(0, dialog_width - 2)], get_color("settings_default", reverse=True), ) except curses.error: pass dialog_win.refresh() msg_win.noutrefresh() curses.doupdate() dialog_win.timeout(200) try: char = dialog_win.getch() except curses.error: continue if menu_state.need_redraw: menu_state.need_redraw = False continue if char in (27, curses.KEY_LEFT, curses.KEY_ENTER, 10, 13, 32): break if char == curses.KEY_UP: old_selected_node = ui_state.selected_node ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list) scroll_offset = 0 refresh_node_selection(old_selected_node, highlight=True) elif char == curses.KEY_DOWN: old_selected_node = ui_state.selected_node ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list) scroll_offset = 0 refresh_node_selection(old_selected_node, highlight=True) elif char == curses.KEY_PPAGE: scroll_offset = max(0, scroll_offset - viewport_h) elif char == curses.KEY_NPAGE: scroll_offset = min(max_scroll, scroll_offset + viewport_h) elif char == curses.KEY_HOME: scroll_offset = 0 elif char == curses.KEY_END: scroll_offset = max_scroll elif char == curses.KEY_RESIZE: continue except (IndexError, KeyError): return finally: if dialog_win is not None: dialog_win.erase() dialog_win.refresh() ui_state.current_window = previous_window curses.curs_set(1) handle_resize(stdscr, False) def handle_ctrl_t(stdscr: curses.window) -> None: """Handle Ctrl + T key events to send a traceroute.""" now = time.monotonic() cooldown = 30.0 remaining = cooldown - (now - ui_state.last_traceroute_time) if remaining > 0: curses.curs_set(0) # Hide cursor contact.ui.dialog.dialog( t("ui.dialog.traceroute_not_sent_title", default="Traceroute Not Sent"), t( "ui.dialog.traceroute_not_sent_body", default="Please wait {seconds} seconds before sending another traceroute.", seconds=int(remaining), ), ) curses.curs_set(1) # Show cursor again handle_resize(stdscr, False) return send_traceroute() ui_state.last_traceroute_time = now curses.curs_set(0) # Hide cursor contact.ui.dialog.dialog( t( "ui.dialog.traceroute_sent_title", default="Traceroute Sent To: {name}", name=get_name_from_database(ui_state.node_list[ui_state.selected_node]), ), t("ui.dialog.traceroute_sent_body", default="Results will appear in messages window."), ) curses.curs_set(1) # Show cursor again handle_resize(stdscr, False) def handle_backspace(entry_win: curses.window, input_text: str) -> str: """Handle backspace key events to remove the last character from input text.""" if input_text: input_text = input_text[:-1] y, x = entry_win.getyx() entry_win.move(y, x - 1) entry_win.addch(" ") # entry_win.move(y, x - 1) entry_win.refresh() return input_text def handle_backtick(stdscr: curses.window) -> None: """Handle backtick key events to open the settings menu.""" curses.curs_set(0) previous_window = ui_state.current_window ui_state.current_window = 4 settings_menu(stdscr, interface_state.interface) ui_state.current_window = previous_window ui_state.single_pane_mode = config.single_pane_mode.lower() == "true" curses.curs_set(1) get_channels() refresh_node_list() handle_resize(stdscr, False) def handle_ctrl_p() -> None: """Handle Ctrl + P key events to toggle the packet log display.""" # Display packet log if ui_state.display_log is False: ui_state.display_log = True draw_messages_window(True) else: ui_state.display_log = False packetlog_win.erase() draw_messages_window(True) # --- Ctrl+K handler for Help --- def handle_ctrl_k(stdscr: curses.window) -> None: """Handle Ctrl + K to show a help window with shortcut keys.""" curses.curs_set(0) cmds = [ t("ui.help.scroll", default="Up/Down = Scroll"), t("ui.help.switch_window", default="Left/Right = Switch window"), t("ui.help.jump_windows", default="F1/F2/F3 = Jump to Channel/Messages/Nodes"), t("ui.help.enter", default="ENTER = Send / Select"), t("ui.help.settings", default="` or F12 = Settings"), t("ui.help.quit", default="ESC = Quit"), t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"), t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"), t("ui.help.node_info", default="F5 = Full node info"), t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"), t("ui.help.favorite", default="Ctrl+F = Favorite"), t("ui.help.bot_responder", default="Ctrl+B = Toggle Bot Responder"), t("ui.help.ignore", default="Ctrl+G = Ignore"), t("ui.help.search", default="Ctrl+/ = Search"), t("ui.help.help", default="Ctrl+K = Help"), ] contact.ui.dialog.dialog(t("ui.dialog.help_title", default="Help - Shortcut Keys"), "\n".join(cmds)) curses.curs_set(1) handle_resize(stdscr, False) def handle_ctrl_b(stdscr: curses.window) -> None: """Handle Ctrl + B key events to toggle automatic bot responses.""" ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t( "ui.bot.status.disabled", default="Disabled" ) curses.curs_set(0) contact.ui.dialog.dialog( t("ui.bot.dialog.title", default="Bot Responder"), t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status), ) if ui_state.channel_list: channel_id = ui_state.channel_list[ui_state.selected_channel] add_new_message( channel_id, f"{config.message_prefix} Info: ", t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()), ) draw_messages_window(True) curses.curs_set(1) handle_resize(stdscr, False) def handle_ctrl_d() -> None: if ui_state.current_window == 0: if isinstance(ui_state.channel_list[ui_state.selected_channel], int): update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=True) # Shift notifications up to account for deleted item for i in range(len(ui_state.notifications)): if ui_state.notifications[i] > ui_state.selected_channel: ui_state.notifications[i] -= 1 del ui_state.channel_list[ui_state.selected_channel] ui_state.selected_channel = min(ui_state.selected_channel, len(ui_state.channel_list) - 1) select_channel(ui_state.selected_channel) draw_channel_list() draw_messages_window() if ui_state.current_window == 2: curses.curs_set(0) confirmation = get_list_input( t( "ui.confirm.remove_from_nodedb", default="Remove {name} from nodedb?", name=get_name_from_database(ui_state.node_list[ui_state.selected_node]), ), "No", ["Yes", "No"], ) if confirmation == "Yes": interface_state.interface.localNode.removeNode(ui_state.node_list[ui_state.selected_node]) # Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid? del interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]] # Convert to "!hex" representation that interface.nodes uses hexid = f"!{hex(ui_state.node_list[ui_state.selected_node])[2:]}" del interface_state.interface.nodes[hexid] ui_state.node_list.pop(ui_state.selected_node) draw_messages_window() draw_node_list() else: draw_messages_window() curses.curs_set(1) def handle_ctrl_fslash() -> None: """Handle Ctrl + / key events to search in the current window.""" if ui_state.current_window == 2 or ui_state.current_window == 0: search(ui_state.current_window) def handle_ctrl_f(stdscr: curses.window) -> None: """Handle Ctrl + F key events to toggle favorite status of the selected node.""" if ui_state.current_window == 2: selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]] curses.curs_set(0) if "isFavorite" not in selectedNode or selectedNode["isFavorite"] == False: confirmation = get_list_input( t( "ui.confirm.set_favorite", default="Set {name} as Favorite?", name=get_name_from_database(ui_state.node_list[ui_state.selected_node]), ), None, ["Yes", "No"], ) if confirmation == "Yes": interface_state.interface.localNode.setFavorite(ui_state.node_list[ui_state.selected_node]) # Maybe we shouldn't be modifying the nodedb, but maybe it should update itself interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isFavorite"] = True refresh_node_list() else: confirmation = get_list_input( t( "ui.confirm.remove_favorite", default="Remove {name} from Favorites?", name=get_name_from_database(ui_state.node_list[ui_state.selected_node]), ), None, ["Yes", "No"], ) if confirmation == "Yes": interface_state.interface.localNode.removeFavorite(ui_state.node_list[ui_state.selected_node]) # Maybe we shouldn't be modifying the nodedb, but maybe it should update itself interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isFavorite"] = False refresh_node_list() handle_resize(stdscr, False) def handle_ctlr_g(stdscr: curses.window) -> None: """Handle Ctrl + G key events to toggle ignored status of the selected node.""" if ui_state.current_window == 2: selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]] curses.curs_set(0) if "isIgnored" not in selectedNode or selectedNode["isIgnored"] == False: confirmation = get_list_input( t( "ui.confirm.set_ignored", default="Set {name} as Ignored?", name=get_name_from_database(ui_state.node_list[ui_state.selected_node]), ), "No", ["Yes", "No"], ) if confirmation == "Yes": interface_state.interface.localNode.setIgnored(ui_state.node_list[ui_state.selected_node]) interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isIgnored"] = True else: confirmation = get_list_input( t( "ui.confirm.remove_ignored", default="Remove {name} from Ignored?", name=get_name_from_database(ui_state.node_list[ui_state.selected_node]), ), "No", ["Yes", "No"], ) if confirmation == "Yes": interface_state.interface.localNode.removeIgnored(ui_state.node_list[ui_state.selected_node]) interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isIgnored"] = False handle_resize(stdscr, False) def draw_channel_list() -> None: """Update the channel list window and pad based on the current state.""" if ui_state.current_window != 0 and ui_state.single_pane_mode: return channel_pad.erase() win_width = channel_win.getmaxyx()[1] channel_pad.resize(max(1, len(ui_state.channel_list)), channel_win.getmaxyx()[1]) idx = 0 for channel in ui_state.channel_list: # Convert node number to long name if it's an integer if isinstance(channel, int): if is_chat_archived(channel): continue channel_name = get_name_from_database(channel, type="long") if channel_name is None: continue channel = channel_name # Determine whether to add the notification notification = " " + config.notification_symbol if idx in ui_state.notifications else "" # Truncate the channel name if it's too long to fit in the window truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 4) color = get_color("channel_list") if idx == ui_state.selected_channel: if ui_state.current_window == 0: color = get_color("channel_list", reverse=True) remove_notification(ui_state.selected_channel) else: color = get_color("channel_selected") channel_pad.addstr(idx, 1, truncated_channel, color) idx += 1 paint_frame(channel_win, selected=(ui_state.current_window == 0)) refresh_pad(0) draw_window_arrows(0) channel_win.refresh() def draw_messages_window(scroll_to_bottom: bool = False) -> None: """Update the messages window based on the selected channel and scroll position.""" if ui_state.current_window != 1 and ui_state.single_pane_mode: return messages_pad.erase() channel = ui_state.channel_list[ui_state.selected_channel] if channel in ui_state.all_messages: messages = ui_state.all_messages[channel] msg_line_count = 0 row = 0 for prefix, message in messages: full_message = normalize_message_text(f"{prefix}{message}") wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2) msg_line_count += len(wrapped_lines) messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1]) for line in wrapped_lines: if prefix.startswith("--"): color = get_color("timestamps") elif prefix.find(config.sent_message_prefix) != -1: color = get_color("tx_messages") else: color = get_color("rx_messages") messages_pad.addstr(row, 1, line, color) row += 1 paint_frame(messages_win, selected=(ui_state.current_window == 1)) visible_lines = get_msg_window_lines(messages_win, packetlog_win) if scroll_to_bottom: ui_state.selected_message = max(msg_line_count - visible_lines, 0) ui_state.start_index[1] = max(msg_line_count - visible_lines, 0) else: ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0) messages_win.refresh() refresh_pad(1) draw_packetlog_win() draw_window_arrows(1) messages_win.refresh() if ui_state.current_window == 4: menu_state.need_redraw = True def draw_node_list() -> None: """Update the nodes list window and pad based on the current state.""" global nodes_pad if ui_state.current_window != 2 and ui_state.single_pane_mode: return if nodes_pad is None: nodes_pad = curses.newpad(1, 1) try: nodes_pad.erase() box_width = nodes_win.getmaxyx()[1] nodes_pad.resize(len(ui_state.node_list) + 1, box_width) except Exception as e: logging.error(f"Error Drawing Nodes List: {e}") logging.error("Traceback: %s", traceback.format_exc()) for i, node_num in enumerate(ui_state.node_list): node = interface_state.interface.nodesByNum[node_num] secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"] status_icon = "🔐" if secure else "🔓" node_name = get_node_display_name(node_num, node) # Future node name custom formatting possible node_str = truncate_with_ellipsis(f"{status_icon} {node_name}", box_width - 4) nodes_pad.addstr(i, 1, node_str, get_node_row_color(i)) paint_frame(nodes_win, selected=(ui_state.current_window == 2)) nodes_win.refresh() refresh_pad(2) draw_window_arrows(2) nodes_win.refresh() # Restore cursor to input field entry_win.keypad(True) curses.curs_set(1) entry_win.refresh() if ui_state.current_window == 4: menu_state.need_redraw = True def select_channel(idx: int) -> None: """Select a channel by index and update the UI state accordingly.""" old_selected_channel = ui_state.selected_channel ui_state.selected_channel = max(0, min(idx, len(ui_state.channel_list) - 1)) draw_messages_window(True) # For now just re-draw channel list when clearing notifications, we can probably make this more efficient if ui_state.selected_channel in ui_state.notifications: remove_notification(ui_state.selected_channel) draw_channel_list() return move_main_highlight( old_idx=old_selected_channel, new_idx=ui_state.selected_channel, options=ui_state.channel_list, menu_win=channel_win, menu_pad=channel_pad, ui_state=ui_state, ) def scroll_channels(direction: int) -> None: """Scroll through the channel list by a given direction.""" new_selected_channel = ui_state.selected_channel + direction if new_selected_channel < 0: new_selected_channel = len(ui_state.channel_list) - 1 elif new_selected_channel >= len(ui_state.channel_list): new_selected_channel = 0 select_channel(new_selected_channel) def scroll_messages(direction: int) -> None: """Scroll through the messages in the current channel by a given direction.""" ui_state.selected_message += direction msg_line_count = messages_pad.getmaxyx()[0] ui_state.selected_message = max( 0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win)) ) max_index = msg_line_count - 1 visible_height = get_msg_window_lines(messages_win, packetlog_win) if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area ui_state.start_index[ui_state.current_window] = ui_state.selected_message elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area ui_state.start_index[ui_state.current_window] = ui_state.selected_message # Ensure start_index is within bounds ui_state.start_index[ui_state.current_window] = max( 0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1) ) messages_win.refresh() refresh_pad(1) draw_window_arrows(ui_state.current_window) def select_node(idx: int) -> None: """Select a node by index and update the UI state accordingly.""" old_selected_node = ui_state.selected_node ui_state.selected_node = max(0, min(idx, len(ui_state.node_list) - 1)) move_main_highlight( old_idx=old_selected_node, new_idx=ui_state.selected_node, options=ui_state.node_list, menu_win=nodes_win, menu_pad=nodes_pad, ui_state=ui_state, ) def scroll_nodes(direction: int) -> None: """Scroll through the node list by a given direction.""" new_selected_node = ui_state.selected_node + direction if new_selected_node < 0: new_selected_node = len(ui_state.node_list) - 1 elif new_selected_node >= len(ui_state.node_list): new_selected_node = 0 select_node(new_selected_node) def draw_packetlog_win() -> None: """Draw the packet log window with the latest packets.""" columns = [10, 10, 15, 30] span = 0 if ui_state.current_window != 1 and ui_state.single_pane_mode: return if ui_state.display_log: packetlog_win.erase() height, width = packetlog_win.getmaxyx() for column in columns[:-1]: span += column # Add headers headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}" packetlog_win.addstr( 1, 1, headers[: width - 2], get_color("log_header", underline=True) ) # Truncate headers if they exceed window width for i, packet in enumerate(reversed(ui_state.packet_buffer)): if i >= height - 3: # Skip if exceeds the window height break # Format each field from_id = get_name_from_database(packet["from"], "short").ljust(columns[0]) to_id = ( "BROADCAST".ljust(columns[1]) if str(packet["to"]) == "4294967295" else get_name_from_database(packet["to"], "short").ljust(columns[1]) ) if "decoded" in packet: port = str(packet["decoded"].get("portnum", "")).ljust(columns[2]) parsed_payload = parse_protobuf(packet) else: port = "NO KEY".ljust(columns[2]) parsed_payload = "NO KEY" # Combine and truncate if necessary logString = f"{from_id} {to_id} {port} {parsed_payload}" logString = logString[: width - 3] # Add to the window packetlog_win.addstr(i + 2, 1, logString, get_color("log")) paint_frame(packetlog_win, selected=False) # Restore cursor to input field entry_win.keypad(True) curses.curs_set(1) entry_win.refresh() def search(win: int) -> None: """Search for a node or channel based on user input.""" start_idx = ui_state.selected_node select_func = select_node if win == 0: start_idx = ui_state.selected_channel select_func = select_channel search_text = "" entry_win.erase() entry_win.timeout(-1) try: while True: draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input")) try: char = entry_win.get_wch() except curses.error: break if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)): break elif char == "\t": start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1 elif char in (curses.KEY_BACKSPACE, chr(127)): if search_text: search_text = search_text[:-1] y, x = entry_win.getyx() entry_win.move(y, x - 1) entry_win.addch(" ") # entry_win.move(y, x - 1) entry_win.erase() entry_win.refresh() elif isinstance(char, str): search_text += char search_text_caseless = search_text.casefold() l = ui_state.node_list if win == 2 else ui_state.channel_list for i, n in enumerate(l[start_idx:] + l[:start_idx]): if ( isinstance(n, int) and search_text_caseless in get_name_from_database(n, "long").casefold() or isinstance(n, int) and search_text_caseless in get_name_from_database(n, "short").casefold() or search_text_caseless in str(n).casefold() ): select_func((i + start_idx) % len(l)) break finally: entry_win.timeout(200) entry_win.erase() def refresh_pad(window: int) -> None: # If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad if ui_state.single_pane_mode and window != ui_state.current_window: return # Derive the target box and pad for the requested window win_height = channel_win.getmaxyx()[0] if window == 1: pad = messages_pad box = messages_win lines = get_msg_window_lines(messages_win, packetlog_win) start_index = ui_state.start_index[1] if ui_state.display_log: packetlog_win.box() packetlog_win.refresh() elif window == 2: pad = nodes_pad box = nodes_win lines = box.getmaxyx()[0] - 2 selected_item = ui_state.selected_node start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders else: pad = channel_pad box = channel_win lines = box.getmaxyx()[0] - 2 selected_item = ui_state.selected_channel start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders # Compute inner drawable area of the box box_y, box_x = box.getbegyx() box_h, box_w = box.getmaxyx() inner_h = max(0, box_h - 2) # minus borders inner_w = max(0, box_w - 2) if inner_h <= 0 or inner_w <= 0: return # Clamp lines to available inner height lines = max(0, min(lines, inner_h)) # Clamp start_index within the pad's height pad_h, pad_w = pad.getmaxyx() if pad_h <= 0: return start_index = max(0, min(start_index, max(0, pad_h - 1))) top = box_y + 1 left = box_x + 1 bottom = box_y + min(inner_h, lines) # inclusive right = box_x + min(inner_w, box_w - 2) if bottom < top or right < left: return draw_frame_title(box, get_window_title(window)) box.refresh() pad.refresh( start_index, 0, top, left, bottom, right, ) def add_notification(channel_number: int) -> None: if channel_number not in ui_state.notifications: ui_state.notifications.append(channel_number) def remove_notification(channel_number: int) -> None: if channel_number in ui_state.notifications: ui_state.notifications.remove(channel_number) def draw_text_field(win: curses.window, text: str, color: int) -> None: win.border() # Put a small hint in the border of the message entry field. # We key off the "Message:" prompt to avoid affecting other bordered fields. if isinstance(text, str) and text.startswith("Message:"): hint = " Ctrl+K Help " h, w = win.getmaxyx() x = max(2, w - len(hint) - 2) try: win.addstr(0, x, hint, get_color("commands")) except curses.error: pass # Draw the actual field text try: win.addstr(1, 1, text, color) except curses.error: pass def draw_centered_text_field(win: curses.window, text: str, y_offset: int, color: int) -> None: height, width = win.getmaxyx() x = (width - len(text)) // 2 y = (height // 2) + y_offset win.addstr(y, x, text, color) win.refresh() ================================================ FILE: contact/ui/control_ui.py ================================================ import base64 import curses import ipaddress import logging import os import sys from typing import List from meshtastic.protobuf import admin_pb2 from contact.utilities.save_to_radio import save_changes import contact.ui.default_config as config from contact.utilities.config_io import config_export, config_import from contact.utilities.interfaces import reconnect_interface from contact.utilities.control_utils import transform_menu_path from contact.utilities.i18n import t from contact.utilities.ini_utils import parse_ini_file from contact.utilities.input_handlers import ( get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input, ) from contact.ui.colors import get_color from contact.ui.dialog import dialog from contact.ui.menus import generate_menu_from_protobuf from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window from contact.ui.splash import draw_splash from contact.ui.user_config import json_editor from contact.utilities.arg_parser import setup_parser from contact.utilities.singleton import interface_state, menu_state # Setup Variables MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals save_option = "Save Changes" max_help_lines = 0 help_win = None sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"] # Compute the effective menu width for the current terminal def get_menu_width() -> int: # Leave at least 2 columns for borders; clamp to >= 20 for usability return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2)) sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"] # Get the parent directory of the script script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) # Paths # locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory translation_file = config.get_localisation_file(config.language) # config_folder = os.path.join(locals_dir, "node-configs") config_folder = os.path.abspath(config.node_configs_file_path) # Load translations field_mapping, help_text = parse_ini_file(translation_file) def _is_repeated_field(field_desc) -> bool: """Return True if the protobuf field is repeated. Protobuf 6.31.0 and later use an is_repeated property, while older versions compare against the label field. """ if hasattr(field_desc, "is_repeated"): return bool(field_desc.is_repeated) return field_desc.label == field_desc.LABEL_REPEATED def reload_translations() -> None: global translation_file, field_mapping, help_text translation_file = config.get_localisation_file(config.language) field_mapping, help_text = parse_ini_file(translation_file) def get_translated_header(menu_path: List[str]) -> str: if not menu_path: return "" transformed_path = transform_menu_path(menu_path) translated_parts = [] for idx, part in enumerate(menu_path): if idx == 0: translated_parts.append(field_mapping.get(part, part)) continue full_key = ".".join(transformed_path[:idx]) translated_parts.append(field_mapping.get(full_key, part)) return " > ".join(translated_parts) def display_menu() -> tuple[object, object]: # if help_win: # min_help_window_height = 6 # else: # min_help_window_height = 0 min_help_window_height = 6 num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0) # Determine the available height for the menu max_menu_height = curses.LINES menu_height = min(max_menu_height - min_help_window_height, num_items + 5) w = get_menu_width() start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2) start_x = (curses.COLS - w) // 2 # Calculate remaining space for help window global max_help_lines remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help menu_win = curses.newwin(menu_height, w, start_y, start_x) menu_win.erase() menu_win.bkgd(get_color("background")) menu_win.attrset(get_color("window_frame")) menu_win.border() menu_win.keypad(True) menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8) menu_pad.bkgd(get_color("background")) header = get_translated_header(menu_state.menu_path) if len(header) > w - 4: header = header[: w - 7] + "..." menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) transformed_path = transform_menu_path(menu_state.menu_path) for idx, option in enumerate(menu_state.current_menu): field_info = menu_state.current_menu[option] current_value = field_info[1] if isinstance(field_info, tuple) else "" full_key = ".".join(transformed_path + [option]) display_name = field_mapping.get(full_key, option) if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}: if isinstance(current_value, int): try: current_value = str( ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False)) ) except ipaddress.AddressValueError: pass elif isinstance(current_value, str) and current_value.isdigit(): try: current_value = str( ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False)) ) except ipaddress.AddressValueError: pass display_option = f"{display_name}"[: w // 2 - 2] display_value = f"{current_value}"[: w // 2 - 4] try: color = get_color( "settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == menu_state.selected_index), ) menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color) except curses.error: pass if menu_state.show_save_option: save_position = menu_height - 2 save_label = t("ui.save_changes", default=save_option) menu_win.addstr( save_position, (w - len(save_label)) // 2, save_label, get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))), ) # Draw help window with dynamically updated max_help_lines draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path) menu_win.refresh() menu_pad.refresh( menu_state.start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4, ) curses.curs_set(0) max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option) return menu_win, menu_pad def draw_help_window( menu_start_y: int, menu_start_x: int, menu_height: int, max_help_lines: int, transformed_path: List[str], ) -> None: global help_win if "help_win" not in globals(): help_win = None # Initialize if it does not exist selected_option = ( list(menu_state.current_menu.keys())[menu_state.selected_index] if menu_state.current_menu else None ) help_y = menu_start_y + menu_height # Use current terminal width for the help window width calculation help_win = update_help_window( help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_width(), help_y, menu_start_x ) def get_input_type_for_field(field) -> type: if field.type in (field.TYPE_INT32, field.TYPE_UINT32, field.TYPE_INT64): return int elif field.type in (field.TYPE_FLOAT, field.TYPE_DOUBLE): return float else: return str def reconnect_interface_with_splash(stdscr: object, interface: object) -> object: try: interface.close() except Exception: pass stdscr.clear() stdscr.refresh() draw_splash(stdscr) new_interface = reconnect_interface(setup_parser().parse_args()) interface_state.interface = new_interface redraw_main_ui_after_reconnect(stdscr) return new_interface def reconnect_after_admin_action(stdscr: object, interface: object, action, log_message: str) -> object: action() logging.info(log_message) return reconnect_interface_with_splash(stdscr, interface) def request_factory_reset(node: object, full: bool = False): try: return node.factoryReset(full=full) except TypeError as ex: field_name = "factory_reset_device" if full else "factory_reset_config" field = admin_pb2.AdminMessage.DESCRIPTOR.fields_by_name[field_name] if field.cpp_type != field.CPPTYPE_INT32: raise node.ensureSessionKey() message = admin_pb2.AdminMessage() setattr(message, field_name, 1) if node == node.iface.localNode: on_response = None else: on_response = node.onAckNak return node._sendAdmin(message, onResponse=on_response) def redraw_main_ui_after_reconnect(stdscr: object) -> None: try: from contact.ui import contact_ui from contact.utilities.utils import get_channels, refresh_node_list get_channels() refresh_node_list() contact_ui.handle_resize(stdscr, False) except Exception: logging.debug("Skipping main UI redraw after reconnect", exc_info=True) def settings_menu(stdscr: object, interface: object) -> None: curses.update_lines_cols() menu = generate_menu_from_protobuf(interface) menu_state.current_menu = menu["Main Menu"] menu_state.menu_path = ["Main Menu"] modified_settings = {} menu_state.need_redraw = True menu_state.show_save_option = False new_value_name = None while True: if menu_state.need_redraw: menu_state.need_redraw = False options = list(menu_state.current_menu.keys()) # Determine if save option should be shown path = menu_state.menu_path menu_state.show_save_option = ( (len(path) > 2 and ("Radio Settings" in path or "Module Settings" in path)) or (len(path) == 2 and "User Settings" in path) or (len(path) == 3 and "Channels" in path) ) # Display the menu menu_win, menu_pad = display_menu() if menu_win is None: continue # Skip if menu_win is not initialized menu_win.timeout(200) # wait up to 200 ms for a keypress (or less if key is pressed) key = menu_win.getch() if key == -1: continue max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1 # max_help_lines = 4 if key == curses.KEY_UP: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1 move_highlight( old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, help_win=help_win, help_text=help_text, max_help_lines=max_help_lines, ) elif key == curses.KEY_DOWN: old_selected_index = menu_state.selected_index menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1 move_highlight( old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, help_win=help_win, help_text=help_text, max_help_lines=max_help_lines, ) elif key == curses.KEY_RESIZE: menu_state.need_redraw = True curses.update_lines_cols() menu_win.erase() if help_win: help_win.erase() menu_win.refresh() if help_win: help_win.refresh() elif key == ord("\t") and menu_state.show_save_option: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index move_highlight( old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, help_win=help_win, help_text=help_text, max_help_lines=max_help_lines, ) elif key == curses.KEY_RIGHT or key == ord("\n"): menu_state.need_redraw = True menu_state.start_index.append(0) menu_win.erase() if help_win: help_win.erase() # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path)) menu_win.refresh() if help_win: help_win.refresh() if menu_state.show_save_option and menu_state.selected_index == len(options): reconnect_required = save_changes(interface, modified_settings, menu_state) modified_settings.clear() logging.info("Changes Saved") if reconnect_required: interface = reconnect_interface_with_splash(stdscr, interface) menu = generate_menu_from_protobuf(interface) if len(menu_state.menu_path) > 1: menu_state.menu_path.pop() menu_state.current_menu = menu["Main Menu"] for step in menu_state.menu_path[1:]: menu_state.current_menu = menu_state.current_menu.get(step, {}) menu_state.selected_index = 0 continue selected_option = options[menu_state.selected_index] if selected_option == "Exit": break elif selected_option == "Export Config File": filename = get_text_input( t("ui.prompt.config_filename", default="Enter a filename for the config file"), None, None, ) if not filename: logging.info("Export aborted: No filename provided.") menu_state.start_index.pop() continue # Go back to the menu if not filename.lower().endswith(".yaml"): filename += ".yaml" try: config_text = config_export(interface) yaml_file_path = os.path.join(config_folder, filename) if os.path.exists(yaml_file_path): overwrite = get_list_input( t( "ui.confirm.overwrite_file", default="{filename} already exists. Overwrite?", filename=filename, ), None, ["Yes", "No"], ) if overwrite == "No": logging.info("Export cancelled: User chose not to overwrite.") menu_state.start_index.pop() continue # Return to menu os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) with open(yaml_file_path, "w", encoding="utf-8") as file: file.write(config_text) logging.info(f"Config file saved to {yaml_file_path}") dialog(t("ui.dialog.config_saved_title", default="Config File Saved:"), yaml_file_path) menu_state.need_redraw = True menu_state.start_index.pop() continue except PermissionError: logging.error(f"Permission denied: Unable to write to {yaml_file_path}") except OSError as e: logging.error(f"OS error while saving config: {e}") except Exception as e: logging.error(f"Unexpected error: {e}") menu_state.start_index.pop() continue elif selected_option == "Load Config File": # Check if folder exists and is not empty if not os.path.exists(config_folder) or not any(os.listdir(config_folder)): dialog("", t("ui.dialog.no_config_files", default=" No config files found. Export a config first.")) menu_state.need_redraw = True continue # Return to menu file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))] # Ensure file_list is not empty before proceeding if not file_list: dialog("", t("ui.dialog.no_config_files", default=" No config files found. Export a config first.")) menu_state.need_redraw = True continue filename = get_list_input( t("ui.prompt.choose_config_file", default="Choose a config file"), None, file_list ) if filename: file_path = os.path.join(config_folder, filename) overwrite = get_list_input( t( "ui.confirm.load_config_file", default="Are you sure you want to load {filename}?", filename=filename, ), None, ["Yes", "No"], ) if overwrite == "Yes": config_import(interface, file_path) menu_state.start_index.pop() continue elif selected_option == "Config URL": current_value = interface.localNode.getURL() new_value = get_text_input( t( "ui.prompt.config_url_current", default="Config URL is currently: {value}", value=current_value, ), None, str, ) if new_value is not None: current_value = new_value overwrite = get_list_input( t("ui.confirm.load_config_url", default="Are you sure you want to load this config?"), None, ["Yes", "No"], ) if overwrite == "Yes": interface.localNode.setURL(new_value) logging.info(f"New Config URL sent to node") menu_state.start_index.pop() continue elif selected_option == "Reboot": confirmation = get_list_input( t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"] ) if confirmation == "Yes": interface = reconnect_after_admin_action( stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu" ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() continue elif selected_option == "Reset Node DB": confirmation = get_list_input( t("ui.confirm.reset_node_db", default="Are you sure you want to Reset Node DB?"), None, ["Yes", "No"], ) if confirmation == "Yes": interface = reconnect_after_admin_action( stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu" ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() continue elif selected_option == "Shutdown": confirmation = get_list_input( t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"] ) if confirmation == "Yes": interface.localNode.shutdown() logging.info(f"Node Shutdown Requested by menu") menu_state.start_index.pop() continue elif selected_option == "Factory Reset": confirmation = get_list_input( t("ui.confirm.factory_reset", default="Are you sure you want to Factory Reset?"), None, ["Yes", "No"], ) if confirmation == "Yes": interface = reconnect_after_admin_action( stdscr, interface, lambda: request_factory_reset(interface.localNode, full=True), "Factory Reset Requested by menu", ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() continue elif selected_option == "factory_reset_config": confirmation = get_list_input( t("ui.confirm.factory_reset_config", default="Are you sure you want to Factory Reset Config?"), None, ["Yes", "No"], ) if confirmation == "Yes": interface = reconnect_after_admin_action( stdscr, interface, lambda: request_factory_reset(interface.localNode, full=False), "Factory Reset Config Requested by menu", ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() continue elif selected_option == "App Settings": menu_win.clear() menu_win.refresh() menu_state.menu_path.append("App Settings") menu_state.menu_index.append(menu_state.selected_index) json_editor(stdscr, menu_state) # Open the App Settings menu reload_translations() menu_state.current_menu = menu["Main Menu"] menu_state.menu_path = ["Main Menu"] menu_state.start_index.pop() menu_state.selected_index = 4 continue field_info = menu_state.current_menu.get(selected_option) if isinstance(field_info, tuple): field, current_value = field_info # Transform the menu path to get the full key transformed_path = transform_menu_path(menu_state.menu_path) full_key = ".".join(transformed_path + [selected_option]) # Fetch human-readable name from field_mapping human_readable_name = field_mapping.get(full_key, selected_option) if selected_option in ["longName", "shortName", "isLicensed"]: if selected_option in ["longName", "shortName"]: new_value = get_text_input( f"{human_readable_name} is currently: {current_value}", selected_option, None ) new_value = current_value if new_value is None else new_value menu_state.current_menu[selected_option] = (field, new_value) elif selected_option == "isLicensed": new_value = get_list_input( f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"], ) new_value = new_value == "True" menu_state.current_menu[selected_option] = (field, new_value) for option, (field, value) in menu_state.current_menu.items(): modified_settings[option] = value menu_state.start_index.pop() elif selected_option in ["latitude", "longitude", "altitude"]: new_value = get_text_input( f"{human_readable_name} is currently: {current_value}", selected_option, float ) new_value = current_value if new_value is None else new_value menu_state.current_menu[selected_option] = (field, new_value) for option in ["latitude", "longitude", "altitude"]: if option in menu_state.current_menu: modified_settings[option] = menu_state.current_menu[option][1] menu_state.start_index.pop() elif selected_option == "admin_key": new_values = get_admin_key_input(current_value) new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values] menu_state.start_index.pop() elif field.type == 8: # Handle boolean type new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) if new_value == "Not Set": pass # Leave it as-is else: new_value = new_value == "True" or new_value is True menu_state.start_index.pop() elif _is_repeated_field(field): # Handle repeated field - Not currently used new_value = get_repeated_input(current_value) new_value = current_value if new_value is None else new_value.split(", ") menu_state.start_index.pop() elif field.enum_type: # Enum field enum_options = {v.name: v.number for v in field.enum_type.values} new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys())) new_value = enum_options.get(new_value_name, current_value) menu_state.start_index.pop() elif field.type == 7: # Field type 7 corresponds to FIXED32 new_value = get_fixed32_input(current_value) menu_state.start_index.pop() elif field.type == 13: # Field type 13 corresponds to UINT32 input_type = get_input_type_for_field(field) new_value = get_text_input( f"{human_readable_name} is currently: {current_value}", selected_option, input_type ) new_value = current_value if new_value is None else int(new_value) menu_state.start_index.pop() elif field.type == 2: # Field type 13 corresponds to INT64 input_type = get_input_type_for_field(field) new_value = get_text_input( f"{human_readable_name} is currently: {current_value}", selected_option, input_type ) new_value = current_value if new_value is None else float(new_value) menu_state.start_index.pop() else: # Handle other field types input_type = get_input_type_for_field(field) new_value = get_text_input( f"{human_readable_name} is currently: {current_value}", selected_option, input_type ) new_value = current_value if new_value is None else new_value menu_state.start_index.pop() for key in menu_state.menu_path[3:]: # Skip "Main Menu" modified_settings = modified_settings.setdefault(key, {}) # For comparison, normalize enum numbers to names compare_value = new_value if field and field.enum_type and isinstance(new_value, int): enum_value_descriptor = field.enum_type.values_by_number.get(new_value) if enum_value_descriptor: compare_value = enum_value_descriptor.name if compare_value != current_value: # Save the raw protobuf number, not the name modified_settings[selected_option] = new_value # Convert enum string to int if field and field.enum_type: enum_value_descriptor = field.enum_type.values_by_number.get(new_value) new_value = enum_value_descriptor.name if enum_value_descriptor else new_value menu_state.current_menu[selected_option] = (field, new_value) else: menu_state.current_menu = menu_state.current_menu[selected_option] menu_state.menu_path.append(selected_option) menu_state.menu_index.append(menu_state.selected_index) menu_state.selected_index = 0 elif key == curses.KEY_LEFT: # If we are at the main menu and there are unsaved changes, prompt to save if len(menu_state.menu_path) == 3 and modified_settings: current_section = menu_state.menu_path[-1] save_prompt = get_list_input( t( "ui.confirm.save_before_exit_section", default="You have unsaved changes in {section}. Save before exiting?", section=current_section, ), None, ["Yes", "No", "Cancel"], mandatory=True, ) if save_prompt == "Cancel": continue # Stay in the menu without doing anything elif save_prompt == "Yes": reconnect_required = save_changes(interface, modified_settings, menu_state) logging.info("Changes Saved") if reconnect_required: interface = reconnect_interface_with_splash(stdscr, interface) modified_settings.clear() menu = rebuild_menu_at_current_path(interface, menu_state) pass menu_state.need_redraw = True menu_win.erase() if help_win: help_win.erase() # max_help_lines = 4 # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path)) menu_win.refresh() if help_win: help_win.refresh() # if len(menu_state.menu_path) < 2: # modified_settings.clear() # Navigate back to the previous menu if len(menu_state.menu_path) > 1: menu_state.menu_path.pop() menu_state.current_menu = menu["Main Menu"] for step in menu_state.menu_path[1:]: menu_state.current_menu = menu_state.current_menu.get(step, {}) menu_state.selected_index = menu_state.menu_index.pop() menu_state.start_index.pop() elif key == 27: # Escape key menu_win.erase() menu_win.refresh() break def rebuild_menu_at_current_path(interface, menu_state): """Rebuild menus from the device and re-point current_menu to the same path.""" new_menu = generate_menu_from_protobuf(interface) cur = new_menu["Main Menu"] for step in menu_state.menu_path[1:]: cur = cur.get(step, {}) menu_state.current_menu = cur return new_menu def set_region(interface: object) -> None: node = interface.getNode("^local") device_config = node.localConfig lora_descriptor = device_config.lora.DESCRIPTOR # Get the enum mapping of region names to their numerical values region_enum = lora_descriptor.fields_by_name["region"].enum_type region_name_to_number = {v.name: v.number for v in region_enum.values} regions = list(region_name_to_number.keys()) new_region_name = get_list_input( t("ui.prompt.select_region", default="Select your region:"), "UNSET", regions ) # Convert region name to corresponding enum number new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found node.localConfig.lora.region = new_region_number node.writeConfig("lora") ================================================ FILE: contact/ui/default_config.py ================================================ import json import logging import os from typing import Dict, List, Optional from contact.ui.colors import setup_colors # Get the parent directory of the script script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) # To test writting to a non-writable directory, you can uncomment the following lines: # mkdir /tmp/test_nonwritable # chmod -w /tmp/test_nonwritable # parent_dir = "/tmp/test_nonwritable" def reload_config() -> None: loaded_config = initialize_config() assign_config_variables(loaded_config) setup_colors(reinit=True) def _is_writable_dir(path: str) -> bool: """ Return True if we can create & delete a temp file in `path`. """ if not os.path.isdir(path): return False test_path = os.path.join(path, ".perm_test_tmp") try: with open(test_path, "w", encoding="utf-8") as _tmp: _tmp.write("ok") os.remove(test_path) return True except OSError: return False def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client") -> str: """ Choose a writable directory for config artifacts. """ if _is_writable_dir(preferred_dir): return preferred_dir home = os.path.expanduser("~") fallback_dir = os.path.join(home, fallback_name) # Ensure the fallback exists. os.makedirs(fallback_dir, exist_ok=True) # If *that* still isn't writable, last-ditch: use a system temp dir. if not _is_writable_dir(fallback_dir): import tempfile fallback_dir = tempfile.mkdtemp(prefix="contact_client_") return fallback_dir # Allow overriding config root via environment variable config_root = os.getenv("CONTACT_CONFIG_ROOT") if config_root: if not _is_writable_dir(config_root): raise RuntimeError(f"CONTACT_CONFIG_ROOT={config_root} is not writable") else: config_root = _get_config_root(parent_dir) # Paths (derived from the chosen root) json_file_path = os.path.join(config_root, "config.json") log_file_path = os.path.join(config_root, "client.log") db_file_path = os.path.join(config_root, "client.db") node_configs_file_path = os.path.join(config_root, "node-configs/") localisations_dir = os.path.join(parent_dir, "localisations") def get_localisation_options(localisations_path: Optional[str] = None) -> List[str]: """ Return available localisation codes from the localisations folder. """ localisations_path = localisations_path or localisations_dir if not os.path.isdir(localisations_path): return [] options = [] for filename in os.listdir(localisations_path): if filename.startswith(".") or not filename.endswith(".ini"): continue options.append(os.path.splitext(filename)[0]) return sorted(options) def get_localisation_file(language: str, localisations_path: Optional[str] = None) -> str: """ Return a valid localisation file path, falling back to a default when missing. """ localisations_path = localisations_path or localisations_dir available = get_localisation_options(localisations_path) if not available: return os.path.join(localisations_path, "en.ini") normalized = (language or "").strip().lower() if normalized.endswith(".ini"): normalized = normalized[:-4] if normalized in available: return os.path.join(localisations_path, f"{normalized}.ini") fallback = "en" if "en" in available else available[0] return os.path.join(localisations_path, f"{fallback}.ini") def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str: """ Formats JSON with arrays on a single line while keeping other elements properly indented. """ def format_value(value: object, current_indent: int) -> str: if isinstance(value, dict): items = [] for key, val in value.items(): items.append(f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}') return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}" elif isinstance(value, list): return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]" else: return json.dumps(value, ensure_ascii=False) return format_value(data, indent) # Recursive function to check and update nested dictionaries def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool: updated = False for key, value in default.items(): if key not in actual: actual[key] = value updated = True elif isinstance(value, dict): # Recursively check nested dictionaries updated = update_dict(value, actual[key]) or updated return updated def initialize_config() -> Dict[str, object]: COLOR_CONFIG_DARK = { "default": ["white", "black"], "background": [" ", "black"], "splash_logo": ["green", "black"], "splash_text": ["white", "black"], "input": ["white", "black"], "node_list": ["white", "black"], "channel_list": ["white", "black"], "channel_selected": ["green", "black"], "rx_messages": ["cyan", "black"], "tx_messages": ["green", "black"], "timestamps": ["white", "black"], "commands": ["white", "black"], "window_frame": ["white", "black"], "window_frame_selected": ["green", "black"], "log_header": ["blue", "black"], "log": ["green", "black"], "settings_default": ["white", "black"], "settings_sensitive": ["red", "black"], "settings_save": ["green", "black"], "settings_breadcrumbs": ["white", "black"], "settings_warning": ["red", "black"], "settings_note": ["green", "black"], "node_favorite": ["green", "black"], "node_ignored": ["red", "black"], } COLOR_CONFIG_LIGHT = { "default": ["black", "white"], "background": [" ", "white"], "splash_logo": ["green", "white"], "splash_text": ["black", "white"], "input": ["black", "white"], "node_list": ["black", "white"], "channel_list": ["black", "white"], "channel_selected": ["green", "white"], "rx_messages": ["cyan", "white"], "tx_messages": ["green", "white"], "timestamps": ["black", "white"], "commands": ["black", "white"], "window_frame": ["black", "white"], "window_frame_selected": ["green", "white"], "log_header": ["black", "white"], "log": ["blue", "white"], "settings_default": ["black", "white"], "settings_sensitive": ["red", "white"], "settings_save": ["green", "white"], "settings_breadcrumbs": ["black", "white"], "settings_warning": ["red", "white"], "settings_note": ["green", "white"], "node_favorite": ["green", "white"], "node_ignored": ["red", "white"], } COLOR_CONFIG_GREEN = { "default": ["green", "black"], "background": [" ", "black"], "splash_logo": ["green", "black"], "splash_text": ["green", "black"], "input": ["green", "black"], "node_list": ["green", "black"], "channel_list": ["green", "black"], "channel_selected": ["cyan", "black"], "rx_messages": ["green", "black"], "tx_messages": ["green", "black"], "timestamps": ["green", "black"], "commands": ["green", "black"], "window_frame": ["green", "black"], "window_frame_selected": ["cyan", "black"], "log_header": ["green", "black"], "log": ["green", "black"], "settings_default": ["green", "black"], "settings_sensitive": ["green", "black"], "settings_save": ["green", "black"], "settings_breadcrumbs": ["green", "black"], "settings_save": ["green", "black"], "settings_breadcrumbs": ["green", "black"], "settings_warning": ["green", "black"], "settings_note": ["green", "black"], "node_favorite": ["cyan", "green"], "node_ignored": ["red", "black"], } available_languages = get_localisation_options() default_language = "en" if "en" in available_languages else (available_languages[0] if available_languages else "en") default_config_variables = { "channel_list_16ths": "3", "node_list_16ths": "5", "single_pane_mode": "False", "db_file_path": db_file_path, "log_file_path": log_file_path, "node_configs_file_path": node_configs_file_path, "language": default_language, "message_prefix": ">>", "sent_message_prefix": ">> Sent", "notification_symbol": "*", "notification_sound": "True", "ack_implicit_str": "[◌]", "ack_str": "[✓]", "nak_str": "[x]", "ack_unknown_str": "[…]", "node_sort": "lastHeard", "theme": "dark", "ping_bot": { "catch_words": "ping; test", "response_word": "Pong!", }, "COLOR_CONFIG_DARK": COLOR_CONFIG_DARK, "COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT, "COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN, } if not os.path.exists(json_file_path): with open(json_file_path, "w", encoding="utf-8") as json_file: formatted_json = format_json_single_line_arrays(default_config_variables) json_file.write(formatted_json) # Ensure all default variables exist in the JSON file with open(json_file_path, "r", encoding="utf-8") as json_file: loaded_config = json.load(json_file) # Check and add missing variables updated = update_dict(default_config_variables, loaded_config) # Update the JSON file if any variables were missing if updated: formatted_json = format_json_single_line_arrays(loaded_config) with open(json_file_path, "w", encoding="utf-8") as json_file: json_file.write(formatted_json) logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.") return loaded_config def assign_config_variables(loaded_config: Dict[str, object]) -> None: # Assign values to local variables global db_file_path, log_file_path, node_configs_file_path, message_prefix, sent_message_prefix global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str global node_list_16ths, channel_list_16ths, single_pane_mode global theme, COLOR_CONFIG, language global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word channel_list_16ths = loaded_config["channel_list_16ths"] node_list_16ths = loaded_config["node_list_16ths"] single_pane_mode = loaded_config["single_pane_mode"] db_file_path = loaded_config["db_file_path"] log_file_path = loaded_config["log_file_path"] node_configs_file_path = loaded_config.get("node_configs_file_path") language = loaded_config["language"] message_prefix = loaded_config["message_prefix"] sent_message_prefix = loaded_config["sent_message_prefix"] notification_symbol = loaded_config["notification_symbol"] notification_sound = loaded_config["notification_sound"] ack_implicit_str = loaded_config["ack_implicit_str"] ack_str = loaded_config["ack_str"] nak_str = loaded_config["nak_str"] ack_unknown_str = loaded_config["ack_unknown_str"] node_sort = loaded_config["node_sort"] theme = loaded_config["theme"] ping_bot = loaded_config.get("ping_bot", {}) ping_bot_catch_words = ping_bot.get("catch_words", "ping; test") ping_bot_response_word = ping_bot.get("response_word", "Pong!") if theme == "dark": COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"] elif theme == "light": COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"] elif theme == "green": COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"] # Call the function when the script is imported loaded_config = initialize_config() assign_config_variables(loaded_config) if __name__ == "__main__": logging.basicConfig( filename="default_config.log", level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL) format="%(asctime)s - %(levelname)s - %(message)s", ) print("\nLoaded Configuration:") print(f"Database File Path: {db_file_path}") print(f"Log File Path: {log_file_path}") print(f"Configs File Path: {node_configs_file_path}") print(f"Message Prefix: {message_prefix}") print(f"Sent Message Prefix: {sent_message_prefix}") print(f"Notification Symbol: {notification_symbol}") print(f"ACK Implicit String: {ack_implicit_str}") print(f"ACK String: {ack_str}") print(f"NAK String: {nak_str}") print(f"ACK Unknown String: {ack_unknown_str}") print(f"Color Config: {COLOR_CONFIG}") ================================================ FILE: contact/ui/dialog.py ================================================ import curses from contact.utilities.i18n import t_text from contact.ui.colors import get_color from contact.ui.nav_utils import draw_main_arrows, slice_to_width, text_width from contact.utilities.singleton import menu_state, ui_state def dialog(title: str, message: str) -> None: title = t_text(title) message = t_text(message) """Display a dialog with a title and message.""" previous_window = ui_state.current_window ui_state.current_window = 4 curses.update_lines_cols() height, width = curses.LINES, curses.COLS # Parse message into lines and calculate dimensions message_lines = message.splitlines() or [""] max_line_length = max(text_width(l) for l in message_lines) # Desired size dialog_width = max(text_width(title) + 4, max_line_length + 4) desired_height = len(message_lines) + 4 # Clamp dialog size to the screen (leave a 1-cell margin if possible) max_w = max(10, width - 2) max_h = max(6, height - 2) dialog_width = min(dialog_width, max_w) dialog_height = min(desired_height, max_h) x = max(0, (width - dialog_width) // 2) y = max(0, (height - dialog_height) // 2) # Ensure we have a start index slot for this dialog window id (4) # ui_state.start_index is used by draw_main_arrows() try: while len(ui_state.start_index) <= 4: ui_state.start_index.append(0) except Exception: # If start_index isn't list-like, fall back to an attribute if not hasattr(ui_state, "start_index"): ui_state.start_index = [0, 0, 0, 0, 0] def visible_message_rows() -> int: # Rows available for message text inside the border, excluding title row and OK row. # Layout: # row 0: title # rows 1..(dialog_height-3): message viewport (with arrows drawn on a subwindow) # row dialog_height-2: OK button # So message viewport height is dialog_height - 3 - 1 + 1 = dialog_height - 3 return max(1, dialog_height - 4) def draw_window(): win.erase() win.bkgd(get_color("background")) win.attrset(get_color("window_frame")) win.border(0) # Title try: win.addstr(0, 2, slice_to_width(title, max(0, dialog_width - 4)), get_color("settings_default")) except curses.error: pass # Message viewport viewport_h = visible_message_rows() start = ui_state.start_index[4] start = max(0, min(start, max(0, len(message_lines) - viewport_h))) ui_state.start_index[4] = start # Create a subwindow covering the message region so draw_main_arrows() doesn't collide with the OK row msg_win = win.derwin(viewport_h + 2, dialog_width - 2, 1, 1) msg_win.erase() for i in range(viewport_h): idx = start + i if idx >= len(message_lines): break line = message_lines[idx] trimmed = slice_to_width(line, max(0, dialog_width - 4)) msg_x = max(0, ((dialog_width - 2) - text_width(trimmed)) // 2) try: msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default")) except curses.error: pass # Draw arrows only when scrolling is needed if len(message_lines) > viewport_h: draw_main_arrows(msg_win, len(message_lines) - 1, window=4) else: # Clear arrow positions if not needed try: h, w = msg_win.getmaxyx() msg_win.addstr(1, w - 2, " ", get_color("settings_default")) msg_win.addstr(h - 2, w - 2, " ", get_color("settings_default")) except curses.error: pass msg_win.noutrefresh() # OK button ok_text = " Ok " try: win.addstr( dialog_height - 2, (dialog_width - len(ok_text)) // 2, ok_text, get_color("settings_default", reverse=True), ) except curses.error: pass win.noutrefresh() curses.doupdate() win = curses.newwin(dialog_height, dialog_width, y, x) win.keypad(True) draw_window() while True: win.timeout(200) char = win.getch() if menu_state.need_redraw: menu_state.need_redraw = False curses.update_lines_cols() height, width = curses.LINES, curses.COLS draw_window() # Close dialog ok_selected = True if char in (27, curses.KEY_LEFT): # Esc or Left arrow win.erase() win.refresh() ui_state.current_window = previous_window return if ok_selected and char in (curses.KEY_ENTER, 10, 13, 32): win.erase() win.refresh() ui_state.current_window = previous_window return if char == -1: continue # Scroll if the dialog is clipped vertically viewport_h = visible_message_rows() if len(message_lines) > viewport_h: start = ui_state.start_index[4] max_start = max(0, len(message_lines) - viewport_h) if char in (curses.KEY_UP, ord("k")): ui_state.start_index[4] = max(0, start - 1) draw_window() elif char in (curses.KEY_DOWN, ord("j")): ui_state.start_index[4] = min(max_start, start + 1) draw_window() elif char == curses.KEY_PPAGE: # Page up ui_state.start_index[4] = max(0, start - viewport_h) draw_window() elif char == curses.KEY_NPAGE: # Page down ui_state.start_index[4] = min(max_start, start + viewport_h) draw_window() ================================================ FILE: contact/ui/menus.py ================================================ import base64 import logging from collections import OrderedDict from typing import Any, Union, Dict from google.protobuf.message import Message from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2 def encode_if_bytes(value: Any) -> str: """Encode byte values to base64 string.""" if isinstance(value, bytes): return base64.b64encode(value).decode("utf-8") return value def extract_fields( message_instance: Message, current_config: Union[Message, Dict[str, Any], None] = None ) -> Dict[str, Any]: if isinstance(current_config, dict): # Handle dictionaries return {key: (None, encode_if_bytes(current_config.get(key, "Not Set"))) for key in current_config} if not hasattr(message_instance, "DESCRIPTOR"): return {} menu = {} fields = message_instance.DESCRIPTOR.fields for field in fields: skip_fields = [ "sessionkey", "ChannelSettings.channel_num", "ChannelSettings.id", "LoRaConfig.ignore_incoming", "DeviceUIConfig.version", ] if any(skip_field in field.full_name for skip_field in skip_fields): continue if field.message_type: # Nested message nested_instance = getattr(message_instance, field.name) nested_config = getattr(current_config, field.name, None) if current_config else None menu[field.name] = extract_fields(nested_instance, nested_config) elif field.enum_type: # Handle enum fields current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set" if isinstance(current_value, int): # If the value is a number, map it to its name enum_value = field.enum_type.values_by_number.get(current_value) if enum_value: # Check if the enum value exists current_value_name = f"{enum_value.name}" else: current_value_name = f"Unknown ({current_value})" menu[field.name] = (field, current_value_name) else: menu[field.name] = (field, current_value) # Non-integer values else: # Handle other field types current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set" menu[field.name] = (field, encode_if_bytes(current_value)) return menu def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]: """ Builds the full settings menu structure from the protobuf definitions. """ menu_structure = {"Main Menu": {}} # Add User Settings current_node_info = interface.getMyNodeInfo() if interface else None if current_node_info: current_user_config = current_node_info.get("user", None) if current_user_config and isinstance(current_user_config, dict): menu_structure["Main Menu"]["User Settings"] = { "longName": (None, current_user_config.get("longName", "Not Set")), "shortName": (None, current_user_config.get("shortName", "Not Set")), "isLicensed": (None, current_user_config.get("isLicensed", "False")), } else: logging.info("User settings not found in Node Info") menu_structure["Main Menu"]["User Settings"] = "No user settings available" else: logging.info("Node Info not available") menu_structure["Main Menu"]["User Settings"] = "Node Info not available" # Add Channels channel = channel_pb2.ChannelSettings() menu_structure["Main Menu"]["Channels"] = {} if interface: for i in range(8): current_channel = interface.localNode.getChannelByChannelIndex(i) if current_channel: channel_config = extract_fields(channel, current_channel.settings) menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config # Add Radio Settings radio = config_pb2.Config() current_radio_config = interface.localNode.localConfig if interface else None menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config) # Add Lat/Lon/Alt position_data = { "latitude": (None, current_node_info["position"].get("latitude", 0.0)), "longitude": (None, current_node_info["position"].get("longitude", 0.0)), "altitude": (None, current_node_info["position"].get("altitude", 0)), } existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {}) ordered_position_menu = OrderedDict() for key, value in existing_position_menu.items(): if key == "fixed_position": # Insert before or after a specific key ordered_position_menu[key] = value ordered_position_menu.update(position_data) # Insert Lat/Lon/Alt **right here** else: ordered_position_menu[key] = value menu_structure["Main Menu"]["Radio Settings"]["position"] = ordered_position_menu # Add Module Settings module = module_config_pb2.ModuleConfig() current_module_config = interface.localNode.moduleConfig if interface else None menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config) # Add App Settings menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"} # Additional settings options menu_structure["Main Menu"].update( { "Export Config File": None, "Load Config File": None, "Config URL": None, "Reboot": None, "Reset Node DB": None, "Shutdown": None, "Factory Reset": None, "factory_reset_config": None, "Exit": None, } ) return menu_structure ================================================ FILE: contact/ui/nav_utils.py ================================================ import curses import re from unicodedata import east_asian_width from contact.ui.colors import get_color from contact.utilities.i18n import t from contact.utilities.control_utils import transform_menu_path from typing import Any, Optional, List, Dict from contact.utilities.singleton import interface_state, ui_state def get_node_color(node_index: int, reverse: bool = False): node_num = ui_state.node_list[node_index] node = interface_state.interface.nodesByNum.get(node_num, {}) if node.get("isFavorite"): return get_color("node_favorite", reverse=reverse) elif node.get("isIgnored"): return get_color("node_ignored", reverse=reverse) return get_color("settings_default", reverse=reverse) # Aliases Segment = tuple[str, str, bool, bool] WrappedLine = List[Segment] sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] save_option = "Save Changes" def get_save_option_label() -> str: return t("ui.save_changes", default=save_option) def move_highlight( old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any ) -> None: show_save_option = None start_index = [0] help_text = None max_help_lines = 0 help_win = None if "help_win" in kwargs: help_win = kwargs["help_win"] if "menu_state" in kwargs: new_idx = kwargs["menu_state"].selected_index show_save_option = kwargs["menu_state"].show_save_option start_index = kwargs["menu_state"].start_index transformed_path = transform_menu_path(kwargs["menu_state"].menu_path) else: new_idx = kwargs["selected_index"] transformed_path = [] if "help_text" in kwargs: help_text = kwargs["help_text"] if "max_help_lines" in kwargs: max_help_lines = kwargs["max_help_lines"] if not options: return if old_idx == new_idx: # No-op return max_index = len(options) + (1 if show_save_option else 0) - 1 visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) # Adjust menu_state.start_index only when moving out of visible range if new_idx == max_index and show_save_option: pass elif new_idx < start_index[-1]: # Moving above the visible area start_index[-1] = new_idx elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area start_index[-1] = new_idx - visible_height # Ensure menu_state.start_index is within bounds start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1)) # Clear old selection if show_save_option and old_idx == max_index: win_h, win_w = menu_win.getmaxyx() save_label = get_save_option_label() menu_win.chgat( win_h - 2, (win_w - len(save_label)) // 2, len(save_label), get_color("settings_save") ) elif 0 <= old_idx < len(options): menu_pad.chgat( old_idx, 0, menu_pad.getmaxyx()[1], ( get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default") ), ) # Highlight new selection if show_save_option and new_idx == max_index: win_h, win_w = menu_win.getmaxyx() save_label = get_save_option_label() menu_win.chgat( win_h - 2, (win_w - len(save_label)) // 2, len(save_label), get_color("settings_save", reverse=True), ) elif 0 <= new_idx < len(options): menu_pad.chgat( new_idx, 0, menu_pad.getmaxyx()[1], ( get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse=True) ), ) menu_win.refresh() # Refresh pad only if scrolling is needed menu_pad.refresh( start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3 + visible_height, menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4, ) # Update help window only if help_text is populated selected_option = options[new_idx] if new_idx < len(options) else None help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] if help_win: win_h, win_w = menu_win.getmaxyx() help_win = update_help_window( help_win, help_text, transformed_path, selected_option, max_help_lines, win_w, help_y, menu_win.getbegyx()[1], ) draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option) def draw_arrows( win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool ) -> None: mi = max_index - (2 if show_save_option else 0) if visible_height < mi: if start_index[-1] > 0: win.addstr(3, 2, "▲", get_color("settings_default")) else: win.addstr(3, 2, " ", get_color("settings_default")) if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1): win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) else: win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) def update_help_window( help_win: object, # curses window or None help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], max_help_lines: int, width: int, help_y: int, help_x: int, ) -> object: # returns a curses window """Handles rendering the help window consistently.""" # Clamp target position and width to the current terminal size help_x = max(0, help_x) help_y = max(0, help_y) # Ensure requested width fits on screen from help_x max_w_from_x = max(1, curses.COLS - help_x) safe_width = min(width, max_w_from_x) # Always leave a minimal border area; enforce a minimum usable width of 3 safe_width = max(3, safe_width) wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_width, max_help_lines) help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border) # Re-clamp Y to keep the window visible if help_y + help_height > curses.LINES: help_y = max(0, curses.LINES - help_height) # If width would overflow the screen, shrink it if help_x + safe_width > curses.COLS: safe_width = max(3, curses.COLS - help_x) # Create or update the help window if help_win is None: help_win = curses.newwin(help_height, safe_width, help_y, help_x) else: help_win.erase() help_win.refresh() help_win.resize(help_height, safe_width) try: help_win.mvwin(help_y, help_x) except curses.error: # If moving fails due to edge conditions, pin to (0,0) as a fallback help_y = 0 help_x = 0 help_win.mvwin(help_y, help_x) help_win.bkgd(get_color("background")) help_win.attrset(get_color("window_frame")) help_win.border() for idx, line_segments in enumerate(wrapped_help): x_pos = 2 # Start after border for text, color, bold, underline in line_segments: try: attr = get_color(color, bold=bold, underline=underline) help_win.addstr(1 + idx, x_pos, text, attr) x_pos += len(text) except curses.error: pass # Prevent crashes help_win.refresh() return help_win def get_wrapped_help_text( help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int ) -> List[WrappedLine]: """Fetches and formats help text for display, ensuring it fits within the allowed lines.""" full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None help_content = help_text.get(full_help_key, t("ui.help.no_help", default="No help available.")) wrap_width = max(width - 6, 10) # Ensure a valid wrapping width # Color replacements color_mappings = { r"\[warning\](.*?)\[/warning\]": ("settings_warning", True, False), # Red for warnings r"\[note\](.*?)\[/note\]": ("settings_note", True, False), # Green for notes r"\[underline\](.*?)\[/underline\]": ("settings_default", False, True), # Underline r"\\033\[31m(.*?)\\033\[0m": ("settings_warning", True, False), # Red text r"\\033\[32m(.*?)\\033\[0m": ("settings_note", True, False), # Green text r"\\033\[4m(.*?)\\033\[0m": ("settings_default", False, True), # Underline } def extract_ansi_segments(text: str) -> List[Segment]: """Extracts and replaces ANSI color codes, ensuring spaces are preserved.""" matches = [] last_pos = 0 pattern_matches = [] # Find all matches and store their positions for pattern, (color, bold, underline) in color_mappings.items(): for match in re.finditer(pattern, text): pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline)) # Sort matches by start position to process sequentially pattern_matches.sort(key=lambda x: x[0]) for start, end, content, color, bold, underline in pattern_matches: # Preserve non-matching text including spaces if last_pos < start: segment = text[last_pos:start] matches.append((segment, "settings_default", False, False)) # Append the colored segment matches.append((content, color, bold, underline)) last_pos = end # Preserve any trailing text if last_pos < len(text): matches.append((text[last_pos:], "settings_default", False, False)) return matches def wrap_ansi_text(segments: List[Segment], wrap_width: int) -> List[WrappedLine]: """Wraps text while preserving ANSI formatting and spaces.""" wrapped_lines = [] line_buffer = [] line_length = 0 for text, color, bold, underline in segments: words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately for word in words: word_length = len(word) if line_length + word_length > wrap_width and word.strip(): # If the word (ignoring spaces) exceeds width, wrap the line wrapped_lines.append(line_buffer) line_buffer = [] line_length = 0 line_buffer.append((word, color, bold, underline)) line_length += word_length if line_buffer: wrapped_lines.append(line_buffer) return wrapped_lines raw_lines = help_content.split("\\n") # Preserve new lines wrapped_help = [] for raw_line in raw_lines: color_segments = extract_ansi_segments(raw_line) wrapped_segments = wrap_ansi_text(color_segments, wrap_width) wrapped_help.extend(wrapped_segments) pass # Trim and add ellipsis if needed if len(wrapped_help) > max_lines: wrapped_help = wrapped_help[:max_lines] wrapped_help[-1].append(("...", "settings_default", False, False)) return wrapped_help def text_width(text: str) -> int: return sum(2 if east_asian_width(c) in "FW" else 1 for c in text) def slice_to_width(text: str, max_width: int) -> str: if max_width <= 0: return "" width = 0 chars = [] for char in text: char_width = text_width(char) if width + char_width > max_width: break chars.append(char) width += char_width return "".join(chars) def pad_to_width(text: str, width: int) -> str: clipped = slice_to_width(text, width) return clipped + (" " * max(0, width - text_width(clipped))) def truncate_with_ellipsis(text: str, width: int) -> str: if width <= 0: return "" if text_width(text) <= width: return pad_to_width(text, width) if width == 1: return "…" return pad_to_width(slice_to_width(text, width - 1) + "…", width) def split_text_to_width_chunks(text: str, width: int) -> List[str]: if width <= 0: return [""] chunks = [] remaining = text while remaining: chunk = slice_to_width(remaining, width) if not chunk: break chunks.append(chunk) remaining = remaining[len(chunk) :] return chunks or [""] def wrap_text(text: str, wrap_width: int) -> List[str]: """Wraps text while preserving spaces and breaking long words.""" whitespace = "\t\n\x0b\x0c\r " whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" ")) text = text.translate(whitespace_trans) words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately wrapped_lines = [] line_buffer = "" line_length = 0 margin = 2 # Left and right margin wrap_width -= margin for word in words: word_length = text_width(word) if word_length > wrap_width: # Break long words if line_buffer: wrapped_lines.append(line_buffer.strip()) line_buffer = "" line_length = 0 wrapped_lines.extend(split_text_to_width_chunks(word, wrap_width)) continue if line_length + word_length > wrap_width and word.strip(): wrapped_lines.append(line_buffer.strip()) line_buffer = "" line_length = 0 line_buffer += word line_length += word_length if line_buffer: wrapped_lines.append(line_buffer.strip()) return wrapped_lines def move_main_highlight( old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object ) -> None: if old_idx == new_idx: # No-op return max_index = len(options) - 1 visible_height = menu_win.getmaxyx()[0] - 2 if new_idx < ui_state.start_index[ui_state.current_window]: # Moving above the visible area ui_state.start_index[ui_state.current_window] = new_idx elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height: # Moving below the visible area ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1 # Ensure start_index is within bounds ui_state.start_index[ui_state.current_window] = max( 0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1) ) highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height) if ui_state.current_window == 0: # hack to fix max_index max_index += 1 draw_main_arrows(menu_win, max_index, window=ui_state.current_window) menu_win.refresh() def highlight_line( menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int ) -> None: if ui_state.current_window == 0: color_old = ( get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list") ) color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True) menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old) menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new) elif ui_state.current_window == 2: menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx)) menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True)) menu_win.refresh() # Refresh pad only if scrolling is needed menu_pad.refresh( ui_state.start_index[ui_state.current_window], 0, menu_win.getbegyx()[0] + 1, menu_win.getbegyx()[1] + 1, menu_win.getbegyx()[0] + visible_height, menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3, ) def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None: height, width = win.getmaxyx() usable_height = height - 2 usable_width = width - 2 if window == 1 and ui_state.display_log: if log_height := kwargs.get("log_height"): usable_height -= log_height - 1 if usable_height < max_index: if ui_state.start_index[window] > 0: win.addstr(1, usable_width, "▲", get_color("settings_default")) else: win.addstr(1, usable_width, " ", get_color("settings_default")) if max_index - ui_state.start_index[window] - 1 >= usable_height: win.addstr(usable_height, usable_width, "▼", get_color("settings_default")) else: win.addstr(usable_height, usable_width, " ", get_color("settings_default")) else: win.addstr(1, usable_width, " ", get_color("settings_default")) win.addstr(usable_height, usable_width, " ", get_color("settings_default")) def get_msg_window_lines(messages_win, packetlog_win) -> None: packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0 return messages_win.getmaxyx()[0] - 2 - packetlog_height ================================================ FILE: contact/ui/splash.py ================================================ import curses from contact.ui.colors import get_color def draw_splash(stdscr: object) -> None: """Draw the splash screen with a logo and connecting message.""" curses.curs_set(0) stdscr.clear() stdscr.bkgd(get_color("background")) height, width = stdscr.getmaxyx() message_1 = "/ Λ" message_2 = "/ / \\" message_3 = "P W R D" message_4 = "connecting..." start_x = width // 2 - len(message_1) // 2 start_x2 = width // 2 - len(message_4) // 2 start_y = height // 2 - 1 stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True)) stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True)) stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True)) stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text")) stdscr.move(start_y + 5, start_x2) stdscr.attrset(get_color("window_frame")) stdscr.box() stdscr.refresh() curses.napms(500) ================================================ FILE: contact/ui/ui_state.py ================================================ from typing import Any, Union, List, Dict from dataclasses import dataclass, field @dataclass class MenuState: menu_index: List[int] = field(default_factory=list) start_index: List[int] = field(default_factory=lambda: [0]) selected_index: int = 0 current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict) menu_path: List[str] = field(default_factory=list) show_save_option: bool = False need_redraw: bool = False @dataclass class ChatUIState: display_log: bool = False channel_list: List[str] = field(default_factory=list) all_messages: Dict[str, List[str]] = field(default_factory=dict) notifications: List[str] = field(default_factory=list) packet_buffer: List[str] = field(default_factory=list) node_list: List[str] = field(default_factory=list) selected_channel: int = 0 selected_message: int = 0 selected_node: int = 0 current_window: int = 0 last_sent_time: float = 0.0 last_traceroute_time: float = 0.0 bot_mode_enabled: bool = False selected_index: int = 0 start_index: List[int] = field(default_factory=lambda: [0, 0, 0]) show_save_option: bool = False menu_path: List[str] = field(default_factory=list) single_pane_mode: bool = False redraw_channels: bool = False redraw_messages: bool = False redraw_nodes: bool = False redraw_packetlog: bool = False redraw_full_ui: bool = False scroll_messages_to_bottom: bool = False @dataclass class InterfaceState: interface: Any = None myNodeNum: int = 0 @dataclass class AppState: lock: Any = None ================================================ FILE: contact/ui/user_config.py ================================================ import os import json import curses from typing import Any, List, Dict, Optional from contact.ui.colors import get_color, setup_colors, COLOR_MAP import contact.ui.default_config as config from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window from contact.utilities.ini_utils import parse_ini_file from contact.utilities.input_handlers import get_list_input from contact.utilities.i18n import t from contact.utilities.singleton import menu_state MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals max_help_lines = 6 save_option = "Save Changes" translation_file = config.get_localisation_file(config.language) field_mapping, help_text = parse_ini_file(translation_file) translation_language = config.language def reload_translations(language: Optional[str] = None) -> None: global translation_file, field_mapping, help_text, translation_language target_language = language or config.language translation_file = config.get_localisation_file(target_language) field_mapping, help_text = parse_ini_file(translation_file) translation_language = target_language def get_app_settings_key(menu_path: List[str], selected_key: str) -> str: parts = ["app_settings"] for part in menu_path: if part in ("Main Menu", "App Settings"): continue parts.append(part) parts.append(selected_key) return ".".join(parts) def get_app_settings_path_parts(menu_path: List[str]) -> List[str]: parts = ["app_settings"] for part in menu_path: if part in ("Main Menu", "App Settings"): continue parts.append(part) return parts def lookup_app_settings_label(full_key: str, fallback: str) -> str: label = field_mapping.get(full_key) if label: return label parts = full_key.split(".") if len(parts) >= 2 and parts[1].startswith("COLOR_CONFIG_"): unified_key = ".".join([parts[0], "color_config"] + parts[2:]) return field_mapping.get(unified_key, fallback) return fallback def get_app_settings_help_path_parts(menu_path: List[str]) -> List[str]: parts = get_app_settings_path_parts(menu_path) if parts and parts[-1] in ("COLOR_CONFIG_DARK", "COLOR_CONFIG_LIGHT", "COLOR_CONFIG_GREEN"): parts[-1] = "color_config" return parts def get_app_settings_header(menu_path: List[str]) -> str: if not menu_path: return "" translated_parts = [] for idx, part in enumerate(menu_path): if idx == 0: translated_parts.append(field_mapping.get(part, part)) continue if part in ("Main Menu", "App Settings"): continue full_key = ".".join(get_app_settings_path_parts(menu_path[: idx + 1])) translated_parts.append(lookup_app_settings_label(full_key, part)) return " > ".join(translated_parts) # Compute an effective width that fits the current terminal def get_effective_width() -> int: # Leave space for borders; ensure a sane minimum return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2)) def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> List[str]: """ Allows the user to select a foreground and background color for a key. """ color_list = [" "] + list(COLOR_MAP.keys()) fg_color = get_list_input( t( "ui.prompt.select_foreground_color", default="Select Foreground Color for {label}", label=display_label, ), current_value[0], color_list, ) bg_color = get_list_input( t( "ui.prompt.select_background_color", default="Select Background Color for {label}", label=display_label, ), current_value[1], color_list, ) return [fg_color, bg_color] def edit_value(key: str, display_label: str, current_value: str) -> str: w = get_effective_width() height = 10 input_width = w - 16 # Allow space for "New Value: " start_y = (curses.LINES - height) // 2 start_x = max(0, (curses.COLS - w) // 2) # Create a centered window edit_win = curses.newwin(height, w, start_y, start_x) edit_win.bkgd(get_color("background")) edit_win.attrset(get_color("window_frame")) edit_win.border() # Display instructions edit_win.addstr( 1, 2, t("ui.label.editing", default="Editing {label}", label=display_label), get_color("settings_default", bold=True), ) edit_win.addstr(3, 2, t("ui.label.current_value", default="Current Value:"), get_color("settings_default")) wrap_width = w - 4 # Account for border and padding wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)] for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height edit_win.addstr(4 + i, 2, line, get_color("settings_default")) edit_win.refresh() # Handle theme selection dynamically if key == "theme": # Load theme names dynamically from the JSON theme_options = [ k.split("_", 2)[2].lower() for k in config.loaded_config.keys() if k.startswith("COLOR_CONFIG") ] return get_list_input( t("ui.prompt.select_value", default="Select {label}", label=display_label), current_value, theme_options, ) elif key == "language": language_options = config.get_localisation_options() if not language_options: return current_value return get_list_input( t("ui.prompt.select_value", default="Select {label}", label=display_label), current_value, language_options, ) elif key == "node_sort": sort_options = ["lastHeard", "name", "hops"] return get_list_input(display_label, current_value, sort_options) elif key == "notification_sound": sound_options = ["True", "False"] return get_list_input(display_label, current_value, sound_options) elif key == "single_pane_mode": sound_options = ["True", "False"] return get_list_input(display_label, current_value, sound_options) # Standard Input Mode (Scrollable) edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default")) curses.curs_set(1) scroll_offset = 0 # Determines which part of the text is visible user_input = "" input_position = (7, 13) # Tuple for row and column row, col = input_position # Unpack tuple while True: if menu_state.need_redraw: curses.update_lines_cols() menu_state.need_redraw = False # Re-create the window to fully reset state edit_win = curses.newwin(height, w, start_y, start_x) edit_win.timeout(200) edit_win.bkgd(get_color("background")) edit_win.attrset(get_color("window_frame")) edit_win.border() # Redraw static content edit_win.addstr( 1, 2, t("ui.label.editing", default="Editing {label}", label=display_label), get_color("settings_default", bold=True), ) edit_win.addstr( 3, 2, t("ui.label.current_value", default="Current Value:"), get_color("settings_default") ) for i, line in enumerate(wrapped_lines[:4]): edit_win.addstr(4 + i, 2, line, get_color("settings_default")) edit_win.addstr( 7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default") ) visible_text = user_input[scroll_offset : scroll_offset + input_width] edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) edit_win.addstr(row, col, visible_text, get_color("settings_default")) edit_win.refresh() edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) try: key = edit_win.get_wch() except curses.error: continue # window not ready — skip this loop if key in (chr(27), curses.KEY_LEFT): curses.curs_set(0) return current_value elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): break elif key in (curses.KEY_BACKSPACE, chr(127)): if user_input: user_input = user_input[:-1] if scroll_offset > 0 and len(user_input) < scroll_offset + input_width: scroll_offset -= 1 else: if isinstance(key, str): user_input += key else: user_input += chr(key) if len(user_input) > input_width: scroll_offset += 1 curses.curs_set(0) return user_input if user_input else current_value def display_menu() -> tuple[Any, Any, List[str]]: """ Render the configuration menu with a Save button directly added to the window. """ if translation_language != config.language: reload_translations() num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0) # Determine menu items based on the type of current_menu if isinstance(menu_state.current_menu, dict): options = list(menu_state.current_menu.keys()) elif isinstance(menu_state.current_menu, list): options = [f"[{i}]" for i in range(len(menu_state.current_menu))] else: options = [] # Fallback in case of unexpected data types # Calculate dynamic dimensions for the menu min_help_window_height = 6 max_menu_height = curses.LINES menu_height = min(max_menu_height - min_help_window_height, num_items + 5) num_items = len(options) w = get_effective_width() start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2) start_x = max(0, (curses.COLS - w) // 2) # Create the window menu_win = curses.newwin(menu_height, w, start_y, start_x) menu_win.erase() menu_win.bkgd(get_color("background")) menu_win.attrset(get_color("window_frame")) menu_win.border() menu_win.keypad(True) # Create the pad for scrolling menu_pad = curses.newpad(num_items + 1, w - 8) menu_pad.bkgd(get_color("background")) # Display the menu path header = get_app_settings_header(menu_state.menu_path) if len(header) > w - 4: header = header[: w - 7] + "..." menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) # Populate the pad with menu options for idx, key in enumerate(options): value = ( menu_state.current_menu[key] if isinstance(menu_state.current_menu, dict) else menu_state.current_menu[int(key.strip("[]"))] ) if isinstance(menu_state.current_menu, dict): full_key = get_app_settings_key(menu_state.menu_path, key) display_key = lookup_app_settings_label(full_key, key) else: display_key = key display_key = f"{display_key}"[: w // 2 - 2] if isinstance(value, dict) or (isinstance(value, list) and len(value) != 2): display_value = ">" else: display_value = f"{value}"[: w // 2 - 8] color = get_color("settings_default", reverse=(idx == menu_state.selected_index)) menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color) # Add Save button to the main window if menu_state.show_save_option: save_position = menu_height - 2 save_label = t("ui.save_changes", default=save_option) menu_win.addstr( save_position, (w - len(save_label)) // 2, save_label, get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))), ) menu_win.refresh() menu_pad.refresh( menu_state.start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4, ) max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option) # Draw help window below the menu global max_help_lines remaining_space = curses.LINES - (start_y + menu_height + 2) max_help_lines = max(remaining_space, 1) transformed_path = get_app_settings_help_path_parts(menu_state.menu_path) selected_option = ( options[min(menu_state.selected_index, len(options) - 1)] if options and menu_state.selected_index >= 0 else None ) help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] menu_state.help_win = update_help_window( menu_state.help_win, help_text, transformed_path, selected_option, max_help_lines, w, help_y, menu_win.getbegyx()[1], ) return menu_win, menu_pad, options def update_app_settings_help(menu_win: curses.window, options: List[str]) -> None: transformed_path = get_app_settings_help_path_parts(menu_state.menu_path) selected_option = options[menu_state.selected_index] if menu_state.selected_index < len(options) else None help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] menu_state.help_win = update_help_window( menu_state.help_win, help_text, transformed_path, selected_option, max_help_lines, menu_win.getmaxyx()[1], help_y, menu_win.getbegyx()[1], ) def json_editor(stdscr: curses.window, menu_state: Any) -> None: menu_state.selected_index = 0 # Track the selected option made_changes = False # Track if any changes were made script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) file_path = os.path.join(parent_dir, "config.json") menu_state.show_save_option = True # Always show the Save button menu_state.help_win = None menu_state.help_text = {} # Ensure the file exists if not os.path.exists(file_path): with open(file_path, "w") as f: json.dump({}, f) # Load JSON data with open(file_path, "r", encoding="utf-8") as f: original_data = json.load(f) data = original_data # Reference to the original data menu_state.current_menu = data # Track the current level of the menu # Render the menu menu_win, menu_pad, options = display_menu() update_app_settings_help(menu_win, options) menu_state.need_redraw = True while True: if menu_state.need_redraw: menu_state.need_redraw = False menu_win, menu_pad, options = display_menu() menu_win.refresh() update_app_settings_help(menu_win, options) max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1 menu_win.timeout(200) key = menu_win.getch() if key == curses.KEY_UP: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1 menu_state.help_win = move_highlight( old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines ) update_app_settings_help(menu_win, options) elif key == curses.KEY_DOWN: old_selected_index = menu_state.selected_index menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1 menu_state.help_win = move_highlight( old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines ) update_app_settings_help(menu_win, options) elif key == ord("\t") and menu_state.show_save_option: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index menu_state.help_win = move_highlight( old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines ) update_app_settings_help(menu_win, options) elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return menu_state.need_redraw = True menu_win.erase() menu_win.refresh() if menu_state.help_win: menu_state.help_win.erase() menu_state.help_win.refresh() if menu_state.selected_index < len(options): # Handle selection of a menu item selected_key = options[menu_state.selected_index] menu_state.menu_path.append(str(selected_key)) menu_state.start_index.append(0) menu_state.menu_index.append(menu_state.selected_index) # Handle nested data if isinstance(menu_state.current_menu, dict): if selected_key in menu_state.current_menu: selected_data = menu_state.current_menu[selected_key] else: continue # Skip invalid key elif isinstance(menu_state.current_menu, list): selected_data = menu_state.current_menu[int(selected_key.strip("[]"))] display_label = selected_key if isinstance(menu_state.current_menu, dict): path_for_label = ( menu_state.menu_path[:-1] if menu_state.menu_path and menu_state.menu_path[-1] == str(selected_key) else menu_state.menu_path ) full_key = get_app_settings_key(path_for_label, selected_key) display_label = lookup_app_settings_label(full_key, selected_key) if isinstance(selected_data, list) and len(selected_data) == 2: # Edit color pair old = selected_data new_value = edit_color_pair(selected_key, display_label, selected_data) menu_state.menu_path.pop() menu_state.start_index.pop() menu_state.menu_index.pop() menu_state.current_menu[selected_key] = new_value if new_value != old: made_changes = True elif isinstance(selected_data, (dict, list)): # Navigate into nested data menu_state.current_menu = selected_data menu_state.selected_index = 0 # Reset the selected index else: # General value editing old = selected_data new_value = edit_value(selected_key, display_label, selected_data) menu_state.menu_path.pop() menu_state.menu_index.pop() menu_state.start_index.pop() menu_state.current_menu[selected_key] = new_value menu_state.need_redraw = True if new_value != old: made_changes = True else: # Save button selected save_json(file_path, data) made_changes = False stdscr.refresh() # config.reload() # This isn't refreshing the file paths as expected break elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow menu_state.need_redraw = True menu_win.erase() menu_win.refresh() if menu_state.help_win: menu_state.help_win.erase() menu_state.help_win.refresh() # menu_state.selected_index = menu_state.menu_index[-1] # Navigate back in the menu if len(menu_state.menu_path) > 2: menu_state.menu_path.pop() menu_state.start_index.pop() menu_state.current_menu = data for path in menu_state.menu_path[2:]: menu_state.current_menu = ( menu_state.current_menu[path] if isinstance(menu_state.current_menu, dict) else menu_state.current_menu[int(path.strip("[]"))] ) else: # Exit the editor if made_changes: save_prompt = get_list_input( t("ui.confirm.save_before_exit", default="You have unsaved changes. Save before exiting?"), None, ["Yes", "No", "Cancel"], mandatory=True, ) if save_prompt == "Cancel": continue # Stay in the menu without doing anything elif save_prompt == "Yes": save_json(file_path, data) made_changes = False menu_win.clear() menu_win.refresh() break def save_json(file_path: str, data: Dict[str, Any]) -> None: formatted_json = config.format_json_single_line_arrays(data) with open(file_path, "w", encoding="utf-8") as f: f.write(formatted_json) config.reload_config() reload_translations(data.get("language")) def main(stdscr: curses.window) -> None: from contact.ui.ui_state import MenuState if len(menu_state.menu_path) == 0: menu_state.menu_path = ["App Settings"] # Initialize if not set curses.curs_set(0) stdscr.keypad(True) setup_colors() json_editor(stdscr, menu_state) if __name__ == "__main__": curses.wrapper(main) ================================================ FILE: contact/utilities/arg_parser.py ================================================ from argparse import ArgumentParser def setup_parser() -> ArgumentParser: parser = ArgumentParser( add_help=True, epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.", ) connOuter = parser.add_argument_group( "Connection", "Optional arguments to specify a device to connect to and how." ) conn = connOuter.add_mutually_exclusive_group() conn.add_argument( "--port", "--serial", "-s", help="The port to connect to via serial, e.g. `/dev/ttyUSB0`.", nargs="?", default=None, const=None, ) conn.add_argument( "--host", "--tcp", "-t", help="The hostname or IP address to connect to using TCP.", nargs="?", default=None, const="localhost", ) conn.add_argument( "--ble", "-b", help="The BLE device MAC address or name to connect to.", nargs="?", default=None, const="any" ) parser.add_argument( "--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true" ) parser.add_argument( "--demo-screenshot", help="Launch with a fake interface and seeded demo data for screenshots/testing.", action="store_true", ) return parser ================================================ FILE: contact/utilities/config_io.py ================================================ import yaml import logging import time from typing import List from google.protobuf.json_format import MessageToDict from meshtastic import mt_config from meshtastic.util import camel_to_snake, snake_to_camel, fromStr # defs are from meshtastic/python/main def _is_repeated_field(field_desc) -> bool: """Return True if the protobuf field is repeated. Protobuf 6.31.0+ exposes `is_repeated`, while older versions require checking `label == LABEL_REPEATED`. """ if hasattr(field_desc, "is_repeated"): return bool(field_desc.is_repeated) return field_desc.label == field_desc.LABEL_REPEATED def traverseConfig(config_root, config, interface_config) -> bool: """Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference""" snake_name = camel_to_snake(config_root) for pref in config: pref_name = f"{snake_name}.{pref}" if isinstance(config[pref], dict): traverseConfig(pref_name, config[pref], interface_config) else: setPref(interface_config, pref_name, config[pref]) return True def splitCompoundName(comp_name: str) -> List[str]: """Split compound (dot separated) preference name into parts""" name: List[str] = comp_name.split(".") if len(name) < 2: name[0] = comp_name name.append(comp_name) return name def setPref(config, comp_name, raw_val) -> bool: """Set a channel or preferences value""" name = splitCompoundName(comp_name) snake_name = camel_to_snake(name[-1]) camel_name = snake_to_camel(name[-1]) uni_name = camel_name if mt_config.camel_case else snake_name logging.debug(f"snake_name:{snake_name}") logging.debug(f"camel_name:{camel_name}") objDesc = config.DESCRIPTOR config_part = config config_type = objDesc.fields_by_name.get(name[0]) if config_type and config_type.message_type is not None: for name_part in name[1:-1]: part_snake_name = camel_to_snake((name_part)) config_part = getattr(config, config_type.name) config_type = config_type.message_type.fields_by_name.get(part_snake_name) pref = None if config_type and config_type.message_type is not None: pref = config_type.message_type.fields_by_name.get(snake_name) # Others like ChannelSettings are standalone elif config_type: pref = config_type if (not pref) or (not config_type): return False if isinstance(raw_val, str): val = fromStr(raw_val) else: val = raw_val logging.debug(f"valStr:{raw_val} val:{val}") if snake_name == "wifi_psk" and len(str(raw_val)) < 8: logging.info(f"Warning: network.wifi_psk must be 8 or more characters.") return False enumType = pref.enum_type # pylint: disable=C0123 if enumType and type(val) == str: # We've failed so far to convert this string into an enum, try to find it by reflection e = enumType.values_by_name.get(val) if e: val = e.number else: logging.info(f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it.") logging.info(f"Choices in sorted order are:") names = [] for f in enumType.values: # Note: We must use the value of the enum (regardless if camel or snake case) names.append(f"{f.name}") for temp_name in sorted(names): logging.info(f" {temp_name}") return False # repeating fields need to be handled with append, not setattr if not _is_repeated_field(pref): try: if config_type.message_type is not None: config_values = getattr(config_part, config_type.name) setattr(config_values, pref.name, val) else: setattr(config_part, snake_name, val) except TypeError: # The setter didn't like our arg type guess try again as a string config_values = getattr(config_part, config_type.name) setattr(config_values, pref.name, str(val)) elif type(val) == list: new_vals = [fromStr(x) for x in val] config_values = getattr(config, config_type.name) getattr(config_values, pref.name)[:] = new_vals else: config_values = getattr(config, config_type.name) if val == 0: # clear values logging.info(f"Clearing {pref.name} list") del getattr(config_values, pref.name)[:] else: logging.info(f"Adding '{raw_val}' to the {pref.name} list") cur_vals = [x for x in getattr(config_values, pref.name) if x not in [0, "", b""]] cur_vals.append(val) getattr(config_values, pref.name)[:] = cur_vals return True prefix = f"{'.'.join(name[0:-1])}." if config_type.message_type is not None else "" logging.info(f"Set {prefix}{uni_name} to {raw_val}") return True def config_import(interface, filename): with open(filename, encoding="utf8") as file: configuration = yaml.safe_load(file) closeNow = True interface.getNode("^local", False).beginSettingsTransaction() if "owner" in configuration: logging.info(f"Setting device owner to {configuration['owner']}") waitForAckNak = True interface.getNode("^local", False).setOwner(configuration["owner"]) time.sleep(0.5) if "owner_short" in configuration: logging.info(f"Setting device owner short to {configuration['owner_short']}") waitForAckNak = True interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"]) time.sleep(0.5) if "ownerShort" in configuration: logging.info(f"Setting device owner short to {configuration['ownerShort']}") waitForAckNak = True interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"]) time.sleep(0.5) if "channel_url" in configuration: logging.info(f"Setting channel url to {configuration['channel_url']}") interface.getNode("^local").setURL(configuration["channel_url"]) time.sleep(0.5) if "channelUrl" in configuration: logging.info(f"Setting channel url to {configuration['channelUrl']}") interface.getNode("^local").setURL(configuration["channelUrl"]) time.sleep(0.5) if "location" in configuration: alt = 0 lat = 0.0 lon = 0.0 localConfig = interface.localNode.localConfig if "alt" in configuration["location"]: alt = int(configuration["location"]["alt"] or 0) logging.info(f"Fixing altitude at {alt} meters") if "lat" in configuration["location"]: lat = float(configuration["location"]["lat"] or 0) logging.info(f"Fixing latitude at {lat} degrees") if "lon" in configuration["location"]: lon = float(configuration["location"]["lon"] or 0) logging.info(f"Fixing longitude at {lon} degrees") logging.info("Setting device position") interface.localNode.setFixedPosition(lat, lon, alt) time.sleep(0.5) if "config" in configuration: localConfig = interface.getNode("^local").localConfig for section in configuration["config"]: traverseConfig(section, configuration["config"][section], localConfig) interface.getNode("^local").writeConfig(camel_to_snake(section)) time.sleep(0.5) if "module_config" in configuration: moduleConfig = interface.getNode("^local").moduleConfig for section in configuration["module_config"]: traverseConfig( section, configuration["module_config"][section], moduleConfig, ) interface.getNode("^local").writeConfig(camel_to_snake(section)) time.sleep(0.5) interface.getNode("^local", False).commitSettingsTransaction() logging.info("Writing modified configuration to device") def config_export(interface) -> str: """used in --export-config""" configObj = {} owner = interface.getLongName() owner_short = interface.getShortName() channel_url = interface.localNode.getURL() myinfo = interface.getMyNodeInfo() pos = myinfo.get("position") lat = None lon = None alt = None if pos: lat = pos.get("latitude") lon = pos.get("longitude") alt = pos.get("altitude") if owner: configObj["owner"] = owner if owner_short: configObj["owner_short"] = owner_short if channel_url: if mt_config.camel_case: configObj["channelUrl"] = channel_url else: configObj["channel_url"] = channel_url # lat and lon don't make much sense without the other (so fill with 0s), and alt isn't meaningful without both if lat or lon: configObj["location"] = {"lat": lat or float(0), "lon": lon or float(0)} if alt: configObj["location"]["alt"] = alt config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below if config: # Convert inner keys to correct snake/camelCase prefs = {} for pref in config: if mt_config.camel_case: prefs[snake_to_camel(pref)] = config[pref] else: prefs[pref] = config[pref] # mark base64 encoded fields as such if pref == "security": if "privateKey" in prefs[pref]: prefs[pref]["privateKey"] = "base64:" + prefs[pref]["privateKey"] if "publicKey" in prefs[pref]: prefs[pref]["publicKey"] = "base64:" + prefs[pref]["publicKey"] if "adminKey" in prefs[pref]: for i in range(len(prefs[pref]["adminKey"])): prefs[pref]["adminKey"][i] = "base64:" + prefs[pref]["adminKey"][i] if mt_config.camel_case: configObj["config"] = config # Identical command here and 2 lines below? else: configObj["config"] = config module_config = MessageToDict(interface.localNode.moduleConfig) if module_config: # Convert inner keys to correct snake/camelCase prefs = {} for pref in module_config: if len(module_config[pref]) > 0: prefs[pref] = module_config[pref] if mt_config.camel_case: configObj["module_config"] = prefs else: configObj["module_config"] = prefs config_txt = "# start of Meshtastic configure yaml\n" # checkme - "config" (now changed to config_out) # was used as a string here and a Dictionary above config_txt += yaml.dump(configObj) # logging.info(config_txt) return config_txt ================================================ FILE: contact/utilities/control_utils.py ================================================ from typing import List import re def transform_menu_path(menu_path: List[str]) -> List[str]: """Applies path replacements and normalizes entries in the menu path.""" path_replacements = {"Radio Settings": "config", "Module Settings": "module"} transformed_path: List[str] = [] for part in menu_path[1:]: # Skip 'Main Menu' # Apply fixed replacements part = path_replacements.get(part, part) # Normalize entries like "Channel 1", "Channel 2", etc. if re.match(r"Channel\s+\d+", part, re.IGNORECASE): part = "channel" transformed_path.append(part) return transformed_path ================================================ FILE: contact/utilities/db_handler.py ================================================ import sqlite3 import time import logging from datetime import datetime from typing import Optional, Union, Dict from contact.utilities.utils import decimal_to_hex import contact.ui.default_config as config from contact.utilities.singleton import ui_state, interface_state def get_table_name(channel: str) -> str: # Construct the table name table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages" quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces return quoted_table_name def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]: """Save messages to the database, ensuring the table exists.""" try: quoted_table_name = get_table_name(channel) schema = """ user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT """ ensure_table_exists(quoted_table_name, schema) with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() timestamp = int(time.time()) # Insert the message insert_query = f""" INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type) VALUES (?, ?, ?, ?) """ db_cursor.execute(insert_query, (user_id, message_text, timestamp, None)) db_connection.commit() return timestamp except sqlite3.Error as e: logging.error(f"SQLite error in save_message_to_db: {e}") except Exception as e: logging.error(f"Unexpected error in save_message_to_db: {e}") def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None: try: with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() update_query = f""" UPDATE {get_table_name(channel)} SET ack_type = ? WHERE user_id = ? AND timestamp = ? AND message_text = ? """ db_cursor.execute(update_query, (ack, str(interface_state.myNodeNum), timestamp, message)) db_connection.commit() except sqlite3.Error as e: logging.error(f"SQLite error in update_ack_nak: {e}") except Exception as e: logging.error(f"Unexpected error in update_ack_nak: {e}") def load_messages_from_db() -> None: """Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list.""" try: with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?" db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",)) tables = [row[0] for row in db_cursor.fetchall()] # Iterate through each table and fetch its messages for table_name in tables: quoted_table_name = ( f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces ) table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")] if "ack_type" not in table_columns: update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT" db_cursor.execute(update_table_query) db_connection.commit() query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}" try: # Fetch all messages from the table db_cursor.execute(query) db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples # Extract the channel name from the table name channel = table_name.split("_")[1] # Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name) channel = int(channel) if channel.isdigit() else channel # Add the channel to ui_state.channel_list if not already present if channel not in ui_state.channel_list and not is_chat_archived(channel): ui_state.channel_list.append(channel) # Ensure the channel exists in ui_state.all_messages if channel not in ui_state.all_messages: ui_state.all_messages[channel] = [] # Add messages to ui_state.all_messages grouped by hourly timestamp hourly_messages = {} for row in db_messages: user_id, message, timestamp, ack_type = row # Only ack_type is allowed to be None if user_id is None or message is None or timestamp is None: logging.warning(f"Skipping row with NULL required field(s): {row}") continue hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00") if hour not in hourly_messages: hourly_messages[hour] = [] ack_str = config.ack_unknown_str if ack_type == "Implicit": ack_str = config.ack_implicit_str elif ack_type == "Ack": ack_str = config.ack_str elif ack_type == "Nak": ack_str = config.nak_str ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]") if user_id == str(interface_state.myNodeNum): sanitized_message = message.replace("\x00", "") formatted_message = ( f"{ts_str} {config.sent_message_prefix}{ack_str}: ", sanitized_message, ) else: sanitized_message = message.replace("\x00", "") formatted_message = ( f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ", sanitized_message, ) hourly_messages[hour].append(formatted_message) # Flatten the hourly messages into ui_state.all_messages[channel] for hour, messages in sorted(hourly_messages.items()): ui_state.all_messages[channel].append((f"-- {hour} --", "")) ui_state.all_messages[channel].extend(messages) except sqlite3.Error as e: logging.error(f"SQLite error while loading messages from table '{table_name}': {e}") except sqlite3.Error as e: logging.error(f"SQLite error in load_messages_from_db: {e}") def init_nodedb() -> None: """Initialize the node database and update it with nodes from the interface.""" try: if not interface_state.interface.nodes: return # No nodes to initialize ensure_node_table_exists() # Ensure the table exists before insertion nodes_snapshot = list(interface_state.interface.nodes.values()) # Insert or update all nodes for node in nodes_snapshot: update_node_info_in_db( user_id=node["num"], long_name=node["user"].get("longName", ""), short_name=node["user"].get("shortName", ""), hw_model=node["user"].get("hwModel", ""), is_licensed=node["user"].get("isLicensed", "0"), role=node["user"].get("role", "CLIENT"), public_key=node["user"].get("publicKey", ""), ) logging.info("Node database initialized successfully.") except sqlite3.Error as e: logging.error(f"SQLite error in init_nodedb: {e}") except Exception as e: logging.error(f"Unexpected error in init_nodedb: {e}") def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None: """Save nodeinfo unless that record is already there, updating if necessary.""" try: user_id = packet["from"] long_name = packet["decoded"]["user"]["longName"] short_name = packet["decoded"]["user"]["shortName"] hw_model = packet["decoded"]["user"]["hwModel"] is_licensed = packet["decoded"]["user"].get("isLicensed", "0") role = packet["decoded"]["user"].get("role", "CLIENT") public_key = packet["decoded"]["user"].get("publicKey", "") update_node_info_in_db(user_id, long_name, short_name, hw_model, is_licensed, role, public_key) except sqlite3.Error as e: logging.error(f"SQLite error in maybe_store_nodeinfo_in_db: {e}") except Exception as e: logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}") def update_node_info_in_db( user_id: Union[int, str], long_name: Optional[str] = None, short_name: Optional[str] = None, hw_model: Optional[str] = None, is_licensed: Optional[Union[str, int]] = None, role: Optional[str] = None, public_key: Optional[str] = None, chat_archived: Optional[int] = None, ) -> None: """Update or insert node information into the database, preserving unchanged fields.""" try: ensure_node_table_exists() # Ensure the table exists before any operation with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({table_name})")] if "chat_archived" not in table_columns: update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER" db_cursor.execute(update_table_query) db_connection.commit() # Fetch existing values to preserve unchanged fields db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,)) existing_record = db_cursor.fetchone() if existing_record: ( existing_long_name, existing_short_name, existing_hw_model, existing_is_licensed, existing_role, existing_public_key, existing_chat_archived, ) = existing_record[1:] long_name = long_name if long_name is not None else existing_long_name short_name = short_name if short_name is not None else existing_short_name hw_model = hw_model if hw_model is not None else existing_hw_model is_licensed = is_licensed if is_licensed is not None else existing_is_licensed role = role if role is not None else existing_role public_key = public_key if public_key is not None else existing_public_key chat_archived = chat_archived if chat_archived is not None else existing_chat_archived long_name = long_name if long_name is not None else "Meshtastic " + str(decimal_to_hex(user_id)[-4:]) short_name = short_name if short_name is not None else str(decimal_to_hex(user_id)[-4:]) hw_model = hw_model if hw_model is not None else "UNSET" is_licensed = is_licensed if is_licensed is not None else 0 role = role if role is not None else "CLIENT" public_key = public_key if public_key is not None else "" chat_archived = chat_archived if chat_archived is not None else 0 # Upsert logic upsert_query = f""" INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET long_name = excluded.long_name, short_name = excluded.short_name, hw_model = excluded.hw_model, is_licensed = excluded.is_licensed, role = excluded.role, public_key = excluded.public_key, chat_archived = excluded.chat_archived """ db_cursor.execute( upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived) ) db_connection.commit() except sqlite3.Error as e: logging.error(f"SQLite error in update_node_info_in_db: {e}") except Exception as e: logging.error(f"Unexpected error in update_node_info_in_db: {e}") def ensure_node_table_exists() -> None: """Ensure the node database table exists.""" table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote for safety schema = """ user_id TEXT PRIMARY KEY, long_name TEXT, short_name TEXT, hw_model TEXT, is_licensed TEXT, role TEXT, public_key TEXT, chat_archived INTEGER """ ensure_table_exists(table_name, schema) def ensure_table_exists(table_name: str, schema: str) -> None: """Ensure the given table exists in the database.""" try: with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})" db_cursor.execute(create_table_query) db_connection.commit() except sqlite3.Error as e: logging.error(f"SQLite error in ensure_table_exists({table_name}): {e}") except Exception as e: logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}") def get_name_from_database(user_id: int, type: str = "long") -> str: """ Retrieve a user's name (long or short) from the node database. :param user_id: The user ID to look up. :param type: "long" for long name, "short" for short name. :return: The retrieved name or the hex of the user id """ try: with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() # Construct table name table_name = f"{str(interface_state.myNodeNum)}_nodedb" nodeinfo_table = f'"{table_name}"' # Quote table name for safety # Determine the correct column to fetch column_name = "long_name" if type == "long" else "short_name" # Query the database query = f"SELECT {column_name} FROM {nodeinfo_table} WHERE user_id = ?" db_cursor.execute(query, (user_id,)) result = db_cursor.fetchone() return result[0] if result else decimal_to_hex(user_id) except sqlite3.Error as e: logging.error(f"SQLite error in get_name_from_database: {e}") return "Unknown" except Exception as e: logging.error(f"Unexpected error in get_name_from_database: {e}") return "Unknown" def is_chat_archived(user_id: int) -> int: try: with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() table_name = f"{str(interface_state.myNodeNum)}_nodedb" nodeinfo_table = f'"{table_name}"' query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?" db_cursor.execute(query, (user_id,)) result = db_cursor.fetchone() return result[0] if result else 0 except sqlite3.Error as e: logging.error(f"SQLite error in is_chat_archived: {e}") return "Unknown" except Exception as e: logging.error(f"Unexpected error in is_chat_archived: {e}") return "Unknown" ================================================ FILE: contact/utilities/demo_data.py ================================================ import os import sqlite3 import tempfile from dataclasses import dataclass from typing import Dict, List, Tuple, Union import contact.ui.default_config as config from contact.utilities.db_handler import get_table_name from contact.utilities.singleton import interface_state DEMO_DB_FILENAME = "contact_demo_client.db" DEMO_LOCAL_NODE_NUM = 0xC0DEC0DE DEMO_BASE_TIMESTAMP = 1738717200 # 2025-02-04 17:00:00 UTC DEMO_CHANNELS = ["MediumFast", "Another Channel"] @dataclass class DemoChannelSettings: name: str @dataclass class DemoChannel: role: bool settings: DemoChannelSettings @dataclass class DemoLoRaConfig: region: int = 1 modem_preset: int = 0 @dataclass class DemoLocalConfig: lora: DemoLoRaConfig class DemoLocalNode: def __init__(self, interface: "DemoInterface", channels: List[DemoChannel]) -> None: self._interface = interface self.channels = channels self.localConfig = DemoLocalConfig(lora=DemoLoRaConfig()) def setFavorite(self, node_num: int) -> None: self._interface.nodesByNum[node_num]["isFavorite"] = True def removeFavorite(self, node_num: int) -> None: self._interface.nodesByNum[node_num]["isFavorite"] = False def setIgnored(self, node_num: int) -> None: self._interface.nodesByNum[node_num]["isIgnored"] = True def removeIgnored(self, node_num: int) -> None: self._interface.nodesByNum[node_num]["isIgnored"] = False def removeNode(self, node_num: int) -> None: self._interface.nodesByNum.pop(node_num, None) class DemoInterface: def __init__(self, nodes: Dict[int, Dict[str, object]], channels: List[DemoChannel]) -> None: self.nodesByNum = nodes self.nodes = self.nodesByNum self.localNode = DemoLocalNode(self, channels) def getMyNodeInfo(self) -> Dict[str, int]: return {"num": DEMO_LOCAL_NODE_NUM} def getNode(self, selector: str) -> DemoLocalNode: if selector != "^local": raise KeyError(selector) return self.localNode def close(self) -> None: return def build_demo_interface() -> DemoInterface: channels = [DemoChannel(role=True, settings=DemoChannelSettings(name=name)) for name in DEMO_CHANNELS] nodes = { DEMO_LOCAL_NODE_NUM: _build_node( DEMO_LOCAL_NODE_NUM, "Meshtastic fb3c", "fb3c", hops=0, snr=13.7, last_heard_offset=5, battery=88, voltage=4.1, favorite=True, ), 0xA1000001: _build_node(0xA1000001, "KG7NDX-N2", "N2", hops=1, last_heard_offset=18, battery=79, voltage=4.0), 0xA1000002: _build_node(0xA1000002, "Satellite II Repeater", "SAT2", hops=2, last_heard_offset=31), 0xA1000003: _build_node(0xA1000003, "Search for Discord/Meshtastic", "DISC", hops=1, last_heard_offset=46), 0xA1000004: _build_node(0xA1000004, "K7EOK Mobile", "MOBL", hops=1, last_heard_offset=63, battery=52), 0xA1000005: _build_node(0xA1000005, "Turtle", "TRTL", hops=3, last_heard_offset=87), 0xA1000006: _build_node(0xA1000006, "CARS Trewvilliger Plaza", "CARS", hops=2, last_heard_offset=121), 0xA1000007: _build_node(0xA1000007, "No Hands!", "NHDS", hops=1, last_heard_offset=155), 0xA1000008: _build_node(0xA1000008, "McCutie", "MCCU", hops=2, last_heard_offset=211, ignored=True), 0xA1000009: _build_node(0xA1000009, "K1PDX", "K1PX", hops=2, last_heard_offset=267), 0xA100000A: _build_node(0xA100000A, "Arnold Creek", "ARND", hops=1, last_heard_offset=301), 0xA100000B: _build_node(0xA100000B, "Nansen", "NANS", hops=1, last_heard_offset=355), 0xA100000C: _build_node(0xA100000C, "Kodin 1", "KOD1", hops=2, last_heard_offset=402), 0xA100000D: _build_node(0xA100000D, "PH1", "PH1", hops=3, last_heard_offset=470), 0xA100000E: _build_node(0xA100000E, "Luna", "LUNA", hops=1, last_heard_offset=501), 0xA100000F: _build_node(0xA100000F, "sputnik1", "SPUT", hops=1, last_heard_offset=550), 0xA1000010: _build_node(0xA1000010, "K7EOK Maplewood West", "MAPL", hops=2, last_heard_offset=602), 0xA1000011: _build_node(0xA1000011, "KE7YVU 2", "YVU2", hops=2, last_heard_offset=655), 0xA1000012: _build_node(0xA1000012, "DNET", "DNET", hops=1, last_heard_offset=702), 0xA1000013: _build_node(0xA1000013, "Green Bluff", "GBLF", hops=1, last_heard_offset=780), 0xA1000014: _build_node(0xA1000014, "Council Crest Solar", "CCST", hops=2, last_heard_offset=830), 0xA1000015: _build_node(0xA1000015, "Meshtastic 61c7", "61c7", hops=1, last_heard_offset=901), 0xA1000016: _build_node(0xA1000016, "Bella", "BELA", hops=2, last_heard_offset=950), 0xA1000017: _build_node(0xA1000017, "Mojo Solar Base 4f12", "MOJO", hops=1, last_heard_offset=1010, favorite=True), } return DemoInterface(nodes=nodes, channels=channels) def configure_demo_database(base_dir: str = "") -> str: if not base_dir: base_dir = tempfile.mkdtemp(prefix="contact_demo_") os.makedirs(base_dir, exist_ok=True) db_path = os.path.join(base_dir, DEMO_DB_FILENAME) if os.path.exists(db_path): os.remove(db_path) config.db_file_path = db_path return db_path def seed_demo_messages() -> None: schema = """ user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT """ with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") cursor = db_connection.cursor() for channel_name, rows in _demo_messages().items(): table_name = get_table_name(channel_name) cursor.execute(f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})") cursor.executemany( f""" INSERT INTO {table_name} (user_id, message_text, timestamp, ack_type) VALUES (?, ?, ?, ?) """, rows, ) db_connection.commit() def _build_node( node_num: int, long_name: str, short_name: str, *, hops: int, last_heard_offset: int, snr: float = 0.0, battery: int = 0, voltage: float = 0.0, favorite: bool = False, ignored: bool = False, ) -> Dict[str, object]: node = { "num": node_num, "user": { "longName": long_name, "shortName": short_name, "hwModel": "TBEAM", "role": "CLIENT", "publicKey": f"pk-{node_num:08x}", "isLicensed": True, }, "lastHeard": DEMO_BASE_TIMESTAMP + 3600 - last_heard_offset, "hopsAway": hops, "isFavorite": favorite, "isIgnored": ignored, } if snr: node["snr"] = snr if battery: node["deviceMetrics"] = { "batteryLevel": battery, "voltage": voltage or 4.0, "uptimeSeconds": 86400 + node_num % 10000, "channelUtilization": 12.5 + (node_num % 7), "airUtilTx": 4.5 + (node_num % 5), } if node_num % 3 == 0: node["position"] = { "latitude": 45.5231 + ((node_num % 50) * 0.0001), "longitude": -122.6765 - ((node_num % 50) * 0.0001), "altitude": 85 + (node_num % 20), } return node def _demo_messages() -> Dict[Union[str, int], List[Tuple[str, str, int, Union[str, None]]]]: return { "MediumFast": [ (str(DEMO_LOCAL_NODE_NUM), "Help, I'm stuck in a ditch!", DEMO_BASE_TIMESTAMP + 45, "Ack"), ("2701131778", "Do you require a alpinist?", DEMO_BASE_TIMESTAMP + 80, None), (str(DEMO_LOCAL_NODE_NUM), "I don't know what that is.", DEMO_BASE_TIMESTAMP + 104, "Implicit"), ], "Another Channel": [ ("2701131788", "Weather is holding for the summit push.", DEMO_BASE_TIMESTAMP + 220, None), (str(DEMO_LOCAL_NODE_NUM), "Copy that. Keep me posted.", DEMO_BASE_TIMESTAMP + 260, "Ack"), ], 2701131788: [ ("2701131788", "Ping me when you are back at the trailhead.", DEMO_BASE_TIMESTAMP + 330, None), (str(DEMO_LOCAL_NODE_NUM), "Will do.", DEMO_BASE_TIMESTAMP + 350, "Ack"), ], } ================================================ FILE: contact/utilities/emoji_utils.py ================================================ """Helpers for normalizing emoji sequences in width-sensitive message rendering.""" # Strip zero-width and presentation modifiers that make terminal cell width inconsistent. EMOJI_MODIFIER_REPLACEMENTS = { "\u200d": "", "\u20e3": "", "\ufe0e": "", "\ufe0f": "", "\U0001F3FB": "", "\U0001F3FC": "", "\U0001F3FD": "", "\U0001F3FE": "", "\U0001F3FF": "", } _EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS) _REGIONAL_INDICATOR_START = ord("\U0001F1E6") _REGIONAL_INDICATOR_END = ord("\U0001F1FF") def _regional_indicator_to_letter(char: str) -> str: return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START) def _normalize_flag_emoji(text: str) -> str: """Convert flag emoji built from regional indicators into ASCII country codes.""" normalized = [] index = 0 while index < len(text): current = text[index] current_ord = ord(current) if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text): next_char = text[index + 1] next_ord = ord(next_char) if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END: normalized.append(_regional_indicator_to_letter(current)) normalized.append(_regional_indicator_to_letter(next_char)) index += 2 continue normalized.append(current) index += 1 return "".join(normalized) def normalize_message_text(text: str) -> str: """Strip modifiers and rewrite flag emoji into stable terminal-friendly text.""" if not text: return text return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION)) ================================================ FILE: contact/utilities/i18n.py ================================================ from typing import Optional import contact.ui.default_config as config from contact.utilities.ini_utils import parse_ini_file _translations = {} _language = None def _load_translations() -> None: global _translations, _language language = config.language if _translations and _language == language: return translation_file = config.get_localisation_file(language) _translations, _ = parse_ini_file(translation_file) _language = language def t(key: str, default: Optional[str] = None, **kwargs: object) -> str: _load_translations() text = _translations.get(key, default if default is not None else key) try: return text.format(**kwargs) except Exception: return text def t_text(text: str, **kwargs: object) -> str: return t(text, default=text, **kwargs) ================================================ FILE: contact/utilities/ini_utils.py ================================================ from typing import Optional, Tuple, Dict from contact.utilities import i18n def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]: """Parses an INI file and returns a mapping of keys to human-readable names and help text.""" try: default_help = i18n.t("ui.help.no_help", default="No help available.") except Exception: default_help = "No help available." field_mapping: Dict[str, str] = {} help_text: Dict[str, str] = {} current_section: Optional[str] = None with open(ini_file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() # Skip empty lines and comments if not line or line.startswith(";") or line.startswith("#"): continue # Handle sections like [config.device] if line.startswith("[") and line.endswith("]"): current_section = line[1:-1] continue # Parse lines like: key, "Human-readable name", "helptext" parts = [p.strip().strip('"') for p in line.split(",", 2)] if len(parts) >= 2: key = parts[0] # If key is 'title', map directly to the section if key == "title": full_key = current_section else: full_key = f"{current_section}.{key}" if current_section else key # Use the provided human-readable name or fallback to key human_readable_name = parts[1] if parts[1] else key field_mapping[full_key] = human_readable_name # Handle help text or default help = parts[2] if len(parts) == 3 and parts[2] else default_help help_text[full_key] = help else: # Handle cases with only the key present full_key = f"{current_section}.{key}" if current_section else key field_mapping[full_key] = key help_text[full_key] = default_help return field_mapping, help_text ================================================ FILE: contact/utilities/input_handlers.py ================================================ import base64 import binascii import curses import ipaddress from typing import Any, Optional, List from contact.ui.colors import get_color from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text from contact.ui.dialog import dialog from contact.utilities.i18n import t, t_text from contact.utilities.validation_rules import get_validation_for from contact.utilities.singleton import menu_state # Dialogs should be at most 80 cols, but shrink on small terminals MAX_DIALOG_WIDTH = 80 MIN_DIALOG_WIDTH = 20 def get_dialog_width() -> int: # Leave 2 columns for borders and clamp to a sane minimum try: return max(MIN_DIALOG_WIDTH, min(MAX_DIALOG_WIDTH, curses.COLS - 2)) except Exception: # Fallback if curses not ready yet return MAX_DIALOG_WIDTH def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None: """Displays an invalid input message in the given window and redraws if needed.""" cursor_y, cursor_x = window.getyx() curses.curs_set(0) dialog(t("ui.dialog.invalid_input", default="Invalid Input"), t_text(message)) if redraw_func: redraw_func() # Redraw the original window content that got obscured else: window.refresh() window.move(cursor_y, cursor_x) curses.curs_set(1) def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]: """Handles user input with wrapped text for long prompts.""" def redraw_input_win(): """Redraw the input window with the current prompt and user input.""" input_win.erase() input_win.border() row = 1 for line in wrapped_prompt: input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True)) row += 1 if row >= height - 3: break input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) input_win.addstr(row + 1, col_start, user_input[:first_line_width], get_color("settings_default")) for i, line in enumerate(wrap_text(user_input[first_line_width:], wrap_width=input_width)): if row + 2 + i < height - 1: input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default")) input_win.refresh() height = 8 width = get_dialog_width() margin = 2 # Left and right margin input_width = width - (2 * margin) # Space available for text max_input_rows = height - 4 # Space for input start_y = max(0, (curses.LINES - height) // 2) start_x = max(0, (curses.COLS - width) // 2) input_win = curses.newwin(height, width, start_y, start_x) input_win.timeout(200) input_win.bkgd(get_color("background")) input_win.attrset(get_color("window_frame")) input_win.border() prompt = t_text(prompt) # Wrap the prompt text wrapped_prompt = wrap_text(prompt, wrap_width=input_width) row = 1 for line in wrapped_prompt: input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True)) row += 1 if row >= height - 3: # Prevent overflow break prompt_text = t("ui.prompt.enter_new_value", default="Enter new value: ") input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) input_win.refresh() curses.curs_set(1) min_value = 0 max_value = 4294967295 min_length = 0 max_length = None if selected_config is not None: validation = get_validation_for(selected_config) or {} min_value = validation.get("min_value", 0) max_value = validation.get("max_value", 4294967295) min_length = validation.get("min_length", 0) max_length = validation.get("max_length") user_input = "" col_start = margin + len(prompt_text) first_line_width = input_width - len(prompt_text) while True: if menu_state.need_redraw: menu_state.need_redraw = False redraw_input_win() try: key = input_win.get_wch() except curses.error: continue if key == chr(27) or key == curses.KEY_LEFT: input_win.erase() input_win.refresh() curses.curs_set(0) menu_state.need_redraw = True return None elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): menu_state.need_redraw = True if not user_input.strip(): invalid_input( input_win, t("ui.error.value_empty", default="Value cannot be empty."), redraw_func=redraw_input_win, ) continue length = len(user_input) if min_length == max_length and max_length is not None: if length != min_length: invalid_input( input_win, t("ui.error.value_exact_length", default="Value must be exactly {length} characters long.", length=min_length), redraw_func=redraw_input_win, ) continue else: if length < min_length: invalid_input( input_win, t("ui.error.value_min_length", default="Value must be at least {length} characters long.", length=min_length), redraw_func=redraw_input_win, ) continue if max_length is not None and length > max_length: invalid_input( input_win, t("ui.error.value_max_length", default="Value must be no more than {length} characters long.", length=max_length), redraw_func=redraw_input_win, ) continue if input_type is int: if not user_input.isdigit(): invalid_input( input_win, t("ui.error.digits_only", default="Only numeric digits (0-9) allowed."), redraw_func=redraw_input_win, ) continue int_val = int(user_input) if not (min_value <= int_val <= max_value): invalid_input( input_win, t( "ui.error.number_range", default="Enter a number between {min_value} and {max_value}.", min_value=min_value, max_value=max_value, ), redraw_func=redraw_input_win, ) continue curses.curs_set(0) return int_val elif input_type is float: try: float_val = float(user_input) if not (min_value <= float_val <= max_value): invalid_input( input_win, t( "ui.error.number_range", default="Enter a number between {min_value} and {max_value}.", min_value=min_value, max_value=max_value, ), redraw_func=redraw_input_win, ) continue except ValueError: invalid_input( input_win, t("ui.error.float_invalid", default="Must be a valid floating point number."), redraw_func=redraw_input_win, ) continue else: curses.curs_set(0) return float_val else: break elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace if user_input: user_input = user_input[:-1] # Remove last character elif max_length is None or len(user_input) < max_length: try: char = chr(key) if not isinstance(key, str) else key if input_type is int: if char.isdigit() or (char == "-" and len(user_input) == 0): user_input += char elif input_type is float: if ( char.isdigit() or (char == "." and "." not in user_input) or (char == "-" and len(user_input) == 0) ): user_input += char else: user_input += char except ValueError: pass # Ignore invalid input # First line must be manually handled before using wrap_text() first_line = user_input[:first_line_width] # Cut to max first line width remaining_text = user_input[first_line_width:] # Remaining text for wrapping wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else [] # Clear only the input area (without touching prompt text) for i in range(max_input_rows): if row + 1 + i < height - 1: input_win.addstr(row + 1 + i, margin, " " * input_width, get_color("settings_default")) # Redraw the prompt text so it never disappears input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) # Redraw wrapped input input_win.addstr(row + 1, col_start, first_line, get_color("settings_default")) # First line next to prompt for i, line in enumerate(wrapped_lines): if row + 2 + i < height - 1: input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default")) input_win.refresh() curses.curs_set(0) input_win.erase() input_win.refresh() return user_input.strip() def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]: """Handles user input for editing up to 3 Admin Keys in Base64 format.""" def to_base64(byte_strings): """Convert byte values to Base64-encoded strings.""" return [base64.b64encode(b).decode() for b in byte_strings] def is_valid_base64(s): """Check if a string is valid Base64 or blank.""" if s == "": return True try: decoded = base64.b64decode(s, validate=True) return len(decoded) == 32 # Ensure it's exactly 32 bytes except (binascii.Error, ValueError): return False cvalue = to_base64(current_value) # Convert current values to Base64 height = 9 width = get_dialog_width() start_y = max(0, (curses.LINES - height) // 2) start_x = max(0, (curses.COLS - width) // 2) admin_key_win = curses.newwin(height, width, start_y, start_x) admin_key_win.timeout(200) admin_key_win.bkgd(get_color("background")) admin_key_win.attrset(get_color("window_frame")) admin_key_win.keypad(True) # Enable keypad for special keys curses.echo() curses.curs_set(1) # Editable list of values (max 3 values) user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields cursor_pos = 0 # Track which value is being edited invalid_input = "" while True: admin_key_win.erase() admin_key_win.border() admin_key_win.addstr( 1, 2, t("ui.prompt.edit_admin_keys", default="Edit up to 3 Admin Keys:"), get_color("settings_default", bold=True), ) # Display current values, allowing editing for i, line in enumerate(user_values): prefix = "→ " if i == cursor_pos else " " # Highlight the current line admin_key_win.addstr( 3 + i, 2, f"{prefix}{t('ui.label.admin_key', default='Admin Key')} {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)), ) admin_key_win.addstr(3 + i, 18, line) # Align text for easier editing # Move cursor to the correct position inside the field curses.curs_set(1) admin_key_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text # Show error message if needed if invalid_input: admin_key_win.addstr(7, 2, t_text(invalid_input), get_color("settings_default", bold=True)) admin_key_win.refresh() key = admin_key_win.getch() if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original admin_key_win.erase() admin_key_win.refresh() curses.noecho() curses.curs_set(0) menu_state.need_redraw = True return None elif key == ord("\n"): # Enter key to save and return menu_state.need_redraw = True if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes curses.noecho() curses.curs_set(0) return user_values # Return the edited Base64 values else: invalid_input = t( "ui.error.admin_key_invalid", default="Error: Each key must be valid Base64 and 32 bytes long!", ) elif key == curses.KEY_UP: # Move cursor up cursor_pos = (cursor_pos - 1) % len(user_values) elif key == curses.KEY_DOWN: # Move cursor down cursor_pos = (cursor_pos + 1) % len(user_values) elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key if len(user_values[cursor_pos]) > 0: user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character else: try: user_values[cursor_pos] += chr(key) # Append valid character input to the selected field invalid_input = "" # Clear error if user starts fixing input except ValueError: pass # Ignore invalid character inputs from contact.utilities.singleton import menu_state # Required if not already imported def get_repeated_input(current_value: List[str]) -> Optional[str]: height = 9 width = get_dialog_width() start_y = max(0, (curses.LINES - height) // 2) start_x = max(0, (curses.COLS - width) // 2) repeated_win = curses.newwin(height, width, start_y, start_x) repeated_win.timeout(200) repeated_win.bkgd(get_color("background")) repeated_win.attrset(get_color("window_frame")) repeated_win.keypad(True) curses.echo() curses.curs_set(1) user_values = current_value[:3] + [""] * (3 - len(current_value)) # Always 3 fields cursor_pos = 0 invalid_input = "" def redraw(): repeated_win.erase() repeated_win.border() repeated_win.addstr( 1, 2, t("ui.prompt.edit_values", default="Edit up to 3 Values:"), get_color("settings_default", bold=True), ) win_h, win_w = repeated_win.getmaxyx() for i, line in enumerate(user_values): prefix = "→ " if i == cursor_pos else " " repeated_win.addstr( 3 + i, 2, f"{prefix}{t('ui.label.value', default='Value')}{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)), ) repeated_win.addstr(3 + i, 18, line[: max(0, win_w - 20)]) # Prevent overflow if invalid_input: win_h, win_w = repeated_win.getmaxyx() repeated_win.addstr(7, 2, invalid_input[: max(0, win_w - 4)], get_color("settings_default", bold=True)) repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) repeated_win.refresh() while True: if menu_state.need_redraw: menu_state.need_redraw = False redraw() redraw() try: key = repeated_win.get_wch() except curses.error: continue # ignore timeout or input issues if key in (27, curses.KEY_LEFT): # ESC or Left Arrow repeated_win.erase() repeated_win.refresh() curses.noecho() curses.curs_set(0) menu_state.need_redraw = True return None elif key in ("\n", curses.KEY_ENTER): curses.noecho() curses.curs_set(0) menu_state.need_redraw = True return ", ".join(user_values).strip() elif key == curses.KEY_UP: cursor_pos = (cursor_pos - 1) % 3 elif key == curses.KEY_DOWN: cursor_pos = (cursor_pos + 1) % 3 elif key in (curses.KEY_BACKSPACE, 127): user_values[cursor_pos] = user_values[cursor_pos][:-1] else: try: ch = chr(key) if isinstance(key, int) else key if ch.isprintable(): user_values[cursor_pos] += ch invalid_input = "" except Exception: pass from contact.utilities.singleton import menu_state # Ensure this is imported def get_fixed32_input(current_value: int) -> int: original_value = current_value try: ip_string = str(ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))) except Exception: ip_string = str(ipaddress.IPv4Address(current_value)) height = 10 width = get_dialog_width() start_y = max(0, (curses.LINES - height) // 2) start_x = max(0, (curses.COLS - width) // 2) fixed32_win = curses.newwin(height, width, start_y, start_x) fixed32_win.bkgd(get_color("background")) fixed32_win.attrset(get_color("window_frame")) fixed32_win.keypad(True) fixed32_win.timeout(200) curses.echo() curses.curs_set(1) user_input = "" def redraw(): fixed32_win.erase() fixed32_win.border() fixed32_win.addstr( 1, 2, t("ui.prompt.enter_ip", default="Enter an IP address (xxx.xxx.xxx.xxx):"), get_color("settings_default", bold=True), ) fixed32_win.addstr( 3, 2, f"{t('ui.label.current', default='Current')}: {ip_string}", get_color("settings_default") ) fixed32_win.addstr( 5, 2, f"{t('ui.label.new_value', default='New value')}: {user_input}", get_color("settings_default"), ) fixed32_win.refresh() while True: if menu_state.need_redraw: menu_state.need_redraw = False redraw() redraw() try: key = fixed32_win.get_wch() except curses.error: continue # ignore timeout if key in (27, curses.KEY_LEFT): # ESC or Left Arrow to cancel fixed32_win.erase() fixed32_win.refresh() curses.noecho() curses.curs_set(0) menu_state.need_redraw = True return original_value elif key in ("\n", curses.KEY_ENTER): octets = user_input.split(".") menu_state.need_redraw = True if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets): curses.noecho() curses.curs_set(0) return int.from_bytes(ipaddress.IPv4Address(user_input).packed, "little", signed=False) else: fixed32_win.addstr( 7, 2, t("ui.error.ip_invalid", default="Invalid IP address. Try again."), get_color("settings_default", bold=True), ) fixed32_win.refresh() curses.napms(1500) user_input = "" elif key in (curses.KEY_BACKSPACE, curses.KEY_DC, 127, 8, "\b", "\x7f"): user_input = user_input[:-1] else: try: ch = chr(key) if isinstance(key, int) else key if ch.isdigit() or ch == ".": user_input += ch except Exception: pass # Ignore unprintable inputs from typing import List, Optional # ensure Optional is imported def get_list_input( prompt: str, current_option: Optional[str], list_options: List[str], mandatory: bool = False ) -> Optional[str]: """ List selector. """ selected_index = list_options.index(current_option) if current_option in list_options else 0 height = min(len(list_options) + 5, curses.LINES) width = get_dialog_width() start_y = max(0, (curses.LINES - height) // 2) start_x = max(0, (curses.COLS - width) // 2) list_win = curses.newwin(height, width, start_y, start_x) list_win.timeout(200) list_win.bkgd(get_color("background")) list_win.attrset(get_color("window_frame")) list_win.keypad(True) list_pad = curses.newpad(len(list_options) + 1, max(1, width - 8)) list_pad.bkgd(get_color("background")) max_index = len(list_options) - 1 visible_height = list_win.getmaxyx()[0] - 5 def redraw_list_ui(): translated_prompt = t_text(prompt) list_win.erase() list_win.border() list_win.addstr(1, 2, translated_prompt, get_color("settings_default", bold=True)) win_h, win_w = list_win.getmaxyx() pad_w = max(1, win_w - 8) for idx, item in enumerate(list_options): color = get_color("settings_default", reverse=(idx == selected_index)) display_item = t_text(item) list_pad.addstr(idx, 0, display_item[:pad_w].ljust(pad_w), color) list_win.refresh() list_pad.refresh( 0, 0, list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4, ) # Recompute visible height each draw in case of resize vis_h = list_win.getmaxyx()[0] - 5 draw_arrows(list_win, vis_h, max_index, [0], show_save_option=False) # Initial draw redraw_list_ui() while True: if menu_state.need_redraw: menu_state.need_redraw = False redraw_list_ui() try: key = list_win.getch() except curses.error: continue if key == curses.KEY_UP: old_selected_index = selected_index selected_index = max(0, selected_index - 1) move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index) elif key == curses.KEY_DOWN: old_selected_index = selected_index selected_index = min(len(list_options) - 1, selected_index + 1) move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index) elif key == ord("\n"): # Enter list_win.clear() list_win.refresh() menu_state.need_redraw = True return list_options[selected_index] elif key == 27 or key == curses.KEY_LEFT: # ESC or Left if mandatory: continue list_win.clear() list_win.refresh() menu_state.need_redraw = True return current_option ================================================ FILE: contact/utilities/interfaces.py ================================================ import logging import time import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface def initialize_interface(args): try: if args.ble: return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None) elif args.host: try: if ":" in args.host: tcp_hostname, tcp_port = args.host.split(":") else: tcp_hostname = args.host tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port) except Exception as ex: logging.error(f"Error connecting to {args.host}. {ex}") else: try: client = meshtastic.serial_interface.SerialInterface(args.port) except FileNotFoundError as ex: logging.error(f"The serial device at '{args.port}' was not found. {ex}") except PermissionError as ex: logging.error( f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}" ) except Exception as ex: logging.error(f"Unexpected error initializing interface: {ex}") except OSError as ex: logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}") if client.devPath is None: try: client = meshtastic.tcp_interface.TCPInterface("localhost") except Exception as ex: logging.error(f"Error connecting to localhost:{ex}") return client except Exception as ex: logging.critical(f"Fatal error initializing interface: {ex}") def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0): last_error = None for attempt in range(attempts): try: interface = initialize_interface(args) if interface is not None: return interface last_error = RuntimeError("initialize_interface returned None") except Exception as ex: last_error = ex if attempt < attempts - 1: time.sleep(delay_seconds) raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error ================================================ FILE: contact/utilities/save_to_radio.py ================================================ from meshtastic.protobuf import channel_pb2 from google.protobuf.message import Message import logging import base64 import time DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"} POWER_REBOOT_KEYS = { "device_battery_ina_address", "is_power_saving", "ls_secs", "min_wake_secs", "on_battery_shutdown_after_secs", "sds_secs", "wait_bluetooth_secs", } DISPLAY_REBOOT_KEYS = {"screen_on_secs", "flip_screen", "oled", "displaymode"} LORA_REBOOT_KEYS = { "use_preset", "region", "modem_preset", "bandwidth", "spread_factor", "coding_rate", "tx_power", "frequency_offset", "override_frequency", "channel_num", "sx126x_rx_boosted_gain", } SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"} USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"} def _collect_changed_keys(modified_settings): changed = set() for key, value in modified_settings.items(): if isinstance(value, dict): changed.update(_collect_changed_keys(value)) else: changed.add(key) return changed def _requires_reconnect(menu_state, modified_settings) -> bool: if not modified_settings or len(menu_state.menu_path) < 2: return False section = menu_state.menu_path[1] changed_keys = _collect_changed_keys(modified_settings) if section == "Module Settings": return True if section == "User Settings": return bool(changed_keys & USER_RECONNECT_KEYS) if section == "Channels": return False if section != "Radio Settings" or len(menu_state.menu_path) < 3: return False config_category = menu_state.menu_path[2].lower() if config_category in {"network", "bluetooth"}: return True if config_category == "security": return not changed_keys.issubset(SECURITY_NON_REBOOT_KEYS) if config_category == "device": return bool(changed_keys & DEVICE_REBOOT_KEYS) if config_category == "power": return bool(changed_keys & POWER_REBOOT_KEYS) if config_category == "display": return bool(changed_keys & DISPLAY_REBOOT_KEYS) if config_category == "lora": return bool(changed_keys & LORA_REBOOT_KEYS) # Firmware defaults most config writes to reboot-required unless a handler # explicitly clears that flag. return True def save_changes(interface, modified_settings, menu_state): """ Save changes to the device based on modified settings. :param interface: Meshtastic interface instance :param menu_path: Current menu path :param modified_settings: Dictionary of modified settings """ try: if not modified_settings: logging.info("No changes to save. modified_settings is empty.") return False node = interface.getNode("^local") admin_key_backup = None if "admin_key" in modified_settings: # Get reference to security config security_config = node.localConfig.security admin_keys = modified_settings["admin_key"] # Filter out empty keys valid_keys = [key for key in admin_keys if key and key.strip() and key != b""] if not valid_keys: logging.warning("No valid admin keys provided. Skipping admin key update.") else: # Clear existing keys if needed if security_config.admin_key: logging.info("Clearing existing admin keys...") del security_config.admin_key[:] node.writeConfig("security") time.sleep(2) # Give time for device to process # Append new keys for key in valid_keys: logging.info(f"Adding admin key: {key}") security_config.admin_key.append(key) node.writeConfig("security") logging.info("Admin keys updated successfully!") # Backup 'admin_key' before removing it admin_key_backup = modified_settings.get("admin_key", None) # Remove 'admin_key' from modified_settings to prevent interference del modified_settings["admin_key"] # Return early if there are no other settings left to process if not modified_settings: return _requires_reconnect(menu_state, {"admin_key": admin_key_backup}) if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings": config_category = menu_state.menu_path[2].lower() # for radio and module configs if {"latitude", "longitude", "altitude"} & modified_settings.keys(): lat = float(modified_settings.get("latitude", 0.0)) lon = float(modified_settings.get("longitude", 0.0)) alt = int(modified_settings.get("altitude", 0)) interface.localNode.setFixedPosition(lat, lon, alt) logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}") return False elif menu_state.menu_path[1] == "User Settings": # for user configs config_category = "User Settings" long_name = modified_settings.get("longName") short_name = modified_settings.get("shortName") is_licensed = modified_settings.get("isLicensed") is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean node.setOwner(long_name, short_name, is_licensed) logging.info( f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}" ) return _requires_reconnect(menu_state, modified_settings) elif menu_state.menu_path[1] == "Channels": # for channel configs config_category = "Channels" try: channel = menu_state.menu_path[-1] channel_num = int(channel.split()[-1]) - 1 except (IndexError, ValueError) as e: channel_num = None channel = node.channels[channel_num] for key, value in modified_settings.items(): if key == "psk": # Special case: decode Base64 for psk channel.settings.psk = base64.b64decode(value) elif key == "position_precision": # Special case: module_settings channel.settings.module_settings.position_precision = value else: setattr(channel.settings, key, value) # Use setattr for other fields if channel_num == 0: channel.role = channel_pb2.Channel.Role.PRIMARY else: channel.role = channel_pb2.Channel.Role.SECONDARY node.writeChannel(channel_num) logging.info(f"Updated Channel {channel_num} in {config_category}") logging.info(node.channels) return False else: config_category = None # Resolve the target config container, including nested sub-messages (e.g., network.ipv4_config) config_container = None if hasattr(node.localConfig, config_category): config_container = getattr(node.localConfig, config_category) elif hasattr(node.moduleConfig, config_category): config_container = getattr(node.moduleConfig, config_category) else: logging.warning(f"Config category '{config_category}' not found in config.") return False if len(menu_state.menu_path) >= 4: nested_key = menu_state.menu_path[3] if hasattr(config_container, nested_key): config_container = getattr(config_container, nested_key) for config_item, new_value in modified_settings.items(): config_subcategory = config_container # Check if the config_item exists in the subcategory if hasattr(config_subcategory, config_item): field = getattr(config_subcategory, config_item) try: if isinstance(field, (int, float, str, bool)): # Direct field types setattr(config_subcategory, config_item, new_value) logging.info(f"Updated {config_category}.{config_item} to {new_value}") elif isinstance(field, Message): # Handle protobuf sub-messages if isinstance(new_value, dict): # If new_value is a dictionary for sub_field, sub_value in new_value.items(): if hasattr(field, sub_field): setattr(field, sub_field, sub_value) logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}") else: logging.warning( f"Sub-field '{sub_field}' not found in {config_category}.{config_item}" ) else: logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.") else: logging.warning(f"Unsupported field type for {config_category}.{config_item}.") except AttributeError as e: logging.error(f"Failed to update {config_category}.{config_item}: {e}") else: logging.warning(f"Config item '{config_item}' not found in config category '{config_category}'.") # Write the configuration changes to the node try: node.writeConfig(config_category) logging.info(f"Changes written to config category: {config_category}") if admin_key_backup is not None: modified_settings["admin_key"] = admin_key_backup return _requires_reconnect(menu_state, modified_settings) except Exception as e: logging.error(f"Failed to write configuration for category '{config_category}': {e}") return False except Exception as e: logging.error(f"Error saving changes: {e}") return False ================================================ FILE: contact/utilities/singleton.py ================================================ from contact.ui.ui_state import ChatUIState, InterfaceState, AppState, MenuState ui_state = ChatUIState() interface_state = InterfaceState() app_state = AppState() menu_state = MenuState() ================================================ FILE: contact/utilities/telemetry_beautifier.py ================================================ import datetime sensors = { 'temperature': {'icon':'🌡️ ','unit':'°'}, 'relative_humidity': {'icon':'💧','unit':'%'}, 'barometric_pressure': {'icon':'⮇ ','unit': 'hPa'}, 'lux': {'icon':'🔦 ','unit': 'lx'}, 'uv_lux': {'icon':'uv🔦 ','unit': 'lx'}, 'wind_speed': {'icon':'💨 ','unit': 'm/s'}, 'wind_direction': {'icon':'⮆ ','unit': ''}, 'battery_level': {'icon':'🔋 ', 'unit':'%'}, 'voltage': {'icon':'', 'unit':'V'}, 'channel_utilization': {'icon':'ChUtil:', 'unit':'%'}, 'air_util_tx': {'icon':'AirUtil:', 'unit':'%'}, 'uptime_seconds': {'icon':'🆙 ', 'unit':'h'}, 'latitude_i': {'icon':'🌍 ', 'unit':''}, 'longitude_i': {'icon':'', 'unit':''}, 'altitude': {'icon':'⬆️ ', 'unit':'m'}, 'time': {'icon':'🕔 ', 'unit':''} } def humanize_wind_direction(degrees): """ Convert degrees to Eest-West-Nnoth-Ssouth directions """ if not 0 <= degrees <= 360: return None directions = [ ("N", 337.5, 22.5), ("NE", 22.5, 67.5), ("E", 67.5, 112.5), ("SE", 112.5, 157.5), ("S", 157.5, 202.5), ("SW", 202.5, 247.5), ("W", 247.5, 292.5), ("NW", 292.5, 337.5), ] if degrees >= directions[0][1] or degrees < directions[0][2]: return directions[0][0] # Check for all other directions for direction, lower_bound, upper_bound in directions[1:]: if lower_bound <= degrees < upper_bound: return direction # This part should ideally not be reached with valid input return None def get_chunks(data): """ Breakdown telemetry data and assign emojis for more visual appeal of the payloads """ reading = data.split('\n') # remove empty list lefover from the split reading = list(filter(None, reading)) parsed="" for item in reading: key, value = item.split(":") # If value is float, round it to the 1 digit after point # else make it int if "." in value: value = round(float(value.strip()),1) else: try: value = int(value.strip()) except Exception: # Leave it string as last resort value = value # Python 3.9-compatible alternative to match/case. if key == "uptime_seconds": # convert seconds to hours, for our sanity value = round(value / 60 / 60, 1) elif key in ("longitude_i", "latitude_i"): # Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry # truncate to 6th digit after floating point, which would be still accurate value = round(value * 1e-7, 6) elif key == "wind_direction": # Convert wind direction from degrees to abbreviation value = humanize_wind_direction(value) elif key == "time": value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m") if key in sensors: parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} " else: # just pass through if we haven't added the particular telemetry key:value to the sensor dict parsed+=f"{key}:{value} " return parsed ================================================ FILE: contact/utilities/utils.py ================================================ import datetime import time from typing import Optional, Union from google.protobuf.message import DecodeError from meshtastic import protocols from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2 import contact.ui.default_config as config from contact.utilities.singleton import ui_state, interface_state import contact.utilities.telemetry_beautifier as tb def _get_channel_name(device_channel, node): if device_channel.settings.name: return device_channel.settings.name lora_config = node.localConfig.lora modem_preset_enum = lora_config.modem_preset modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name return convert_to_camel_case(modem_preset_string) def get_channels(): """Retrieve channels from the node and rebuild named channel state.""" node = interface_state.interface.getNode("^local") device_channels = node.channels previous_channel_list = list(ui_state.channel_list) previous_messages = dict(ui_state.all_messages) named_channels = [] for device_channel in device_channels: if device_channel.role: named_channels.append(_get_channel_name(device_channel, node)) previous_named_channels = [channel for channel in previous_channel_list if isinstance(channel, str)] preserved_direct_channels = [channel for channel in previous_channel_list if isinstance(channel, int)] rebuilt_messages = {} for index, channel_name in enumerate(named_channels): previous_name = previous_named_channels[index] if index < len(previous_named_channels) else channel_name if previous_name in previous_messages: rebuilt_messages[channel_name] = previous_messages[previous_name] elif channel_name in previous_messages: rebuilt_messages[channel_name] = previous_messages[channel_name] else: rebuilt_messages[channel_name] = [] for channel in preserved_direct_channels: if channel in previous_messages: rebuilt_messages[channel] = previous_messages[channel] ui_state.channel_list = named_channels + preserved_direct_channels ui_state.all_messages = rebuilt_messages if ui_state.channel_list: ui_state.selected_channel = max(0, min(ui_state.selected_channel, len(ui_state.channel_list) - 1)) return ui_state.channel_list def get_node_list(): if interface_state.interface.nodes: my_node_num = interface_state.myNodeNum def node_sort(node): if config.node_sort == "lastHeard": return -node["lastHeard"] if ("lastHeard" in node and isinstance(node["lastHeard"], int)) else 0 elif config.node_sort == "name": return node["user"]["longName"] elif config.node_sort == "hops": return node["hopsAway"] if "hopsAway" in node else 100 else: return node sorted_nodes = sorted(interface_state.interface.nodes.values(), key=node_sort) # Move favorite nodes to the beginning sorted_nodes = sorted( sorted_nodes, key=lambda node: node["isFavorite"] if "isFavorite" in node else False, reverse=True ) # Move ignored nodes to the end sorted_nodes = sorted(sorted_nodes, key=lambda node: node["isIgnored"] if "isIgnored" in node else False) node_list = [node["num"] for node in sorted_nodes if node["num"] != my_node_num] return [my_node_num] + node_list # Ensuring your node is always first return [] def refresh_node_list(): new_node_list = get_node_list() if new_node_list != ui_state.node_list: ui_state.node_list = new_node_list return True return False def get_nodeNum(): myinfo = interface_state.interface.getMyNodeInfo() myNodeNum = myinfo["num"] return myNodeNum def decimal_to_hex(decimal_number): return f"!{decimal_number:08x}" def convert_to_camel_case(string): words = string.split("_") camel_case_string = "".join(word.capitalize() for word in words) return camel_case_string def get_time_val_units(time_delta): value = 0 unit = "" if time_delta.days > 365: value = time_delta.days // 365 unit = "y" elif time_delta.days > 30: value = time_delta.days // 30 unit = "mon" elif time_delta.days > 7: value = time_delta.days // 7 unit = "w" elif time_delta.days > 0: value = time_delta.days unit = "d" elif time_delta.seconds > 3600: value = time_delta.seconds // 3600 unit = "h" elif time_delta.seconds > 60: value = time_delta.seconds // 60 unit = "min" else: value = time_delta.seconds unit = "s" return (value, unit) def get_readable_duration(seconds): delta = datetime.timedelta(seconds=seconds) val, units = get_time_val_units(delta) return f"{val} {units}" def get_time_ago(timestamp): now = datetime.datetime.now() dt = datetime.datetime.fromtimestamp(timestamp) delta = now - dt value, unit = get_time_val_units(delta) if unit != "s": return f"{value} {unit} ago" return "now" def add_new_message(channel_id, prefix, message): if channel_id not in ui_state.all_messages: ui_state.all_messages[channel_id] = [] # Timestamp handling current_timestamp = time.time() current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00") # Retrieve the last timestamp if available channel_messages = ui_state.all_messages[channel_id] if channel_messages: # Check the last entry for a timestamp for entry in reversed(channel_messages): if entry[0].startswith("--"): last_hour = entry[0].strip("- ").strip() break else: last_hour = None else: last_hour = None # Add a new timestamp if it's a new hour if last_hour != current_hour: ui_state.all_messages[channel_id].append((f"-- {current_hour} --", "")) # Add the message ts_str = time.strftime("[%H:%M:%S] ") ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message)) def parse_protobuf(packet: dict) -> Union[str, dict]: """Attempt to parse a decoded payload using the registered protobuf handler.""" try: decoded = packet.get("decoded") or {} portnum = decoded.get("portnum") payload = decoded.get("payload") if isinstance(payload, str): return payload # These portnumbers carry information visible elswhere in the app, so we just note them in the logs if portnum == "TEXT_MESSAGE_APP": return "✉️" elif portnum == "NODEINFO_APP": return "Name identification payload" elif portnum == "TRACEROUTE_APP": return "Traceroute payload" handler = protocols.get(portnums_pb2.PortNum.Value(portnum)) if portnum is not None else None if handler is not None and handler.protobufFactory is not None: try: pb = handler.protobufFactory() pb.ParseFromString(bytes(payload)) # If we have position payload if portnum == "POSITION_APP": return tb.get_chunks(str(pb)) # Part of TELEMETRY_APP portnum if hasattr(pb, "device_metrics") and pb.HasField("device_metrics"): return tb.get_chunks(str(pb.device_metrics)) # Part of TELEMETRY_APP portnum if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"): return tb.get_chunks(str(pb.environment_metrics)) # For other data, without implemented beautification, fallback to just printing the object return str(pb).replace("\n", " ").replace("\r", " ").strip() except DecodeError: return payload # return payload except Exception: return payload ================================================ FILE: contact/utilities/validation_rules.py ================================================ validation_rules = { "shortName": {"max_length": 4}, "longName": {"max_length": 32}, "fixed_pin": {"min_length": 6, "max_length": 6}, "position_flags": {"max_length": 3}, "enabled_protocols": {"max_value": 2}, "hop_limit": {"max_value": 7}, "latitude": {"min_value": -90, "max_value": 90}, "longitude": {"min_value": -180, "max_value": 180}, "altitude": {"min_value": -4294967295, "max_value": 4294967295}, "red": {"max_value": 255}, "green": {"max_value": 255}, "blue": {"max_value": 255}, "current": {"max_value": 255}, "position_precision": {"max_value": 32}, } def get_validation_for(key: str) -> dict: for rule_key, config in validation_rules.items(): if rule_key in key: return config return {} ================================================ FILE: pyproject.toml ================================================ [project] name = "contact" version = "1.5.8" description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores." authors = [ {name = "Ben Lipsey",email = "ben@pdxlocations.com"} ] license = "GPL-3.0-only" readme = "README.md" requires-python = ">=3.9,<3.15" dependencies = [ "meshtastic (>=2.7.5,<3.0.0)" ] [project.urls] Homepage = "https://github.com/pdxlocations/contact" Issues = "https://github.com/pdxlocations/contact/issues" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] contact = "contact.__main__:start" ================================================ FILE: requirements.txt ================================================ meshtastic ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/test_arg_parser.py ================================================ import unittest from contact.utilities.arg_parser import setup_parser class ArgParserTests(unittest.TestCase): def test_demo_screenshot_flag_is_supported(self) -> None: args = setup_parser().parse_args(["--demo-screenshot"]) self.assertTrue(args.demo_screenshot) def test_demo_screenshot_defaults_to_false(self) -> None: args = setup_parser().parse_args([]) self.assertFalse(args.demo_screenshot) ================================================ FILE: tests/test_bot_handler.py ================================================ import unittest import importlib import sys import types from unittest import mock import contact.ui.default_config as config class BotHandlerTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: sys.modules.setdefault( "contact.message_handlers.tx_handler", types.SimpleNamespace(send_message=mock.Mock()), ) cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler") def test_is_bot_message_uses_configured_catch_words(self) -> None: with mock.patch.object(config, "ping_bot_catch_words", "ping; test; pong"): self.assertTrue(self.bot_handler.is_bot_message("PING")) self.assertTrue(self.bot_handler.is_bot_message("test")) self.assertFalse(self.bot_handler.is_bot_message("hello")) def test_is_bot_message_ignores_empty_config_values(self) -> None: with mock.patch.object(config, "ping_bot_catch_words", " ; ; "): self.assertTrue(self.bot_handler.is_bot_message("ping")) if __name__ == "__main__": unittest.main() ================================================ FILE: tests/test_config_io.py ================================================ import unittest from contact.utilities.config_io import _is_repeated_field, splitCompoundName class ConfigIoTests(unittest.TestCase): def test_split_compound_name_preserves_multi_part_values(self) -> None: self.assertEqual(splitCompoundName("config.device.role"), ["config", "device", "role"]) def test_split_compound_name_duplicates_single_part_values(self) -> None: self.assertEqual(splitCompoundName("owner"), ["owner", "owner"]) def test_is_repeated_field_prefers_new_style_attribute(self) -> None: field = type("Field", (), {"is_repeated": True})() self.assertTrue(_is_repeated_field(field)) def test_is_repeated_field_falls_back_to_label_comparison(self) -> None: field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3}) self.assertTrue(_is_repeated_field(field_type())) ================================================ FILE: tests/test_contact_ui.py ================================================ import unittest from unittest import mock import contact.ui.default_config as config from contact.ui import contact_ui from contact.ui.nav_utils import text_width from contact.utilities.singleton import ui_state from tests.test_support import reset_singletons, restore_config, snapshot_config class ContactUiTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() self.saved_config = snapshot_config("single_pane_mode") def tearDown(self) -> None: restore_config(self.saved_config) reset_singletons() def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None: stdscr = mock.Mock() ui_state.current_window = 1 config.single_pane_mode = "False" with mock.patch.object(contact_ui.curses, "curs_set") as curs_set: with mock.patch.object(contact_ui, "settings_menu") as settings_menu: with mock.patch.object(contact_ui, "get_channels") as get_channels: with mock.patch.object(contact_ui, "refresh_node_list") as refresh_node_list: with mock.patch.object(contact_ui, "handle_resize") as handle_resize: contact_ui.handle_backtick(stdscr) settings_menu.assert_called_once() get_channels.assert_called_once_with() refresh_node_list.assert_called_once_with() handle_resize.assert_called_once_with(stdscr, False) self.assertEqual(curs_set.call_args_list[0].args, (0,)) self.assertEqual(curs_set.call_args_list[-1].args, (1,)) self.assertEqual(ui_state.current_window, 1) def test_process_pending_ui_updates_draws_requested_windows(self) -> None: stdscr = mock.Mock() ui_state.redraw_channels = True ui_state.redraw_messages = True ui_state.redraw_nodes = True ui_state.redraw_packetlog = True ui_state.scroll_messages_to_bottom = True with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list: with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window: with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list: with mock.patch.object(contact_ui, "draw_packetlog_win") as draw_packetlog_win: contact_ui.process_pending_ui_updates(stdscr) draw_channel_list.assert_called_once_with() draw_messages_window.assert_called_once_with(True) draw_node_list.assert_called_once_with() draw_packetlog_win.assert_called_once_with() def test_process_pending_ui_updates_full_redraw_uses_handle_resize(self) -> None: stdscr = mock.Mock() ui_state.redraw_full_ui = True ui_state.redraw_channels = True ui_state.redraw_messages = True with mock.patch.object(contact_ui, "handle_resize") as handle_resize: contact_ui.process_pending_ui_updates(stdscr) handle_resize.assert_called_once_with(stdscr, False) self.assertFalse(ui_state.redraw_channels) self.assertFalse(ui_state.redraw_messages) def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None: ui_state.node_list = [101, 202] ui_state.selected_node = 1 ui_state.start_index = [0, 0, 0] contact_ui.nodes_pad = mock.Mock() contact_ui.nodes_pad.getmaxyx.return_value = (4, 20) contact_ui.nodes_win = mock.Mock() contact_ui.nodes_win.getmaxyx.return_value = (10, 20) interface = mock.Mock() interface.nodesByNum = {101: {}, 202: {}} with mock.patch.object(contact_ui, "refresh_pad") as refresh_pad: with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows: with mock.patch.object(contact_ui, "get_node_row_color", side_effect=[11, 22]): with mock.patch("contact.ui.contact_ui.interface_state.interface", interface): contact_ui.refresh_node_selection(old_index=0, highlight=True) self.assertEqual( contact_ui.nodes_pad.chgat.call_args_list, [mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)], ) refresh_pad.assert_called_once_with(2) draw_window_arrows.assert_called_once_with(2) def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None: ui_state.channel_list = ["VeryLongChannelName"] ui_state.notifications = [] ui_state.selected_channel = 0 ui_state.current_window = 0 contact_ui.channel_pad = mock.Mock() contact_ui.channel_win = mock.Mock() contact_ui.channel_win.getmaxyx.return_value = (10, 20) with mock.patch.object(contact_ui, "get_color", return_value=1): with mock.patch.object(contact_ui, "paint_frame"): with mock.patch.object(contact_ui, "refresh_pad"): with mock.patch.object(contact_ui, "draw_window_arrows"): with mock.patch.object(contact_ui, "remove_notification"): contact_ui.draw_channel_list() text = contact_ui.channel_pad.addstr.call_args.args[2] self.assertEqual(len(text), 16) def test_draw_node_list_reserves_scroll_arrow_column(self) -> None: ui_state.node_list = [101] ui_state.current_window = 2 contact_ui.nodes_pad = mock.Mock() contact_ui.nodes_win = mock.Mock() contact_ui.nodes_win.getmaxyx.return_value = (10, 20) contact_ui.entry_win = mock.Mock() interface = mock.Mock() interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}} with mock.patch("contact.ui.contact_ui.interface_state.interface", interface): with mock.patch.object(contact_ui, "get_node_row_color", return_value=1): with mock.patch.object(contact_ui.curses, "curs_set"): with mock.patch.object(contact_ui, "paint_frame"): with mock.patch.object(contact_ui, "refresh_pad"): with mock.patch.object(contact_ui, "draw_window_arrows"): contact_ui.draw_node_list() text = contact_ui.nodes_pad.addstr.call_args.args[2] self.assertEqual(text_width(text), 16) self.assertIn("…", text) def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None: stdscr = mock.Mock() stdscr.getmaxyx.return_value = (24, 80) ui_state.single_pane_mode = True ui_state.current_window = 1 contact_ui.entry_win = mock.Mock() contact_ui.channel_win = mock.Mock() contact_ui.messages_win = mock.Mock() contact_ui.nodes_win = mock.Mock() contact_ui.packetlog_win = mock.Mock() contact_ui.messages_pad = mock.Mock() contact_ui.nodes_pad = mock.Mock() contact_ui.channel_pad = mock.Mock() with mock.patch.object(contact_ui.curses, "curs_set"): with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list: with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window: with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list: with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows: contact_ui.handle_resize(stdscr, False) contact_ui.channel_win.resize.assert_called_once_with(21, 80) contact_ui.messages_win.resize.assert_called_once_with(21, 80) contact_ui.nodes_win.resize.assert_called_once_with(21, 80) contact_ui.channel_win.mvwin.assert_called_once_with(0, 0) contact_ui.messages_win.mvwin.assert_called_once_with(0, 0) contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0) contact_ui.channel_win.box.assert_not_called() contact_ui.nodes_win.box.assert_not_called() contact_ui.messages_win.box.assert_called_once_with() draw_channel_list.assert_called_once_with() draw_messages_window.assert_called_once_with(True) draw_node_list.assert_called_once_with() draw_window_arrows.assert_called_once_with(1) def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None: ui_state.single_pane_mode = True ui_state.channel_list = ["Primary"] ui_state.selected_channel = 0 self.assertEqual(contact_ui.get_window_title(0), "") self.assertEqual(contact_ui.get_window_title(1), "Primary") def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None: ui_state.single_pane_mode = True ui_state.current_window = 1 ui_state.channel_list = ["Primary"] ui_state.selected_channel = 0 ui_state.start_index = [0, 0, 0] ui_state.display_log = False contact_ui.channel_win = mock.Mock() contact_ui.channel_win.getmaxyx.return_value = (10, 20) contact_ui.messages_pad = mock.Mock() contact_ui.messages_pad.getmaxyx.return_value = (5, 20) contact_ui.messages_win = mock.Mock() contact_ui.messages_win.getbegyx.return_value = (0, 0) contact_ui.messages_win.getmaxyx.return_value = (10, 20) with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4): contact_ui.refresh_pad(1) contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD) def test_search_ignores_no_input_from_curses(self) -> None: ui_state.node_list = [101] ui_state.selected_node = 0 contact_ui.entry_win = mock.Mock() contact_ui.entry_win.get_wch.side_effect = contact_ui.curses.error("no input") with mock.patch.object(contact_ui, "draw_centered_text_field"): with mock.patch.object(contact_ui, "get_color", return_value=0): contact_ui.search(2) contact_ui.entry_win.timeout.assert_has_calls([mock.call(-1), mock.call(200)]) contact_ui.entry_win.erase.assert_called() def test_f5_node_details_ignores_no_input_from_curses(self) -> None: stdscr = mock.Mock() ui_state.node_list = [101] ui_state.selected_node = 0 ui_state.current_window = 2 dialog_win = mock.Mock() dialog_win.getch.side_effect = [contact_ui.curses.error("no input"), 27] msg_win = mock.Mock() dialog_win.derwin.return_value = msg_win interface = mock.Mock() interface.nodesByNum = { 101: { "num": 101, "user": { "longName": "Test Node", "shortName": "TN", "hwModel": "T-Beam", "role": "CLIENT", "publicKey": "abc", }, } } with mock.patch("contact.ui.contact_ui.interface_state.interface", interface): with mock.patch.object(contact_ui.curses, "LINES", 24, create=True): with mock.patch.object(contact_ui.curses, "COLS", 80, create=True): with mock.patch.object(contact_ui.curses, "curs_set"): with mock.patch.object(contact_ui.curses, "update_lines_cols"): with mock.patch.object(contact_ui.curses, "doupdate"): with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win): with mock.patch.object(contact_ui, "get_color", return_value=0): with mock.patch.object(contact_ui, "refresh_node_selection"): with mock.patch.object(contact_ui, "handle_resize") as handle_resize: contact_ui.handle_f5_key(stdscr) self.assertEqual(dialog_win.getch.call_count, 2) handle_resize.assert_called_once_with(stdscr, False) def test_f5_node_details_tolerates_none_metrics(self) -> None: stdscr = mock.Mock() ui_state.node_list = [101] ui_state.selected_node = 0 ui_state.current_window = 2 dialog_win = mock.Mock() dialog_win.getch.return_value = 27 msg_win = mock.Mock() dialog_win.derwin.return_value = msg_win interface = mock.Mock() interface.nodesByNum = { 101: { "num": 101, "snr": None, "hopsAway": None, "deviceMetrics": { "batteryLevel": None, "channelUtilization": None, "airUtilTx": None, "uptimeSeconds": None, }, "user": { "longName": "Test Node", "shortName": "TN", "hwModel": "T-Beam", "role": "CLIENT", "publicKey": "abc", }, } } with mock.patch("contact.ui.contact_ui.interface_state.interface", interface): with mock.patch.object(contact_ui.curses, "LINES", 24, create=True): with mock.patch.object(contact_ui.curses, "COLS", 80, create=True): with mock.patch.object(contact_ui.curses, "curs_set"): with mock.patch.object(contact_ui.curses, "update_lines_cols"): with mock.patch.object(contact_ui.curses, "doupdate"): with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win): with mock.patch.object(contact_ui, "get_color", return_value=0): with mock.patch.object(contact_ui, "refresh_node_selection"): with mock.patch.object(contact_ui, "handle_resize") as handle_resize: contact_ui.handle_f5_key(stdscr) handle_resize.assert_called_once_with(stdscr, False) ================================================ FILE: tests/test_control_ui.py ================================================ from argparse import Namespace from types import SimpleNamespace import unittest from unittest import mock from contact.ui import control_ui from contact.utilities.singleton import interface_state from tests.test_support import reset_singletons class ControlUiTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() def tearDown(self) -> None: reset_singletons() def test_reconnect_interface_with_splash_replaces_interface(self) -> None: old_interface = mock.Mock() new_interface = mock.Mock() stdscr = mock.Mock() parser = mock.Mock() parser.parse_args.return_value = Namespace() with mock.patch.object(control_ui, "setup_parser", return_value=parser): with mock.patch.object(control_ui, "draw_splash") as draw_splash: with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect: with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw: result = control_ui.reconnect_interface_with_splash(stdscr, old_interface) old_interface.close.assert_called_once_with() stdscr.clear.assert_called_once_with() stdscr.refresh.assert_called_once_with() draw_splash.assert_called_once_with(stdscr) reconnect.assert_called_once_with(parser.parse_args.return_value) redraw.assert_called_once_with(stdscr) self.assertIs(result, new_interface) self.assertIs(interface_state.interface, new_interface) def test_reconnect_after_admin_action_runs_action_then_reconnects(self) -> None: stdscr = mock.Mock() interface = mock.Mock() new_interface = mock.Mock() action = mock.Mock() with mock.patch.object(control_ui, "reconnect_interface_with_splash", return_value=new_interface) as reconnect: result = control_ui.reconnect_after_admin_action( stdscr, interface, action, "Factory Reset Requested by menu" ) action.assert_called_once_with() reconnect.assert_called_once_with(stdscr, interface) self.assertIs(result, new_interface) def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None: stdscr = mock.Mock() with mock.patch("contact.utilities.utils.get_channels") as get_channels: with mock.patch("contact.utilities.utils.refresh_node_list") as refresh_node_list: with mock.patch("contact.ui.contact_ui.handle_resize") as handle_resize: control_ui.redraw_main_ui_after_reconnect(stdscr) get_channels.assert_called_once_with() refresh_node_list.assert_called_once_with() handle_resize.assert_called_once_with(stdscr, False) def test_request_factory_reset_uses_library_helper_when_supported(self) -> None: node = mock.Mock() control_ui.request_factory_reset(node) node.factoryReset.assert_called_once_with(full=False) node.ensureSessionKey.assert_not_called() node._sendAdmin.assert_not_called() def test_request_factory_reset_uses_library_helper_for_full_reset_when_supported(self) -> None: node = mock.Mock() control_ui.request_factory_reset(node, full=True) node.factoryReset.assert_called_once_with(full=True) node.ensureSessionKey.assert_not_called() node._sendAdmin.assert_not_called() def test_request_factory_reset_falls_back_to_int_valued_admin_message(self) -> None: node = mock.Mock() node.factoryReset.side_effect = TypeError( "Field meshtastic.protobuf.AdminMessage.factory_reset_config: Expected an int, got a boolean." ) node.iface = SimpleNamespace(localNode=node) control_ui.request_factory_reset(node) node.ensureSessionKey.assert_called_once_with() sent_message = node._sendAdmin.call_args.args[0] self.assertEqual(sent_message.factory_reset_config, 1) self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"]) def test_request_factory_reset_full_falls_back_to_int_valued_admin_message(self) -> None: node = mock.Mock() node.factoryReset.side_effect = TypeError( "Field meshtastic.protobuf.AdminMessage.factory_reset_device: Expected an int, got a boolean." ) node.iface = SimpleNamespace(localNode=node) control_ui.request_factory_reset(node, full=True) node.ensureSessionKey.assert_called_once_with() sent_message = node._sendAdmin.call_args.args[0] self.assertEqual(sent_message.factory_reset_device, 1) self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"]) ================================================ FILE: tests/test_control_utils.py ================================================ import unittest from contact.utilities.control_utils import transform_menu_path class ControlUtilsTests(unittest.TestCase): def test_transform_menu_path_applies_replacements_and_normalization(self) -> None: transformed = transform_menu_path(["Main Menu", "Radio Settings", "Channel 2", "Detail"]) self.assertEqual(transformed, ["config", "channel", "Detail"]) def test_transform_menu_path_preserves_unmatched_entries(self) -> None: transformed = transform_menu_path(["Main Menu", "Module Settings", "WiFi"]) self.assertEqual(transformed, ["module", "WiFi"]) ================================================ FILE: tests/test_db_handler.py ================================================ import os import sqlite3 import tempfile import unittest import contact.ui.default_config as config from contact.utilities import db_handler from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface from contact.utilities.singleton import interface_state, ui_state from contact.utilities.utils import decimal_to_hex from tests.test_support import reset_singletons, restore_config, snapshot_config class DbHandlerTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() self.saved_config = snapshot_config( "db_file_path", "message_prefix", "sent_message_prefix", "ack_str", "ack_implicit_str", "ack_unknown_str", "nak_str", ) self.tempdir = tempfile.TemporaryDirectory() config.db_file_path = os.path.join(self.tempdir.name, "client.db") interface_state.myNodeNum = 123 def tearDown(self) -> None: self.tempdir.cleanup() restore_config(self.saved_config) reset_singletons() def test_save_message_to_db_and_update_ack_roundtrip(self) -> None: timestamp = db_handler.save_message_to_db("Primary", "123", "hello") self.assertIsInstance(timestamp, int) db_handler.update_ack_nak("Primary", timestamp, "hello", "Ack") with sqlite3.connect(config.db_file_path) as conn: row = conn.execute('SELECT user_id, message_text, ack_type FROM "123_Primary_messages"').fetchone() self.assertEqual(row, ("123", "hello", "Ack")) def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None: db_handler.update_node_info_in_db(999, short_name="ABCD") original_long_name = db_handler.get_name_from_database(999, "long") self.assertTrue(original_long_name.startswith("Meshtastic ")) self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD") self.assertEqual(db_handler.is_chat_archived(999), 0) db_handler.update_node_info_in_db(999, chat_archived=1) self.assertEqual(db_handler.get_name_from_database(999, "long"), original_long_name) self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD") self.assertEqual(db_handler.is_chat_archived(999), 1) def test_get_name_from_database_returns_hex_when_user_is_missing(self) -> None: user_id = 0x1234ABCD db_handler.ensure_node_table_exists() self.assertEqual(db_handler.get_name_from_database(user_id, "short"), decimal_to_hex(user_id)) self.assertEqual(db_handler.is_chat_archived(user_id), 0) def test_load_messages_from_db_populates_channels_and_messages(self) -> None: db_handler.update_node_info_in_db(123, long_name="Local Node", short_name="ME") db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM") db_handler.update_node_info_in_db(789, long_name="Archived", short_name="AR", chat_archived=1) db_handler.ensure_table_exists( '"123_Primary_messages"', """ user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT """, ) db_handler.ensure_table_exists( '"123_789_messages"', """ user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT """, ) with sqlite3.connect(config.db_file_path) as conn: conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("123", "sent", 1700000000, "Ack")) conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("456", "reply", 1700000001, None)) conn.execute('INSERT INTO "123_789_messages" VALUES (?, ?, ?, ?)', ("789", "hidden", 1700000002, None)) conn.commit() ui_state.channel_list = [] ui_state.all_messages = {} db_handler.load_messages_from_db() self.assertIn("Primary", ui_state.channel_list) self.assertNotIn(789, ui_state.channel_list) self.assertIn("Primary", ui_state.all_messages) self.assertIn(789, ui_state.all_messages) messages = ui_state.all_messages["Primary"] self.assertTrue(messages[0][0].startswith("-- ")) self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in messages)) self.assertTrue(any("RM:" in prefix for prefix, _ in messages)) self.assertEqual(ui_state.all_messages[789][-1][1], "hidden") def test_init_nodedb_inserts_nodes_from_interface(self) -> None: interface_state.interface = build_demo_interface() interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM db_handler.init_nodedb() self.assertEqual(db_handler.get_name_from_database(2701131778, "short"), "SAT2") ================================================ FILE: tests/test_default_config.py ================================================ import tempfile import unittest from contact.ui import default_config class DefaultConfigTests(unittest.TestCase): def test_get_localisation_options_filters_hidden_and_non_ini_files(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: for filename in ("en.ini", "ru.ini", ".hidden.ini", "notes.txt"): with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle: handle.write("") self.assertEqual(default_config.get_localisation_options(tmpdir), ["en", "ru"]) def test_get_localisation_file_normalizes_extensions_and_falls_back_to_english(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: for filename in ("en.ini", "ru.ini"): with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle: handle.write("") self.assertTrue(default_config.get_localisation_file("RU.ini", tmpdir).endswith("/ru.ini")) self.assertTrue(default_config.get_localisation_file("missing", tmpdir).endswith("/en.ini")) def test_update_dict_only_adds_missing_values(self) -> None: default = {"theme": "dark", "nested": {"language": "en", "sound": True}} actual = {"nested": {"language": "ru"}} updated = default_config.update_dict(default, actual) self.assertTrue(updated) self.assertEqual(actual, {"theme": "dark", "nested": {"language": "ru", "sound": True}}) def test_format_json_single_line_arrays_keeps_arrays_inline(self) -> None: rendered = default_config.format_json_single_line_arrays({"items": [1, 2], "nested": {"flags": ["a", "b"]}}) self.assertIn('"items": [1, 2]', rendered) self.assertIn('"flags": ["a", "b"]', rendered) ================================================ FILE: tests/test_demo_data.py ================================================ import tempfile import unittest from unittest import mock import contact.__main__ as entrypoint import contact.ui.default_config as config from contact.utilities.db_handler import get_name_from_database from contact.utilities.demo_data import DEMO_CHANNELS, DEMO_LOCAL_NODE_NUM, build_demo_interface, configure_demo_database from contact.utilities.singleton import interface_state, ui_state from tests.test_support import reset_singletons, restore_config, snapshot_config class DemoDataTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() self.saved_config = snapshot_config("db_file_path", "node_sort", "single_pane_mode") def tearDown(self) -> None: restore_config(self.saved_config) reset_singletons() def test_build_demo_interface_exposes_expected_shape(self) -> None: interface = build_demo_interface() self.assertEqual(interface.getMyNodeInfo()["num"], DEMO_LOCAL_NODE_NUM) self.assertEqual([channel.settings.name for channel in interface.getNode("^local").channels], DEMO_CHANNELS) self.assertIn(DEMO_LOCAL_NODE_NUM, interface.nodesByNum) def test_initialize_globals_seed_demo_populates_ui_state_and_db(self) -> None: interface_state.interface = build_demo_interface() with tempfile.TemporaryDirectory() as tmpdir: demo_db_path = configure_demo_database(tmpdir) with mock.patch.object(entrypoint.pub, "subscribe"): entrypoint.initialize_globals(seed_demo=True) self.assertEqual(config.db_file_path, demo_db_path) self.assertIn("MediumFast", ui_state.channel_list) self.assertIn("Another Channel", ui_state.channel_list) self.assertIn(2701131788, ui_state.channel_list) self.assertEqual(ui_state.node_list[0], DEMO_LOCAL_NODE_NUM) self.assertEqual(get_name_from_database(2701131778, "short"), "SAT2") medium_fast = ui_state.all_messages["MediumFast"] self.assertTrue(medium_fast[0][0].startswith("-- ")) self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in medium_fast)) self.assertTrue(any("SAT2:" in prefix for prefix, _ in medium_fast)) direct_messages = ui_state.all_messages[2701131788] self.assertEqual(len(direct_messages), 3) ================================================ FILE: tests/test_dialog.py ================================================ import unittest from unittest import mock from contact.ui import dialog as dialog_module from contact.utilities.singleton import menu_state, ui_state class _FakeWindow: def __init__(self, height: int, width: int) -> None: self.height = height self.width = width self.children = [] self.added_strings = [] self._getch_values = [10] def erase(self) -> None: return None def bkgd(self, *_args) -> None: return None def attrset(self, *_args) -> None: return None def border(self, *_args) -> None: return None def addstr(self, y: int, x: int, text: str, *_args) -> None: self.added_strings.append((y, x, text)) def derwin(self, height: int, width: int, _y: int, _x: int): child = _FakeWindow(height, width) self.children.append(child) return child def noutrefresh(self) -> None: return None def keypad(self, *_args) -> None: return None def timeout(self, *_args) -> None: return None def getch(self) -> int: return self._getch_values.pop(0) if self._getch_values else -1 def refresh(self) -> None: return None def getmaxyx(self): return (self.height, self.width) class DialogTests(unittest.TestCase): def setUp(self) -> None: self.previous_window = ui_state.current_window self.previous_start_index = list(ui_state.start_index) self.previous_need_redraw = menu_state.need_redraw def tearDown(self) -> None: ui_state.current_window = self.previous_window ui_state.start_index = self.previous_start_index menu_state.need_redraw = self.previous_need_redraw def test_dialog_renders_full_message_when_width_is_sufficient(self) -> None: root = _FakeWindow(5, 33) ui_state.current_window = 0 ui_state.start_index = [0, 0, 0] menu_state.need_redraw = False with mock.patch.object(dialog_module, "t_text", side_effect=lambda text: text): with mock.patch.object(dialog_module, "get_color", return_value=0): with mock.patch.object(dialog_module, "draw_main_arrows"): with mock.patch.object(dialog_module.curses, "update_lines_cols"): with mock.patch.object(dialog_module.curses, "doupdate"): with mock.patch.object(dialog_module.curses, "LINES", 24, create=True): with mock.patch.object(dialog_module.curses, "COLS", 80, create=True): with mock.patch.object(dialog_module.curses, "newwin", return_value=root): dialog_module.dialog("Bot Responder", "Bot responder is now Enabled.") self.assertTrue(root.children) message_text = [text for _y, _x, text in root.children[0].added_strings] self.assertIn("Bot responder is now Enabled.", message_text) if __name__ == "__main__": unittest.main() ================================================ FILE: tests/test_emoji_utils.py ================================================ import unittest from contact.utilities.emoji_utils import normalize_message_text class EmojiUtilsTests(unittest.TestCase): def test_strips_modifiers_from_keycaps_and_skin_tones(self) -> None: self.assertEqual(normalize_message_text("👍🏽 7️⃣"), "👍 7") def test_rewrites_flag_emoji_to_country_codes(self) -> None: self.assertEqual(normalize_message_text("🇺🇸 hello 🇩🇪"), "US hello DE") ================================================ FILE: tests/test_i18n.py ================================================ import os import tempfile import unittest from unittest import mock import contact.ui.default_config as config from contact.utilities import i18n from contact.utilities.ini_utils import parse_ini_file from tests.test_support import restore_config, snapshot_config class I18nTests(unittest.TestCase): def setUp(self) -> None: self.saved_config = snapshot_config("language") i18n._translations = {} i18n._language = None def tearDown(self) -> None: restore_config(self.saved_config) i18n._translations = {} i18n._language = None def test_t_loads_translation_file_and_formats_placeholders(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: translation_file = os.path.join(tmpdir, "xx.ini") with open(translation_file, "w", encoding="utf-8") as handle: handle.write('[ui]\n') handle.write('greeting,"Hello {name}"\n') config.language = "xx" with mock.patch.object(config, "get_localisation_file", return_value=translation_file): self.assertEqual(i18n.t("ui.greeting", name="Ben"), "Hello Ben") def test_t_falls_back_to_default_and_returns_unformatted_text_on_error(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: translation_file = os.path.join(tmpdir, "xx.ini") with open(translation_file, "w", encoding="utf-8") as handle: handle.write('[ui]\n') handle.write('greeting,"Hello {name}"\n') config.language = "xx" with mock.patch.object(config, "get_localisation_file", return_value=translation_file): self.assertEqual(i18n.t("ui.greeting"), "Hello {name}") self.assertEqual(i18n.t("ui.missing", default="Fallback"), "Fallback") self.assertEqual(i18n.t_text("Literal {value}", value=7), "Literal 7") def test_loader_cache_is_reused_until_language_changes(self) -> None: config.language = "en" with mock.patch.object(i18n, "parse_ini_file", return_value=({"key": "value"}, {})) as parse_ini_file: self.assertEqual(i18n.t("key"), "value") self.assertEqual(i18n.t("key"), "value") self.assertEqual(parse_ini_file.call_count, 1) config.language = "ru" self.assertEqual(i18n.t("missing", default="fallback"), "fallback") self.assertEqual(parse_ini_file.call_count, 2) def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None: required_keys = { "ui.help.bot_responder", "ui.bot.status.enabled", "ui.bot.status.disabled", "ui.bot.dialog.title", "ui.bot.dialog.body", "ui.bot.status.message", "app_settings.ping_bot", "app_settings.ping_bot.catch_words", "app_settings.ping_bot.response_word", } for language in config.get_localisation_options(): field_mapping, _ = parse_ini_file(config.get_localisation_file(language)) self.assertTrue(required_keys.issubset(field_mapping), msg=f"Missing bot translation keys in {language}") ================================================ FILE: tests/test_ini_utils.py ================================================ import os import tempfile import unittest from unittest import mock from contact.utilities.ini_utils import parse_ini_file class IniUtilsTests(unittest.TestCase): def test_parse_ini_file_reads_sections_fields_and_help_text(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: ini_path = os.path.join(tmpdir, "settings.ini") with open(ini_path, "w", encoding="utf-8") as handle: handle.write('; comment\n') handle.write('[config.device]\n') handle.write('title,"Device","Device help"\n') handle.write('name,"Node Name","Node help"\n') handle.write('empty_help,"Fallback",""\n') with mock.patch("contact.utilities.ini_utils.i18n.t", return_value="No help available."): mapping, help_text = parse_ini_file(ini_path) self.assertEqual(mapping["config.device"], "Device") self.assertEqual(help_text["config.device"], "Device help") self.assertEqual(mapping["config.device.name"], "Node Name") self.assertEqual(help_text["config.device.name"], "Node help") self.assertEqual(help_text["config.device.empty_help"], "No help available.") def test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: ini_path = os.path.join(tmpdir, "settings.ini") with open(ini_path, "w", encoding="utf-8") as handle: handle.write('[section]\n') handle.write('name,"Name"\n') with mock.patch("contact.utilities.ini_utils.i18n.t", side_effect=RuntimeError("boom")): mapping, help_text = parse_ini_file(ini_path) self.assertEqual(mapping["section.name"], "Name") self.assertEqual(help_text["section.name"], "No help available.") ================================================ FILE: tests/test_interfaces.py ================================================ from argparse import Namespace import unittest from unittest import mock from contact.utilities.interfaces import reconnect_interface class InterfacesTests(unittest.TestCase): def test_reconnect_interface_retries_until_connection_succeeds(self) -> None: args = Namespace() with mock.patch("contact.utilities.interfaces.initialize_interface", side_effect=[None, None, "iface"]) as initialize: with mock.patch("contact.utilities.interfaces.time.sleep") as sleep: result = reconnect_interface(args, attempts=3, delay_seconds=0.25) self.assertEqual(result, "iface") self.assertEqual(initialize.call_count, 3) self.assertEqual(sleep.call_count, 2) def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None: args = Namespace() with mock.patch("contact.utilities.interfaces.initialize_interface", return_value=None): with mock.patch("contact.utilities.interfaces.time.sleep"): with self.assertRaises(RuntimeError): reconnect_interface(args, attempts=2, delay_seconds=0) ================================================ FILE: tests/test_main.py ================================================ from argparse import Namespace from types import SimpleNamespace import unittest from unittest import mock import contact.__main__ as entrypoint import contact.ui.default_config as config from contact.utilities.singleton import interface_state, ui_state from tests.test_support import reset_singletons, restore_config, snapshot_config class MainRuntimeTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() self.saved_config = snapshot_config("single_pane_mode") def tearDown(self) -> None: restore_config(self.saved_config) reset_singletons() def test_initialize_runtime_interface_uses_demo_branch(self) -> None: args = Namespace(demo_screenshot=True) with mock.patch.object(entrypoint, "configure_demo_database") as configure_demo_database: with mock.patch.object(entrypoint, "build_demo_interface", return_value="demo-interface") as build_demo: with mock.patch.object(entrypoint, "initialize_interface") as initialize_interface: result = entrypoint.initialize_runtime_interface(args) self.assertEqual(result, "demo-interface") configure_demo_database.assert_called_once_with() build_demo.assert_called_once_with() initialize_interface.assert_not_called() def test_initialize_runtime_interface_uses_live_branch_without_demo_flag(self) -> None: args = Namespace(demo_screenshot=False) with mock.patch.object(entrypoint, "initialize_interface", return_value="live-interface") as initialize_interface: result = entrypoint.initialize_runtime_interface(args) self.assertEqual(result, "live-interface") initialize_interface.assert_called_once_with(args) def test_interface_is_ready_detects_missing_local_node(self) -> None: self.assertFalse(entrypoint.interface_is_ready(object())) self.assertTrue(entrypoint.interface_is_ready(SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock())))) def test_initialize_runtime_interface_with_retry_retries_until_node_is_ready(self) -> None: args = Namespace(demo_screenshot=False) stdscr = mock.Mock() bad_interface = mock.Mock(spec=["close"]) good_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock())) with mock.patch.object(entrypoint, "initialize_runtime_interface", side_effect=[bad_interface, good_interface]): with mock.patch.object(entrypoint, "get_list_input", return_value="Retry") as get_list_input: with mock.patch.object(entrypoint, "draw_splash") as draw_splash: result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args) self.assertIs(result, good_interface) get_list_input.assert_called_once() bad_interface.close.assert_called_once_with() draw_splash.assert_called_once_with(stdscr) def test_initialize_runtime_interface_with_retry_returns_none_when_user_closes(self) -> None: args = Namespace(demo_screenshot=False) stdscr = mock.Mock() bad_interface = mock.Mock(spec=["close"]) with mock.patch.object(entrypoint, "initialize_runtime_interface", return_value=bad_interface): with mock.patch.object(entrypoint, "get_list_input", return_value="Close") as get_list_input: with mock.patch.object(entrypoint, "draw_splash") as draw_splash: result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args) self.assertIsNone(result) get_list_input.assert_called_once() bad_interface.close.assert_called_once_with() draw_splash.assert_not_called() def test_prompt_region_if_unset_reinitializes_interface_after_confirmation(self) -> None: args = Namespace() old_interface = mock.Mock() new_interface = mock.Mock() stdscr = mock.Mock() interface_state.interface = old_interface with mock.patch.object(entrypoint, "get_list_input", return_value="Yes"): with mock.patch.object(entrypoint, "set_region") as set_region: with mock.patch.object(entrypoint, "draw_splash") as draw_splash: with mock.patch.object(entrypoint, "reconnect_interface", return_value=new_interface) as reconnect: entrypoint.prompt_region_if_unset(args, stdscr) set_region.assert_called_once_with(old_interface) old_interface.close.assert_called_once_with() draw_splash.assert_called_once_with(stdscr) reconnect.assert_called_once_with(args) self.assertIs(interface_state.interface, new_interface) def test_prompt_region_if_unset_leaves_interface_unchanged_when_declined(self) -> None: args = Namespace() interface = mock.Mock() interface_state.interface = interface with mock.patch.object(entrypoint, "get_list_input", return_value="No"): with mock.patch.object(entrypoint, "set_region") as set_region: with mock.patch.object(entrypoint, "reconnect_interface") as reconnect: entrypoint.prompt_region_if_unset(args) set_region.assert_not_called() reconnect.assert_not_called() interface.close.assert_not_called() self.assertIs(interface_state.interface, interface) def test_initialize_globals_resets_and_populates_runtime_state(self) -> None: ui_state.channel_list = ["stale"] ui_state.all_messages = {"stale": [("old", "message")]} ui_state.notifications = [1] ui_state.packet_buffer = ["packet"] ui_state.node_list = [99] ui_state.selected_channel = 3 ui_state.selected_message = 4 ui_state.selected_node = 5 ui_state.start_index = [9, 9, 9] config.single_pane_mode = "True" with mock.patch.object(entrypoint, "get_nodeNum", return_value=123): with mock.patch.object(entrypoint, "get_channels", return_value=["Primary"]) as get_channels: with mock.patch.object(entrypoint, "get_node_list", return_value=[123, 456]) as get_node_list: with mock.patch.object(entrypoint.pub, "subscribe") as subscribe: with mock.patch.object(entrypoint, "init_nodedb") as init_nodedb: with mock.patch.object(entrypoint, "seed_demo_messages") as seed_demo_messages: with mock.patch.object(entrypoint, "load_messages_from_db") as load_messages: entrypoint.initialize_globals(seed_demo=True) self.assertEqual(ui_state.channel_list, ["Primary"]) self.assertEqual(ui_state.all_messages, {}) self.assertEqual(ui_state.notifications, []) self.assertEqual(ui_state.packet_buffer, []) self.assertEqual(ui_state.node_list, [123, 456]) self.assertEqual(ui_state.selected_channel, 0) self.assertEqual(ui_state.selected_message, 0) self.assertEqual(ui_state.selected_node, 0) self.assertEqual(ui_state.start_index, [0, 0, 0]) self.assertTrue(ui_state.single_pane_mode) self.assertEqual(interface_state.myNodeNum, 123) get_channels.assert_called_once_with() get_node_list.assert_called_once_with() subscribe.assert_called_once_with(entrypoint.on_receive, "meshtastic.receive") init_nodedb.assert_called_once_with() seed_demo_messages.assert_called_once_with() load_messages.assert_called_once_with() def test_ensure_min_rows_retries_until_terminal_is_large_enough(self) -> None: stdscr = mock.Mock() stdscr.getmaxyx.side_effect = [(10, 80), (11, 80)] with mock.patch.object(entrypoint, "dialog") as dialog: with mock.patch.object(entrypoint.curses, "update_lines_cols") as update_lines_cols: entrypoint.ensure_min_rows(stdscr, min_rows=11) dialog.assert_called_once() update_lines_cols.assert_called_once_with() stdscr.clear.assert_called_once_with() stdscr.refresh.assert_called_once_with() def test_start_prints_help_and_exits_zero(self) -> None: parser = mock.Mock() with mock.patch.object(entrypoint.sys, "argv", ["contact", "--help"]): with mock.patch.object(entrypoint, "setup_parser", return_value=parser): with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock: with self.assertRaises(SystemExit) as raised: entrypoint.start() self.assertEqual(raised.exception.code, 0) parser.print_help.assert_called_once_with() exit_mock.assert_called_once_with(0) def test_start_runs_curses_wrapper_and_closes_interface(self) -> None: interface = mock.Mock() interface_state.interface = interface with mock.patch.object(entrypoint.sys, "argv", ["contact"]): with mock.patch.object(entrypoint.curses, "wrapper") as wrapper: entrypoint.start() wrapper.assert_called_once_with(entrypoint.main) interface.close.assert_called_once_with() def test_start_does_not_crash_when_wrapper_returns_without_interface(self) -> None: interface_state.interface = None with mock.patch.object(entrypoint.sys, "argv", ["contact"]): with mock.patch.object(entrypoint.curses, "wrapper") as wrapper: entrypoint.start() wrapper.assert_called_once_with(entrypoint.main) def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None: stdscr = mock.Mock() args = Namespace(settings=False, demo_screenshot=False) with mock.patch.object(entrypoint, "setup_colors"): with mock.patch.object(entrypoint, "ensure_min_rows"): with mock.patch.object(entrypoint, "draw_splash"): with mock.patch.object(entrypoint, "setup_parser") as setup_parser: with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None): with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals: setup_parser.return_value.parse_args.return_value = args entrypoint.main(stdscr) initialize_globals.assert_not_called() def test_start_handles_keyboard_interrupt(self) -> None: interface = mock.Mock() interface_state.interface = interface with mock.patch.object(entrypoint.sys, "argv", ["contact"]): with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt): with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock: with self.assertRaises(SystemExit) as raised: entrypoint.start() self.assertEqual(raised.exception.code, 0) interface.close.assert_called_once_with() exit_mock.assert_called_once_with(0) def test_start_handles_keyboard_interrupt_with_no_interface(self) -> None: interface_state.interface = None with mock.patch.object(entrypoint.sys, "argv", ["contact"]): with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt): with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock: with self.assertRaises(SystemExit) as raised: entrypoint.start() self.assertEqual(raised.exception.code, 0) exit_mock.assert_called_once_with(0) def test_start_handles_fatal_exception_and_exits_one(self) -> None: with mock.patch.object(entrypoint.sys, "argv", ["contact"]): with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")): with mock.patch.object(entrypoint.curses, "endwin") as endwin: with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc: with mock.patch("builtins.print") as print_mock: with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock: with self.assertRaises(SystemExit) as raised: entrypoint.start() self.assertEqual(raised.exception.code, 1) endwin.assert_called_once_with() print_exc.assert_called_once_with() print_mock.assert_any_call("Fatal error:", mock.ANY) exit_mock.assert_called_once_with(1) ================================================ FILE: tests/test_menus.py ================================================ from types import SimpleNamespace import unittest from meshtastic.protobuf import config_pb2, module_config_pb2 from contact.ui.menus import generate_menu_from_protobuf class MenusTests(unittest.TestCase): def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None: local_node = SimpleNamespace( localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(), getChannelByChannelIndex=lambda _: None, ) interface = SimpleNamespace( localNode=local_node, getMyNodeInfo=lambda: { "user": {"longName": "Test User", "shortName": "TU", "isLicensed": False}, "position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}, }, ) menu = generate_menu_from_protobuf(interface) keys = list(menu["Main Menu"].keys()) self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config")) self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config") ================================================ FILE: tests/test_nav_utils.py ================================================ import unittest from unittest import mock from contact.ui import nav_utils from contact.ui.nav_utils import truncate_with_ellipsis, wrap_text from contact.utilities.singleton import ui_state class NavUtilsTests(unittest.TestCase): def setUp(self) -> None: ui_state.current_window = 0 ui_state.node_list = [] ui_state.start_index = [0, 0, 0] def test_wrap_text_splits_wide_characters_by_display_width(self) -> None: self.assertEqual(wrap_text("🔐🔐🔐", 4), ["🔐", "🔐", "🔐"]) def test_truncate_with_ellipsis_respects_display_width(self) -> None: self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…") def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -> None: ui_state.current_window = 2 ui_state.start_index = [0, 0, 0] menu_win = mock.Mock() menu_win.getbegyx.return_value = (0, 0) menu_win.getmaxyx.return_value = (8, 20) menu_pad = mock.Mock() menu_pad.getmaxyx.return_value = (4, 20) with mock.patch.object(nav_utils, "get_node_color", side_effect=[11, 22]): nav_utils.highlight_line(menu_win, menu_pad, 0, 1, 5) self.assertEqual( menu_pad.chgat.call_args_list, [mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)], ) ================================================ FILE: tests/test_rx_handler.py ================================================ import unittest from unittest import mock import contact.ui.default_config as config from contact.message_handlers import rx_handler from contact.utilities.singleton import interface_state, menu_state, ui_state from tests.test_support import reset_singletons, restore_config, snapshot_config class RxHandlerTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() self.saved_config = snapshot_config("notification_sound", "message_prefix") config.notification_sound = "False" def tearDown(self) -> None: restore_config(self.saved_config) reset_singletons() def test_on_receive_text_message_refreshes_selected_channel(self) -> None: interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] ui_state.all_messages = {"Primary": []} ui_state.selected_channel = 0 packet = { "from": 222, "to": 999, "channel": 0, "hopStart": 3, "hopLimit": 1, "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}, } with mock.patch.object(rx_handler, "refresh_node_list", return_value=True): with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw: with mock.patch.object(rx_handler, "add_notification") as add_notification: with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db: with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"): rx_handler.on_receive(packet, interface=None) self.assertEqual(request_ui_redraw.call_args_list, [mock.call(nodes=True), mock.call(messages=True, scroll_messages_to_bottom=True)]) add_notification.assert_not_called() save_message_to_db.assert_called_once_with("Primary", 222, "hello") self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello") self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0]) self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0]) def test_on_receive_direct_message_adds_channel_and_notification(self) -> None: interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] ui_state.all_messages = {"Primary": []} ui_state.selected_channel = 0 packet = { "from": 222, "to": 111, "hopStart": 1, "hopLimit": 1, "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"dm"}, } with mock.patch.object(rx_handler, "refresh_node_list", return_value=False): with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw: with mock.patch.object(rx_handler, "add_notification") as add_notification: with mock.patch.object(rx_handler, "update_node_info_in_db") as update_node_info_in_db: with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db: with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"): rx_handler.on_receive(packet, interface=None) self.assertIn(222, ui_state.channel_list) self.assertIn(222, ui_state.all_messages) request_ui_redraw.assert_called_once_with(channels=True) add_notification.assert_called_once_with(1) update_node_info_in_db.assert_called_once_with(222, chat_archived=False) save_message_to_db.assert_called_once_with(222, 222, "dm") def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None: ui_state.packet_buffer = list(range(25)) ui_state.display_log = True ui_state.current_window = 4 with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw: rx_handler.on_receive({"id": "new"}, interface=None) request_ui_redraw.assert_called_once_with(packetlog=True) self.assertEqual(len(ui_state.packet_buffer), 20) self.assertEqual(ui_state.packet_buffer[-1], {"id": "new"}) self.assertTrue(menu_state.need_redraw) ================================================ FILE: tests/test_save_to_radio.py ================================================ from types import SimpleNamespace import unittest from unittest import mock from contact.utilities.save_to_radio import save_changes class SaveToRadioTests(unittest.TestCase): def build_interface(self): node = mock.Mock() node.localConfig = SimpleNamespace( lora=SimpleNamespace(region=0, serial_enabled=False), device=SimpleNamespace(role="CLIENT", name="node"), security=SimpleNamespace(debug_log_api_enabled=False, serial_enabled=False, admin_key=[]), display=SimpleNamespace(flip_screen=False, units=0), power=SimpleNamespace(is_power_saving=False, adc_enabled=False), network=SimpleNamespace(wifi_enabled=False), bluetooth=SimpleNamespace(enabled=False), ) node.moduleConfig = SimpleNamespace(mqtt=SimpleNamespace(enabled=False)) interface = mock.Mock() interface.getNode.return_value = node return interface, node def test_save_changes_returns_true_for_lora_writes_that_require_reconnect(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"]) reconnect_required = save_changes(interface, {"region": 7}, menu_state) self.assertTrue(reconnect_required) self.assertEqual(node.localConfig.lora.region, 7) node.writeConfig.assert_called_once_with("lora") def test_save_changes_returns_false_when_nothing_changed(self) -> None: interface = mock.Mock() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"]) self.assertFalse(save_changes(interface, {}, menu_state)) def test_save_changes_returns_false_for_non_rebooting_security_fields(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"]) reconnect_required = save_changes(interface, {"serial_enabled": True}, menu_state) self.assertFalse(reconnect_required) self.assertTrue(node.localConfig.security.serial_enabled) def test_save_changes_returns_true_for_rebooting_security_fields(self) -> None: interface, _node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"]) reconnect_required = save_changes(interface, {"admin_key": [b"12345678"]}, menu_state) self.assertTrue(reconnect_required) def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"]) self.assertFalse(save_changes(interface, {"name": "renamed"}, menu_state)) self.assertEqual(node.localConfig.device.name, "renamed") node.writeConfig.reset_mock() self.assertTrue(save_changes(interface, {"role": "ROUTER"}, menu_state)) self.assertEqual(node.localConfig.device.role, "ROUTER") def test_save_changes_returns_true_for_network_settings(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Network"]) reconnect_required = save_changes(interface, {"wifi_enabled": True}, menu_state) self.assertTrue(reconnect_required) self.assertTrue(node.localConfig.network.wifi_enabled) def test_save_changes_returns_true_only_for_rebooting_power_fields(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Power"]) self.assertFalse(save_changes(interface, {"adc_enabled": True}, menu_state)) self.assertTrue(node.localConfig.power.adc_enabled) node.writeConfig.reset_mock() self.assertTrue(save_changes(interface, {"is_power_saving": True}, menu_state)) self.assertTrue(node.localConfig.power.is_power_saving) def test_save_changes_returns_true_for_module_settings(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "Module Settings", "Mqtt"]) reconnect_required = save_changes(interface, {"enabled": True}, menu_state) self.assertTrue(reconnect_required) self.assertTrue(node.moduleConfig.mqtt.enabled) def test_save_changes_returns_true_for_user_name_changes(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"]) reconnect_required = save_changes(interface, {"longName": "Node"}, menu_state) self.assertTrue(reconnect_required) node.setOwner.assert_called_once() def test_save_changes_returns_true_for_user_license_changes(self) -> None: interface, node = self.build_interface() menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"]) reconnect_required = save_changes(interface, {"isLicensed": True}, menu_state) self.assertTrue(reconnect_required) node.setOwner.assert_called_once() ================================================ FILE: tests/test_settings.py ================================================ from argparse import Namespace from types import SimpleNamespace import unittest from unittest import mock import contact.settings as settings class SettingsRuntimeTests(unittest.TestCase): def test_main_closes_interface_after_normal_settings_exit(self) -> None: stdscr = mock.Mock() args = Namespace() interface = mock.Mock() interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1))) with mock.patch.object(settings, "setup_colors"): with mock.patch.object(settings, "ensure_min_rows"): with mock.patch.object(settings, "draw_splash"): with mock.patch.object(settings.curses, "curs_set"): with mock.patch.object(settings, "setup_parser") as setup_parser: with mock.patch.object(settings, "initialize_interface", return_value=interface): with mock.patch.object(settings, "settings_menu") as settings_menu: setup_parser.return_value.parse_args.return_value = args settings.main(stdscr) settings_menu.assert_called_once_with(stdscr, interface) interface.close.assert_called_once_with() def test_main_closes_reconnected_interface_after_region_reset(self) -> None: stdscr = mock.Mock() args = Namespace() old_interface = mock.Mock() old_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=0))) new_interface = mock.Mock() new_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1))) with mock.patch.object(settings, "setup_colors"): with mock.patch.object(settings, "ensure_min_rows"): with mock.patch.object(settings, "draw_splash"): with mock.patch.object(settings.curses, "curs_set"): with mock.patch.object(settings, "setup_parser") as setup_parser: with mock.patch.object(settings, "initialize_interface", return_value=old_interface): with mock.patch.object(settings, "get_list_input", return_value="Yes"): with mock.patch.object(settings, "set_region") as set_region: with mock.patch.object( settings, "reconnect_interface", return_value=new_interface ) as reconnect_interface: with mock.patch.object(settings, "settings_menu") as settings_menu: setup_parser.return_value.parse_args.return_value = args settings.main(stdscr) set_region.assert_called_once_with(old_interface) reconnect_interface.assert_called_once_with(args) settings_menu.assert_called_once_with(stdscr, new_interface) old_interface.close.assert_called_once_with() new_interface.close.assert_called_once_with() def test_main_closes_interface_when_settings_menu_raises(self) -> None: stdscr = mock.Mock() args = Namespace() interface = mock.Mock() interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1))) with mock.patch.object(settings, "setup_colors"): with mock.patch.object(settings, "ensure_min_rows"): with mock.patch.object(settings, "draw_splash"): with mock.patch.object(settings.curses, "curs_set"): with mock.patch.object(settings, "setup_parser") as setup_parser: with mock.patch.object(settings, "initialize_interface", return_value=interface): with mock.patch.object(settings, "settings_menu", side_effect=RuntimeError("boom")): setup_parser.return_value.parse_args.return_value = args with self.assertRaises(RuntimeError): settings.main(stdscr) interface.close.assert_called_once_with() ================================================ FILE: tests/test_support.py ================================================ import threading import contact.ui.default_config as config from contact.ui.ui_state import AppState, ChatUIState, InterfaceState, MenuState from contact.utilities.singleton import app_state, interface_state, menu_state, ui_state def reset_singletons() -> None: _reset_instance(ui_state, ChatUIState()) _reset_instance(interface_state, InterfaceState()) _reset_instance(menu_state, MenuState()) _reset_instance(app_state, AppState()) app_state.lock = threading.Lock() def restore_config(saved: dict) -> None: for key, value in saved.items(): setattr(config, key, value) def snapshot_config(*keys: str) -> dict: return {key: getattr(config, key) for key in keys} def _reset_instance(target: object, replacement: object) -> None: target.__dict__.clear() target.__dict__.update(replacement.__dict__) ================================================ FILE: tests/test_telemetry_beautifier.py ================================================ import unittest from unittest import mock from contact.utilities.telemetry_beautifier import get_chunks, humanize_wind_direction class TelemetryBeautifierTests(unittest.TestCase): def test_humanize_wind_direction_handles_boundaries(self) -> None: self.assertEqual(humanize_wind_direction(0), "N") self.assertEqual(humanize_wind_direction(90), "E") self.assertEqual(humanize_wind_direction(225), "SW") self.assertIsNone(humanize_wind_direction(-1)) def test_get_chunks_formats_known_and_unknown_values(self) -> None: rendered = get_chunks("uptime_seconds:7200\nwind_direction:90\nlatitude_i:123456789\nunknown:abc\n") self.assertIn("🆙 2.0h", rendered) self.assertIn("⮆ E", rendered) self.assertIn("🌍 12.345679", rendered) self.assertIn("unknown:abc", rendered) def test_get_chunks_formats_time_values(self) -> None: with mock.patch("contact.utilities.telemetry_beautifier.datetime.datetime") as mocked_datetime: mocked_datetime.fromtimestamp.return_value.strftime.return_value = "01.01.1970 00:00" rendered = get_chunks("time:0\n") self.assertIn("🕔 01.01.1970 00:00", rendered) ================================================ FILE: tests/test_tx_handler.py ================================================ from types import SimpleNamespace import unittest from unittest import mock from meshtastic import BROADCAST_NUM import contact.ui.default_config as config from contact.message_handlers import tx_handler from contact.utilities.singleton import interface_state, ui_state from tests.test_support import reset_singletons, restore_config, snapshot_config class TxHandlerTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() tx_handler.ack_naks.clear() self.saved_config = snapshot_config("sent_message_prefix", "ack_str", "ack_implicit_str", "nak_str", "ack_unknown_str") def tearDown(self) -> None: tx_handler.ack_naks.clear() restore_config(self.saved_config) reset_singletons() def test_send_message_on_named_channel_tracks_ack_request(self) -> None: interface = mock.Mock() interface.sendText.return_value = SimpleNamespace(id="req-1") interface_state.interface = interface interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] ui_state.all_messages = {"Primary": []} with mock.patch.object(tx_handler, "save_message_to_db", return_value=999) as save_message_to_db: with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "): tx_handler.send_message("hello", channel=0) interface.sendText.assert_called_once_with( text="hello", destinationId=BROADCAST_NUM, wantAck=True, wantResponse=False, onResponse=tx_handler.onAckNak, channelIndex=0, ) save_message_to_db.assert_called_once_with("Primary", 111, "hello") self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary") self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1) self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999) self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello") def test_send_message_to_direct_node_uses_node_as_destination(self) -> None: interface = mock.Mock() interface.sendText.return_value = SimpleNamespace(id="req-2") interface_state.interface = interface interface_state.myNodeNum = 111 ui_state.channel_list = [222] ui_state.all_messages = {222: []} with mock.patch.object(tx_handler, "save_message_to_db", return_value=123): with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "): tx_handler.send_message("dm", channel=0) interface.sendText.assert_called_once_with( text="dm", destinationId=222, wantAck=True, wantResponse=False, onResponse=tx_handler.onAckNak, channelIndex=0, ) self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222) def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None: interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] ui_state.selected_channel = 0 ui_state.all_messages = {"Primary": [("pending", "hello")]} tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55} packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}} with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak: with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "): with mock.patch("contact.ui.contact_ui.request_ui_redraw") as request_ui_redraw: tx_handler.onAckNak(packet) update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Ack") request_ui_redraw.assert_called_once_with(messages=True) self.assertIn(config.sent_message_prefix, ui_state.all_messages["Primary"][0][0]) self.assertIn(config.ack_str, ui_state.all_messages["Primary"][0][0]) def test_on_ack_nak_uses_implicit_marker_for_self_ack(self) -> None: interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] ui_state.selected_channel = 0 ui_state.all_messages = {"Primary": [("pending", "hello")]} tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55} packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}} with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak: with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "): with mock.patch("contact.ui.contact_ui.request_ui_redraw"): tx_handler.onAckNak(packet) update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Implicit") self.assertIn(config.ack_implicit_str, ui_state.all_messages["Primary"][0][0]) ================================================ FILE: tests/test_utils.py ================================================ import unittest from unittest import mock import contact.ui.default_config as config from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface from contact.utilities.singleton import interface_state, ui_state from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf from tests.test_support import reset_singletons, restore_config, snapshot_config class UtilsTests(unittest.TestCase): def setUp(self) -> None: reset_singletons() self.saved_config = snapshot_config("node_sort") def tearDown(self) -> None: restore_config(self.saved_config) reset_singletons() def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None: config.node_sort = "lastHeard" interface = build_demo_interface() interface_state.interface = interface interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM node_list = get_node_list() self.assertEqual(node_list[0], DEMO_LOCAL_NODE_NUM) self.assertEqual(node_list[-1], 0xA1000008) def test_add_new_message_groups_messages_by_hour(self) -> None: ui_state.all_messages = {"MediumFast": []} with mock.patch("contact.utilities.utils.time.time", side_effect=[1000, 1000]): with mock.patch("contact.utilities.utils.time.strftime", return_value="[00:16:40] "): with mock.patch("contact.utilities.utils.datetime.datetime") as mocked_datetime: mocked_datetime.fromtimestamp.return_value.strftime.return_value = "2025-02-04 17:00" add_new_message("MediumFast", ">> Test: ", "First") add_new_message("MediumFast", ">> Test: ", "Second") self.assertEqual( ui_state.all_messages["MediumFast"], [ ("-- 2025-02-04 17:00 --", ""), ("[00:16:40] >> Test: ", "First"), ("[00:16:40] >> Test: ", "Second"), ], ) def test_get_channels_populates_message_buckets_for_device_channels(self) -> None: interface_state.interface = build_demo_interface() ui_state.channel_list = [] ui_state.all_messages = {} channels = get_channels() self.assertIn("MediumFast", channels) self.assertIn("Another Channel", channels) self.assertIn("MediumFast", ui_state.all_messages) self.assertIn("Another Channel", ui_state.all_messages) def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None: interface = build_demo_interface() interface.localNode.channels[0].settings.name = "Renamed Channel" interface_state.interface = interface ui_state.channel_list = ["MediumFast", "Another Channel", 2701131788] ui_state.all_messages = { "MediumFast": [("prefix", "first")], "Another Channel": [("prefix", "second")], 2701131788: [("prefix", "dm")], } ui_state.selected_channel = 2 channels = get_channels() self.assertEqual(channels[0], "Renamed Channel") self.assertEqual(channels[1], "Another Channel") self.assertEqual(channels[2], 2701131788) self.assertEqual(ui_state.all_messages["Renamed Channel"], [("prefix", "first")]) self.assertEqual(ui_state.all_messages["Another Channel"], [("prefix", "second")]) self.assertEqual(ui_state.all_messages[2701131788], [("prefix", "dm")]) self.assertNotIn("MediumFast", ui_state.all_messages) def test_parse_protobuf_returns_string_payload_unchanged(self) -> None: packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": "hello"}} self.assertEqual(parse_protobuf(packet), "hello") def test_parse_protobuf_returns_placeholder_for_text_messages(self) -> None: packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}} self.assertEqual(parse_protobuf(packet), "✉️") ================================================ FILE: tests/test_validation_rules.py ================================================ import unittest from contact.utilities.validation_rules import get_validation_for class ValidationRulesTests(unittest.TestCase): def test_get_validation_for_matches_exact_keys(self) -> None: self.assertEqual(get_validation_for("shortName"), {"max_length": 4}) def test_get_validation_for_matches_substrings(self) -> None: self.assertEqual(get_validation_for("config.position.latitude"), {"min_value": -90, "max_value": 90}) def test_get_validation_for_returns_empty_dict_for_unknown_key(self) -> None: self.assertEqual(get_validation_for("totally_unknown"), {})