Full Code of pdxlocations/contact for AI

main 69cb568d2cd9 cached
68 files
481.3 KB
112.5k tokens
379 symbols
1 requests
Download .txt
Showing preview only (520K chars total). Download the full file or copy to clipboard to get everything.
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. <https://fsf.org/>
 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.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    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 <https://www.gnu.org/licenses/>.

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:

    <program>  Copyright (C) <year>  <name of author>
    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
<https://www.gnu.org/licenses/>.

  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
<https://www.gnu.org/licenses/why-not-lgpl.html>.


================================================
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.

<img width="991" height="516" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/76722145-e8a4-4f01-8898-f4ae794b5d7b" />

<br><br>
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`

<img width="696" alt="Screenshot 2025-04-08 at 6 10 06 PM" src="https://github.com/user-attachments/assets/3d5e3964-f009-4772-bd6e-91b907c65a3b" />

### 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`:

<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54 PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />

## 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?
     
Download .txt
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
Download .txt
SYMBOL INDEX (379 symbols across 54 files)

FILE: contact/__main__.py
  function prompt_region_if_unset (line 66) | def prompt_region_if_unset(args: object, stdscr: Optional[curses.window]...
  function close_interface (line 77) | def close_interface(interface: object, timeout_seconds: float = DEFAULT_...
  function interface_is_ready (line 109) | def interface_is_ready(interface: object) -> bool:
  function initialize_runtime_interface_with_retry (line 116) | def initialize_runtime_interface_with_retry(stdscr: curses.window, args:...
  function initialize_globals (line 135) | def initialize_globals(seed_demo: bool = False) -> None:
  function initialize_runtime_interface (line 159) | def initialize_runtime_interface(args: object):
  function main (line 166) | def main(stdscr: curses.window) -> None:
  function ensure_min_rows (line 210) | def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
  function start (line 228) | def start() -> None:

FILE: contact/message_handlers/bot_handler.py
  function _get_bot_catch_words (line 13) | def _get_bot_catch_words() -> set[str]:
  function is_bot_message (line 23) | def is_bot_message(message: str) -> bool:
  function bot_respond (line 27) | def bot_respond(packet: Dict[str, Any], message: str, send_channel: int)...

FILE: contact/message_handlers/rx_handler.py
  function schedule_notification_sound (line 16) | def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) ...
  function play_sound (line 66) | def play_sound():
  function on_receive (line 106) | def on_receive(packet: Dict[str, Any], interface: Any) -> None:

FILE: contact/message_handlers/tx_handler.py
  function onAckNak (line 27) | def onAckNak(packet: Dict[str, Any]) -> None:
  function on_response_traceroute (line 66) | def on_response_traceroute(packet: Dict[str, Any]) -> None:
  function send_message (line 167) | def send_message(message: str, destination: int = BROADCAST_NUM, channel...
  function send_traceroute (line 200) | def send_traceroute() -> None:

FILE: contact/settings.py
  function close_interface (line 19) | def close_interface(interface: object) -> None:
  function main (line 26) | def main(stdscr: curses.window) -> None:
  function ensure_min_rows (line 66) | def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:

FILE: contact/ui/colors.py
  function setup_colors (line 16) | def setup_colors(reinit: bool = False) -> None:
  function get_color (line 33) | def get_color(category: str, bold: bool = False, reverse: bool = False, ...

FILE: contact/ui/contact_ui.py
  function request_ui_redraw (line 36) | def request_ui_redraw(
  function process_pending_ui_updates (line 53) | def process_pending_ui_updates(stdscr: curses.window) -> None:
  function draw_window_arrows (line 84) | def draw_window_arrows(window_id: int) -> None:
  function compute_widths (line 103) | def compute_widths(total_w: int, focus: int):
  function paint_frame (line 116) | def paint_frame(win, selected: bool) -> None:
  function get_channel_row_color (line 123) | def get_channel_row_color(index: int) -> int:
  function get_node_row_color (line 131) | def get_node_row_color(index: int, highlight: bool = False) -> int:
  function refresh_node_selection (line 143) | def refresh_node_selection(old_index: int = -1, highlight: bool = False)...
  function refresh_main_window (line 166) | def refresh_main_window(window_id: int, selected: bool) -> None:
  function get_node_display_name (line 184) | def get_node_display_name(node_num: int, node: dict) -> str:
  function get_selected_channel_title (line 189) | def get_selected_channel_title() -> str:
  function get_window_title (line 199) | def get_window_title(window: int) -> str:
  function draw_frame_title (line 207) | def draw_frame_title(box: curses.window, title: str) -> None:
  function handle_resize (line 226) | def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
  function drain_resize_events (line 340) | def drain_resize_events(input_win: curses.window) -> Union[str, int, None]:
  function main_ui (line 358) | def main_ui(stdscr: curses.window) -> None:
  function handle_up (line 466) | def handle_up() -> None:
  function handle_down (line 476) | def handle_down() -> None:
  function handle_home (line 486) | def handle_home() -> None:
  function handle_end (line 499) | def handle_end() -> None:
  function handle_pageup (line 512) | def handle_pageup() -> None:
  function handle_pagedown (line 526) | def handle_pagedown() -> None:
  function handle_leftright (line 542) | def handle_leftright(char: int) -> None:
  function handle_function_keys (line 560) | def handle_function_keys(char: int) -> None:
  function handle_enter (line 590) | def handle_enter(input_text: str) -> str:
  function handle_f5_key (line 637) | def handle_f5_key(stdscr: curses.window) -> None:
  function handle_ctrl_t (line 850) | def handle_ctrl_t(stdscr: curses.window) -> None:
  function handle_backspace (line 885) | def handle_backspace(entry_win: curses.window, input_text: str) -> str:
  function handle_backtick (line 897) | def handle_backtick(stdscr: curses.window) -> None:
  function handle_ctrl_p (line 911) | def handle_ctrl_p() -> None:
  function handle_ctrl_k (line 924) | def handle_ctrl_k(stdscr: curses.window) -> None:
  function handle_ctrl_b (line 952) | def handle_ctrl_b(stdscr: curses.window) -> None:
  function handle_ctrl_d (line 978) | def handle_ctrl_d() -> None:
  function handle_ctrl_fslash (line 1024) | def handle_ctrl_fslash() -> None:
  function handle_ctrl_f (line 1030) | def handle_ctrl_f(stdscr: curses.window) -> None:
  function handle_ctlr_g (line 1074) | def handle_ctlr_g(stdscr: curses.window) -> None:
  function draw_channel_list (line 1111) | def draw_channel_list() -> None:
  function draw_messages_window (line 1155) | def draw_messages_window(scroll_to_bottom: bool = False) -> None:
  function draw_node_list (line 1207) | def draw_node_list() -> None:
  function select_channel (line 1250) | def select_channel(idx: int) -> None:
  function scroll_channels (line 1272) | def scroll_channels(direction: int) -> None:
  function scroll_messages (line 1284) | def scroll_messages(direction: int) -> None:
  function select_node (line 1311) | def select_node(idx: int) -> None:
  function scroll_nodes (line 1326) | def scroll_nodes(direction: int) -> None:
  function draw_packetlog_win (line 1338) | def draw_packetlog_win() -> None:
  function search (line 1392) | def search(win: int) -> None:
  function refresh_pad (line 1448) | def refresh_pad(window: int) -> None:
  function add_notification (line 1520) | def add_notification(channel_number: int) -> None:
  function remove_notification (line 1525) | def remove_notification(channel_number: int) -> None:
  function draw_text_field (line 1530) | def draw_text_field(win: curses.window, text: str, color: int) -> None:
  function draw_centered_text_field (line 1551) | def draw_centered_text_field(win: curses.window, text: str, y_offset: in...

FILE: contact/ui/control_ui.py
  function get_menu_width (line 42) | def get_menu_width() -> int:
  function _is_repeated_field (line 63) | def _is_repeated_field(field_desc) -> bool:
  function reload_translations (line 71) | def reload_translations() -> None:
  function get_translated_header (line 77) | def get_translated_header(menu_path: List[str]) -> str:
  function display_menu (line 92) | def display_menu() -> tuple[object, object]:
  function draw_help_window (line 197) | def draw_help_window(
  function get_input_type_for_field (line 220) | def get_input_type_for_field(field) -> type:
  function reconnect_interface_with_splash (line 229) | def reconnect_interface_with_splash(stdscr: object, interface: object) -...
  function reconnect_after_admin_action (line 244) | def reconnect_after_admin_action(stdscr: object, interface: object, acti...
  function request_factory_reset (line 250) | def request_factory_reset(node: object, full: bool = False):
  function redraw_main_ui_after_reconnect (line 270) | def redraw_main_ui_after_reconnect(stdscr: object) -> None:
  function settings_menu (line 282) | def settings_menu(stdscr: object, interface: object) -> None:
  function rebuild_menu_at_current_path (line 784) | def rebuild_menu_at_current_path(interface, menu_state):
  function set_region (line 794) | def set_region(interface: object) -> None:

FILE: contact/ui/default_config.py
  function reload_config (line 17) | def reload_config() -> None:
  function _is_writable_dir (line 23) | def _is_writable_dir(path: str) -> bool:
  function _get_config_root (line 39) | def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_...
  function get_localisation_options (line 75) | def get_localisation_options(localisations_path: Optional[str] = None) -...
  function get_localisation_file (line 92) | def get_localisation_file(language: str, localisations_path: Optional[st...
  function format_json_single_line_arrays (line 112) | def format_json_single_line_arrays(data: Dict[str, object], indent: int ...
  function update_dict (line 132) | def update_dict(default: Dict[str, object], actual: Dict[str, object]) -...
  function initialize_config (line 144) | def initialize_config() -> Dict[str, object]:
  function assign_config_variables (line 276) | def assign_config_variables(loaded_config: Dict[str, object]) -> None:

FILE: contact/ui/dialog.py
  function dialog (line 9) | def dialog(title: str, message: str) -> None:

FILE: contact/ui/menus.py
  function encode_if_bytes (line 10) | def encode_if_bytes(value: Any) -> str:
  function extract_fields (line 17) | def extract_fields(
  function generate_menu_from_protobuf (line 60) | def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:

FILE: contact/ui/nav_utils.py
  function get_node_color (line 12) | def get_node_color(node_index: int, reverse: bool = False):
  function get_save_option_label (line 30) | def get_save_option_label() -> str:
  function move_highlight (line 33) | def move_highlight(
  function draw_arrows (line 152) | def draw_arrows(
  function update_help_window (line 170) | def update_help_window(
  function get_wrapped_help_text (line 237) | def get_wrapped_help_text(
  function text_width (line 330) | def text_width(text: str) -> int:
  function slice_to_width (line 334) | def slice_to_width(text: str, max_width: int) -> str:
  function pad_to_width (line 349) | def pad_to_width(text: str, width: int) -> str:
  function truncate_with_ellipsis (line 354) | def truncate_with_ellipsis(text: str, width: int) -> str:
  function split_text_to_width_chunks (line 364) | def split_text_to_width_chunks(text: str, width: int) -> List[str]:
  function wrap_text (line 379) | def wrap_text(text: str, wrap_width: int) -> List[str]:
  function move_main_highlight (line 418) | def move_main_highlight(
  function highlight_line (line 447) | def highlight_line(
  function draw_main_arrows (line 476) | def draw_main_arrows(win: object, max_index: int, window: int, **kwargs)...
  function get_msg_window_lines (line 501) | def get_msg_window_lines(messages_win, packetlog_win) -> None:

FILE: contact/ui/splash.py
  function draw_splash (line 5) | def draw_splash(stdscr: object) -> None:

FILE: contact/ui/ui_state.py
  class MenuState (line 6) | class MenuState:
  class ChatUIState (line 17) | class ChatUIState:
  class InterfaceState (line 46) | class InterfaceState:
  class AppState (line 52) | class AppState:

FILE: contact/ui/user_config.py
  function reload_translations (line 23) | def reload_translations(language: Optional[str] = None) -> None:
  function get_app_settings_key (line 31) | def get_app_settings_key(menu_path: List[str], selected_key: str) -> str:
  function get_app_settings_path_parts (line 41) | def get_app_settings_path_parts(menu_path: List[str]) -> List[str]:
  function lookup_app_settings_label (line 50) | def lookup_app_settings_label(full_key: str, fallback: str) -> str:
  function get_app_settings_help_path_parts (line 61) | def get_app_settings_help_path_parts(menu_path: List[str]) -> List[str]:
  function get_app_settings_header (line 68) | def get_app_settings_header(menu_path: List[str]) -> str:
  function get_effective_width (line 84) | def get_effective_width() -> int:
  function edit_color_pair (line 89) | def edit_color_pair(key: str, display_label: str, current_value: List[st...
  function edit_value (line 116) | def edit_value(key: str, display_label: str, current_value: str) -> str:
  function display_menu (line 255) | def display_menu() -> tuple[Any, Any, List[str]]:
  function update_app_settings_help (line 369) | def update_app_settings_help(menu_win: curses.window, options: List[str]...
  function json_editor (line 385) | def json_editor(stdscr: curses.window, menu_state: Any) -> None:
  function save_json (line 568) | def save_json(file_path: str, data: Dict[str, Any]) -> None:
  function main (line 576) | def main(stdscr: curses.window) -> None:

FILE: contact/utilities/arg_parser.py
  function setup_parser (line 4) | def setup_parser() -> ArgumentParser:

FILE: contact/utilities/config_io.py
  function _is_repeated_field (line 12) | def _is_repeated_field(field_desc) -> bool:
  function traverseConfig (line 23) | def traverseConfig(config_root, config, interface_config) -> bool:
  function splitCompoundName (line 36) | def splitCompoundName(comp_name: str) -> List[str]:
  function setPref (line 45) | def setPref(config, comp_name, raw_val) -> bool:
  function config_import (line 137) | def config_import(interface, filename):
  function config_export (line 213) | def config_export(interface) -> str:

FILE: contact/utilities/control_utils.py
  function transform_menu_path (line 5) | def transform_menu_path(menu_path: List[str]) -> List[str]:

FILE: contact/utilities/db_handler.py
  function get_table_name (line 14) | def get_table_name(channel: str) -> str:
  function save_message_to_db (line 21) | def save_message_to_db(channel: str, user_id: str, message_text: str) ->...
  function update_ack_nak (line 55) | def update_ack_nak(channel: str, timestamp: int, message: str, ack: str)...
  function load_messages_from_db (line 78) | def load_messages_from_db() -> None:
  function init_nodedb (line 172) | def init_nodedb() -> None:
  function maybe_store_nodeinfo_in_db (line 202) | def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None:
  function update_node_info_in_db (line 221) | def update_node_info_in_db(
  function ensure_node_table_exists (line 301) | def ensure_node_table_exists() -> None:
  function ensure_table_exists (line 317) | def ensure_table_exists(table_name: str, schema: str) -> None:
  function get_name_from_database (line 332) | def get_name_from_database(user_id: int, type: str = "long") -> str:
  function is_chat_archived (line 368) | def is_chat_archived(user_id: int) -> int:

FILE: contact/utilities/demo_data.py
  class DemoChannelSettings (line 19) | class DemoChannelSettings:
  class DemoChannel (line 24) | class DemoChannel:
  class DemoLoRaConfig (line 30) | class DemoLoRaConfig:
  class DemoLocalConfig (line 36) | class DemoLocalConfig:
  class DemoLocalNode (line 40) | class DemoLocalNode:
    method __init__ (line 41) | def __init__(self, interface: "DemoInterface", channels: List[DemoChan...
    method setFavorite (line 46) | def setFavorite(self, node_num: int) -> None:
    method removeFavorite (line 49) | def removeFavorite(self, node_num: int) -> None:
    method setIgnored (line 52) | def setIgnored(self, node_num: int) -> None:
    method removeIgnored (line 55) | def removeIgnored(self, node_num: int) -> None:
    method removeNode (line 58) | def removeNode(self, node_num: int) -> None:
  class DemoInterface (line 62) | class DemoInterface:
    method __init__ (line 63) | def __init__(self, nodes: Dict[int, Dict[str, object]], channels: List...
    method getMyNodeInfo (line 68) | def getMyNodeInfo(self) -> Dict[str, int]:
    method getNode (line 71) | def getNode(self, selector: str) -> DemoLocalNode:
    method close (line 76) | def close(self) -> None:
  function build_demo_interface (line 80) | def build_demo_interface() -> DemoInterface:
  function configure_demo_database (line 123) | def configure_demo_database(base_dir: str = "") -> str:
  function seed_demo_messages (line 136) | def seed_demo_messages() -> None:
  function _build_node (line 162) | def _build_node(
  function _demo_messages (line 212) | def _demo_messages() -> Dict[Union[str, int], List[Tuple[str, str, int, ...

FILE: contact/utilities/emoji_utils.py
  function _regional_indicator_to_letter (line 21) | def _regional_indicator_to_letter(char: str) -> str:
  function _normalize_flag_emoji (line 25) | def _normalize_flag_emoji(text: str) -> str:
  function normalize_message_text (line 49) | def normalize_message_text(text: str) -> str:

FILE: contact/utilities/i18n.py
  function _load_translations (line 10) | def _load_translations() -> None:
  function t (line 21) | def t(key: str, default: Optional[str] = None, **kwargs: object) -> str:
  function t_text (line 30) | def t_text(text: str, **kwargs: object) -> str:

FILE: contact/utilities/ini_utils.py
  function parse_ini_file (line 5) | def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str...

FILE: contact/utilities/input_handlers.py
  function get_dialog_width (line 19) | def get_dialog_width() -> int:
  function invalid_input (line 28) | def invalid_input(window: curses.window, message: str, redraw_func: Opti...
  function get_text_input (line 41) | def get_text_input(prompt: str, selected_config: str, input_type: str) -...
  function get_admin_key_input (line 267) | def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
  function get_repeated_input (line 373) | def get_repeated_input(current_value: List[str]) -> Optional[str]:
  function get_fixed32_input (line 463) | def get_fixed32_input(current_value: int) -> int:
  function get_list_input (line 557) | def get_list_input(

FILE: contact/utilities/interfaces.py
  function initialize_interface (line 6) | def initialize_interface(args):
  function reconnect_interface (line 47) | def reconnect_interface(args, attempts: int = 15, delay_seconds: float =...

FILE: contact/utilities/save_to_radio.py
  function _collect_changed_keys (line 35) | def _collect_changed_keys(modified_settings):
  function _requires_reconnect (line 45) | def _requires_reconnect(menu_state, modified_settings) -> bool:
  function save_changes (line 81) | def save_changes(interface, modified_settings, menu_state):

FILE: contact/utilities/telemetry_beautifier.py
  function humanize_wind_direction (line 22) | def humanize_wind_direction(degrees):
  function get_chunks (line 49) | def get_chunks(data):

FILE: contact/utilities/utils.py
  function _get_channel_name (line 13) | def _get_channel_name(device_channel, node):
  function get_channels (line 23) | def get_channels():
  function get_node_list (line 61) | def get_node_list():
  function refresh_node_list (line 90) | def refresh_node_list():
  function get_nodeNum (line 98) | def get_nodeNum():
  function decimal_to_hex (line 104) | def decimal_to_hex(decimal_number):
  function convert_to_camel_case (line 108) | def convert_to_camel_case(string):
  function get_time_val_units (line 114) | def get_time_val_units(time_delta):
  function get_readable_duration (line 142) | def get_readable_duration(seconds):
  function get_time_ago (line 148) | def get_time_ago(timestamp):
  function add_new_message (line 159) | def add_new_message(channel_id, prefix, message):
  function parse_protobuf (line 189) | def parse_protobuf(packet: dict) -> Union[str, dict]:

FILE: contact/utilities/validation_rules.py
  function get_validation_for (line 19) | def get_validation_for(key: str) -> dict:

FILE: tests/test_arg_parser.py
  class ArgParserTests (line 6) | class ArgParserTests(unittest.TestCase):
    method test_demo_screenshot_flag_is_supported (line 7) | def test_demo_screenshot_flag_is_supported(self) -> None:
    method test_demo_screenshot_defaults_to_false (line 11) | def test_demo_screenshot_defaults_to_false(self) -> None:

FILE: tests/test_bot_handler.py
  class BotHandlerTests (line 10) | class BotHandlerTests(unittest.TestCase):
    method setUpClass (line 12) | def setUpClass(cls) -> None:
    method test_is_bot_message_uses_configured_catch_words (line 19) | def test_is_bot_message_uses_configured_catch_words(self) -> None:
    method test_is_bot_message_ignores_empty_config_values (line 25) | def test_is_bot_message_ignores_empty_config_values(self) -> None:

FILE: tests/test_config_io.py
  class ConfigIoTests (line 6) | class ConfigIoTests(unittest.TestCase):
    method test_split_compound_name_preserves_multi_part_values (line 7) | def test_split_compound_name_preserves_multi_part_values(self) -> None:
    method test_split_compound_name_duplicates_single_part_values (line 10) | def test_split_compound_name_duplicates_single_part_values(self) -> None:
    method test_is_repeated_field_prefers_new_style_attribute (line 13) | def test_is_repeated_field_prefers_new_style_attribute(self) -> None:
    method test_is_repeated_field_falls_back_to_label_comparison (line 18) | def test_is_repeated_field_falls_back_to_label_comparison(self) -> None:

FILE: tests/test_contact_ui.py
  class ContactUiTests (line 12) | class ContactUiTests(unittest.TestCase):
    method setUp (line 13) | def setUp(self) -> None:
    method tearDown (line 17) | def tearDown(self) -> None:
    method test_handle_backtick_refreshes_channels_after_settings_menu (line 21) | def test_handle_backtick_refreshes_channels_after_settings_menu(self) ...
    method test_process_pending_ui_updates_draws_requested_windows (line 41) | def test_process_pending_ui_updates_draws_requested_windows(self) -> N...
    method test_process_pending_ui_updates_full_redraw_uses_handle_resize (line 60) | def test_process_pending_ui_updates_full_redraw_uses_handle_resize(sel...
    method test_refresh_node_selection_reserves_scroll_arrow_column (line 73) | def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> ...
    method test_draw_channel_list_reserves_scroll_arrow_column (line 98) | def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
    method test_draw_node_list_reserves_scroll_arrow_column (line 117) | def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
    method test_handle_resize_single_pane_keeps_full_width_windows (line 139) | def test_handle_resize_single_pane_keeps_full_width_windows(self) -> N...
    method test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode (line 175) | def test_get_window_title_uses_selected_channel_only_for_messages_in_s...
    method test_refresh_pad_draws_selected_channel_title_on_message_frame (line 183) | def test_refresh_pad_draws_selected_channel_title_on_message_frame(sel...
    method test_search_ignores_no_input_from_curses (line 204) | def test_search_ignores_no_input_from_curses(self) -> None:
    method test_f5_node_details_ignores_no_input_from_curses (line 217) | def test_f5_node_details_ignores_no_input_from_curses(self) -> None:
    method test_f5_node_details_tolerates_none_metrics (line 257) | def test_f5_node_details_tolerates_none_metrics(self) -> None:

FILE: tests/test_control_ui.py
  class ControlUiTests (line 12) | class ControlUiTests(unittest.TestCase):
    method setUp (line 13) | def setUp(self) -> None:
    method tearDown (line 16) | def tearDown(self) -> None:
    method test_reconnect_interface_with_splash_replaces_interface (line 19) | def test_reconnect_interface_with_splash_replaces_interface(self) -> N...
    method test_reconnect_after_admin_action_runs_action_then_reconnects (line 41) | def test_reconnect_after_admin_action_runs_action_then_reconnects(self...
    method test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout (line 56) | def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_l...
    method test_request_factory_reset_uses_library_helper_when_supported (line 68) | def test_request_factory_reset_uses_library_helper_when_supported(self...
    method test_request_factory_reset_uses_library_helper_for_full_reset_when_supported (line 77) | def test_request_factory_reset_uses_library_helper_for_full_reset_when...
    method test_request_factory_reset_falls_back_to_int_valued_admin_message (line 86) | def test_request_factory_reset_falls_back_to_int_valued_admin_message(...
    method test_request_factory_reset_full_falls_back_to_int_valued_admin_message (line 100) | def test_request_factory_reset_full_falls_back_to_int_valued_admin_mes...

FILE: tests/test_control_utils.py
  class ControlUtilsTests (line 6) | class ControlUtilsTests(unittest.TestCase):
    method test_transform_menu_path_applies_replacements_and_normalization (line 7) | def test_transform_menu_path_applies_replacements_and_normalization(se...
    method test_transform_menu_path_preserves_unmatched_entries (line 12) | def test_transform_menu_path_preserves_unmatched_entries(self) -> None:

FILE: tests/test_db_handler.py
  class DbHandlerTests (line 15) | class DbHandlerTests(unittest.TestCase):
    method setUp (line 16) | def setUp(self) -> None:
    method tearDown (line 31) | def tearDown(self) -> None:
    method test_save_message_to_db_and_update_ack_roundtrip (line 36) | def test_save_message_to_db_and_update_ack_roundtrip(self) -> None:
    method test_update_node_info_in_db_fills_defaults_and_preserves_existing_values (line 48) | def test_update_node_info_in_db_fills_defaults_and_preserves_existing_...
    method test_get_name_from_database_returns_hex_when_user_is_missing (line 62) | def test_get_name_from_database_returns_hex_when_user_is_missing(self)...
    method test_load_messages_from_db_populates_channels_and_messages (line 69) | def test_load_messages_from_db_populates_channels_and_messages(self) -...
    method test_init_nodedb_inserts_nodes_from_interface (line 115) | def test_init_nodedb_inserts_nodes_from_interface(self) -> None:

FILE: tests/test_default_config.py
  class DefaultConfigTests (line 7) | class DefaultConfigTests(unittest.TestCase):
    method test_get_localisation_options_filters_hidden_and_non_ini_files (line 8) | def test_get_localisation_options_filters_hidden_and_non_ini_files(sel...
    method test_get_localisation_file_normalizes_extensions_and_falls_back_to_english (line 16) | def test_get_localisation_file_normalizes_extensions_and_falls_back_to...
    method test_update_dict_only_adds_missing_values (line 25) | def test_update_dict_only_adds_missing_values(self) -> None:
    method test_format_json_single_line_arrays_keeps_arrays_inline (line 34) | def test_format_json_single_line_arrays_keeps_arrays_inline(self) -> N...

FILE: tests/test_demo_data.py
  class DemoDataTests (line 14) | class DemoDataTests(unittest.TestCase):
    method setUp (line 15) | def setUp(self) -> None:
    method tearDown (line 19) | def tearDown(self) -> None:
    method test_build_demo_interface_exposes_expected_shape (line 23) | def test_build_demo_interface_exposes_expected_shape(self) -> None:
    method test_initialize_globals_seed_demo_populates_ui_state_and_db (line 30) | def test_initialize_globals_seed_demo_populates_ui_state_and_db(self) ...

FILE: tests/test_dialog.py
  class _FakeWindow (line 8) | class _FakeWindow:
    method __init__ (line 9) | def __init__(self, height: int, width: int) -> None:
    method erase (line 16) | def erase(self) -> None:
    method bkgd (line 19) | def bkgd(self, *_args) -> None:
    method attrset (line 22) | def attrset(self, *_args) -> None:
    method border (line 25) | def border(self, *_args) -> None:
    method addstr (line 28) | def addstr(self, y: int, x: int, text: str, *_args) -> None:
    method derwin (line 31) | def derwin(self, height: int, width: int, _y: int, _x: int):
    method noutrefresh (line 36) | def noutrefresh(self) -> None:
    method keypad (line 39) | def keypad(self, *_args) -> None:
    method timeout (line 42) | def timeout(self, *_args) -> None:
    method getch (line 45) | def getch(self) -> int:
    method refresh (line 48) | def refresh(self) -> None:
    method getmaxyx (line 51) | def getmaxyx(self):
  class DialogTests (line 55) | class DialogTests(unittest.TestCase):
    method setUp (line 56) | def setUp(self) -> None:
    method tearDown (line 61) | def tearDown(self) -> None:
    method test_dialog_renders_full_message_when_width_is_sufficient (line 66) | def test_dialog_renders_full_message_when_width_is_sufficient(self) ->...

FILE: tests/test_emoji_utils.py
  class EmojiUtilsTests (line 6) | class EmojiUtilsTests(unittest.TestCase):
    method test_strips_modifiers_from_keycaps_and_skin_tones (line 7) | def test_strips_modifiers_from_keycaps_and_skin_tones(self) -> None:
    method test_rewrites_flag_emoji_to_country_codes (line 10) | def test_rewrites_flag_emoji_to_country_codes(self) -> None:

FILE: tests/test_i18n.py
  class I18nTests (line 13) | class I18nTests(unittest.TestCase):
    method setUp (line 14) | def setUp(self) -> None:
    method tearDown (line 19) | def tearDown(self) -> None:
    method test_t_loads_translation_file_and_formats_placeholders (line 24) | def test_t_loads_translation_file_and_formats_placeholders(self) -> None:
    method test_t_falls_back_to_default_and_returns_unformatted_text_on_error (line 35) | def test_t_falls_back_to_default_and_returns_unformatted_text_on_error...
    method test_loader_cache_is_reused_until_language_changes (line 48) | def test_loader_cache_is_reused_until_language_changes(self) -> None:
    method test_bot_ui_translation_keys_exist_in_all_locales (line 60) | def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None:

FILE: tests/test_ini_utils.py
  class IniUtilsTests (line 9) | class IniUtilsTests(unittest.TestCase):
    method test_parse_ini_file_reads_sections_fields_and_help_text (line 10) | def test_parse_ini_file_reads_sections_fields_and_help_text(self) -> N...
    method test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails (line 29) | def test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails(sel...

FILE: tests/test_interfaces.py
  class InterfacesTests (line 8) | class InterfacesTests(unittest.TestCase):
    method test_reconnect_interface_retries_until_connection_succeeds (line 9) | def test_reconnect_interface_retries_until_connection_succeeds(self) -...
    method test_reconnect_interface_raises_after_exhausting_attempts (line 20) | def test_reconnect_interface_raises_after_exhausting_attempts(self) ->...

FILE: tests/test_main.py
  class MainRuntimeTests (line 13) | class MainRuntimeTests(unittest.TestCase):
    method setUp (line 14) | def setUp(self) -> None:
    method tearDown (line 18) | def tearDown(self) -> None:
    method test_initialize_runtime_interface_uses_demo_branch (line 22) | def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
    method test_initialize_runtime_interface_uses_live_branch_without_demo_flag (line 35) | def test_initialize_runtime_interface_uses_live_branch_without_demo_fl...
    method test_interface_is_ready_detects_missing_local_node (line 44) | def test_interface_is_ready_detects_missing_local_node(self) -> None:
    method test_initialize_runtime_interface_with_retry_retries_until_node_is_ready (line 48) | def test_initialize_runtime_interface_with_retry_retries_until_node_is...
    method test_initialize_runtime_interface_with_retry_returns_none_when_user_closes (line 64) | def test_initialize_runtime_interface_with_retry_returns_none_when_use...
    method test_prompt_region_if_unset_reinitializes_interface_after_confirmation (line 79) | def test_prompt_region_if_unset_reinitializes_interface_after_confirma...
    method test_prompt_region_if_unset_leaves_interface_unchanged_when_declined (line 98) | def test_prompt_region_if_unset_leaves_interface_unchanged_when_declin...
    method test_initialize_globals_resets_and_populates_runtime_state (line 113) | def test_initialize_globals_resets_and_populates_runtime_state(self) -...
    method test_ensure_min_rows_retries_until_terminal_is_large_enough (line 152) | def test_ensure_min_rows_retries_until_terminal_is_large_enough(self) ...
    method test_start_prints_help_and_exits_zero (line 165) | def test_start_prints_help_and_exits_zero(self) -> None:
    method test_start_runs_curses_wrapper_and_closes_interface (line 178) | def test_start_runs_curses_wrapper_and_closes_interface(self) -> None:
    method test_start_does_not_crash_when_wrapper_returns_without_interface (line 189) | def test_start_does_not_crash_when_wrapper_returns_without_interface(s...
    method test_main_returns_cleanly_when_user_closes_missing_node_dialog (line 198) | def test_main_returns_cleanly_when_user_closes_missing_node_dialog(sel...
    method test_start_handles_keyboard_interrupt (line 213) | def test_start_handles_keyboard_interrupt(self) -> None:
    method test_start_handles_keyboard_interrupt_with_no_interface (line 227) | def test_start_handles_keyboard_interrupt_with_no_interface(self) -> N...
    method test_start_handles_fatal_exception_and_exits_one (line 239) | def test_start_handles_fatal_exception_and_exits_one(self) -> None:

FILE: tests/test_menus.py
  class MenusTests (line 9) | class MenusTests(unittest.TestCase):
    method test_main_menu_includes_factory_reset_config_after_factory_reset (line 10) | def test_main_menu_includes_factory_reset_config_after_factory_reset(s...

FILE: tests/test_nav_utils.py
  class NavUtilsTests (line 9) | class NavUtilsTests(unittest.TestCase):
    method setUp (line 10) | def setUp(self) -> None:
    method test_wrap_text_splits_wide_characters_by_display_width (line 15) | def test_wrap_text_splits_wide_characters_by_display_width(self) -> None:
    method test_truncate_with_ellipsis_respects_display_width (line 18) | def test_truncate_with_ellipsis_respects_display_width(self) -> None:
    method test_highlight_line_reserves_scroll_arrow_column_for_nodes (line 21) | def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -...

FILE: tests/test_rx_handler.py
  class RxHandlerTests (line 11) | class RxHandlerTests(unittest.TestCase):
    method setUp (line 12) | def setUp(self) -> None:
    method tearDown (line 17) | def tearDown(self) -> None:
    method test_on_receive_text_message_refreshes_selected_channel (line 21) | def test_on_receive_text_message_refreshes_selected_channel(self) -> N...
    method test_on_receive_direct_message_adds_channel_and_notification (line 50) | def test_on_receive_direct_message_adds_channel_and_notification(self)...
    method test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded (line 79) | def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(...

FILE: tests/test_save_to_radio.py
  class SaveToRadioTests (line 8) | class SaveToRadioTests(unittest.TestCase):
    method build_interface (line 9) | def build_interface(self):
    method test_save_changes_returns_true_for_lora_writes_that_require_reconnect (line 25) | def test_save_changes_returns_true_for_lora_writes_that_require_reconn...
    method test_save_changes_returns_false_when_nothing_changed (line 35) | def test_save_changes_returns_false_when_nothing_changed(self) -> None:
    method test_save_changes_returns_false_for_non_rebooting_security_fields (line 41) | def test_save_changes_returns_false_for_non_rebooting_security_fields(...
    method test_save_changes_returns_true_for_rebooting_security_fields (line 50) | def test_save_changes_returns_true_for_rebooting_security_fields(self)...
    method test_save_changes_returns_true_only_for_rebooting_device_fields (line 58) | def test_save_changes_returns_true_only_for_rebooting_device_fields(se...
    method test_save_changes_returns_true_for_network_settings (line 69) | def test_save_changes_returns_true_for_network_settings(self) -> None:
    method test_save_changes_returns_true_only_for_rebooting_power_fields (line 78) | def test_save_changes_returns_true_only_for_rebooting_power_fields(sel...
    method test_save_changes_returns_true_for_module_settings (line 89) | def test_save_changes_returns_true_for_module_settings(self) -> None:
    method test_save_changes_returns_true_for_user_name_changes (line 98) | def test_save_changes_returns_true_for_user_name_changes(self) -> None:
    method test_save_changes_returns_true_for_user_license_changes (line 107) | def test_save_changes_returns_true_for_user_license_changes(self) -> N...

FILE: tests/test_settings.py
  class SettingsRuntimeTests (line 9) | class SettingsRuntimeTests(unittest.TestCase):
    method test_main_closes_interface_after_normal_settings_exit (line 10) | def test_main_closes_interface_after_normal_settings_exit(self) -> None:
    method test_main_closes_reconnected_interface_after_region_reset (line 29) | def test_main_closes_reconnected_interface_after_region_reset(self) ->...
    method test_main_closes_interface_when_settings_menu_raises (line 58) | def test_main_closes_interface_when_settings_menu_raises(self) -> None:

FILE: tests/test_support.py
  function reset_singletons (line 8) | def reset_singletons() -> None:
  function restore_config (line 16) | def restore_config(saved: dict) -> None:
  function snapshot_config (line 21) | def snapshot_config(*keys: str) -> dict:
  function _reset_instance (line 25) | def _reset_instance(target: object, replacement: object) -> None:

FILE: tests/test_telemetry_beautifier.py
  class TelemetryBeautifierTests (line 7) | class TelemetryBeautifierTests(unittest.TestCase):
    method test_humanize_wind_direction_handles_boundaries (line 8) | def test_humanize_wind_direction_handles_boundaries(self) -> None:
    method test_get_chunks_formats_known_and_unknown_values (line 14) | def test_get_chunks_formats_known_and_unknown_values(self) -> None:
    method test_get_chunks_formats_time_values (line 22) | def test_get_chunks_formats_time_values(self) -> None:

FILE: tests/test_tx_handler.py
  class TxHandlerTests (line 14) | class TxHandlerTests(unittest.TestCase):
    method setUp (line 15) | def setUp(self) -> None:
    method tearDown (line 20) | def tearDown(self) -> None:
    method test_send_message_on_named_channel_tracks_ack_request (line 25) | def test_send_message_on_named_channel_tracks_ack_request(self) -> None:
    method test_send_message_to_direct_node_uses_node_as_destination (line 51) | def test_send_message_to_direct_node_uses_node_as_destination(self) ->...
    method test_on_ack_nak_updates_message_for_explicit_ack (line 73) | def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
    method test_on_ack_nak_uses_implicit_marker_for_self_ack (line 92) | def test_on_ack_nak_uses_implicit_marker_for_self_ack(self) -> None:

FILE: tests/test_utils.py
  class UtilsTests (line 12) | class UtilsTests(unittest.TestCase):
    method setUp (line 13) | def setUp(self) -> None:
    method tearDown (line 17) | def tearDown(self) -> None:
    method test_get_node_list_keeps_local_first_and_ignored_last (line 21) | def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
    method test_add_new_message_groups_messages_by_hour (line 32) | def test_add_new_message_groups_messages_by_hour(self) -> None:
    method test_get_channels_populates_message_buckets_for_device_channels (line 51) | def test_get_channels_populates_message_buckets_for_device_channels(se...
    method test_get_channels_rebuilds_renamed_channels_and_preserves_messages (line 63) | def test_get_channels_rebuilds_renamed_channels_and_preserves_messages...
    method test_parse_protobuf_returns_string_payload_unchanged (line 85) | def test_parse_protobuf_returns_string_payload_unchanged(self) -> None:
    method test_parse_protobuf_returns_placeholder_for_text_messages (line 90) | def test_parse_protobuf_returns_placeholder_for_text_messages(self) ->...

FILE: tests/test_validation_rules.py
  class ValidationRulesTests (line 6) | class ValidationRulesTests(unittest.TestCase):
    method test_get_validation_for_matches_exact_keys (line 7) | def test_get_validation_for_matches_exact_keys(self) -> None:
    method test_get_validation_for_matches_substrings (line 10) | def test_get_validation_for_matches_substrings(self) -> None:
    method test_get_validation_for_returns_empty_dict_for_unknown_key (line 13) | def test_get_validation_for_returns_empty_dict_for_unknown_key(self) -...
Condensed preview — 68 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (534K chars).
[
  {
    "path": ".github/workflows/contact-buildx.yml",
    "chars": 1359,
    "preview": "name: contact-buildx\n\non:\n  push:\n    tags:\n      - \"[0-9]+.[0-9]+.[0-9]+\"\n      - \"[0-9]+.[0-9]+.[0-9]+a[0-9]+\"\n      -"
  },
  {
    "path": ".github/workflows/release.yaml",
    "chars": 4402,
    "preview": "name: release\n\non:\n  push:\n    tags:\n      - \"[0-9]+.[0-9]+.[0-9]+\"\n      - \"[0-9]+.[0-9]+.[0-9]+a[0-9]+\"\n      - \"[0-9]"
  },
  {
    "path": ".gitignore",
    "chars": 141,
    "preview": "venv/\n.venv/\n__pycache__/\nnode-configs/\nclient.db\n.DS_Store\nclient.log\nsettings.log\nconfig.json\ndefault_config.log\ndist/"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 121,
    "preview": "{\n  \"[python]\": {\n      \"editor.defaultFormatter\": \"ms-python.black-formatter\",\n      \"editor.formatOnSave\": true\n    }\n"
  },
  {
    "path": "Dockerfile",
    "chars": 159,
    "preview": "FROM docker.io/python:3.14\n\nCOPY . /app\nWORKDIR /data\n\n# Install contact\nRUN python -m pip install /app && rm -rf /app\n\n"
  },
  {
    "path": "LICENSE",
    "chars": 35149,
    "preview": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free "
  },
  {
    "path": "README.md",
    "chars": 4356,
    "preview": "## Contact - A Console UI for Meshtastic\n\n#### Powered by Meshtastic.org\n\n### Install with:\n```bash\npip install contact\n"
  },
  {
    "path": "contact/__main__.py",
    "chars": 8415,
    "preview": "#!/usr/bin/env python3\n\n\"\"\"\nContact - A Console UI for Meshtastic by http://github.com/pdxlocations\nPowered by Meshtasti"
  },
  {
    "path": "contact/localisations/en.ini",
    "chars": 35950,
    "preview": "#field_name, \"Human readable field name with first word capitalized\", \"Help text with [warning]warnings[/warning], [note"
  },
  {
    "path": "contact/localisations/fr.ini",
    "chars": 9056,
    "preview": "##field_name, \"Nom du champ lisible avec première lettre en majuscule\", \"Texte d'aide avec [warning]avertissements[/warn"
  },
  {
    "path": "contact/localisations/ru.ini",
    "chars": 38781,
    "preview": "#field_name, \"Human readable field name with first word capitalized\", \"Help text with [warning]warnings[/warning], [note"
  },
  {
    "path": "contact/message_handlers/bot_handler.py",
    "chars": 3160,
    "preview": "# A basic auto-responder bot that replies to specific messages when bot mode is enabled.\nimport logging\nimport threading"
  },
  {
    "path": "contact/message_handlers/rx_handler.py",
    "chars": 7024,
    "preview": "import logging\nimport os\nimport platform\nimport shutil\nimport time\nimport subprocess\nimport threading\nfrom typing import"
  },
  {
    "path": "contact/message_handlers/tx_handler.py",
    "chars": 7797,
    "preview": "import time\n\nfrom typing import Any, Dict\n\nimport google.protobuf.json_format\nfrom meshtastic import BROADCAST_NUM\nfrom "
  },
  {
    "path": "contact/settings.py",
    "chars": 3634,
    "preview": "import contextlib\nimport curses\nimport io\nimport logging\nimport sys\nimport traceback\n\nimport contact.ui.default_config a"
  },
  {
    "path": "contact/ui/colors.py",
    "chars": 1317,
    "preview": "import curses\nimport contact.ui.default_config as config\n\nCOLOR_MAP = {\n    \"black\": curses.COLOR_BLACK,\n    \"red\": curs"
  },
  {
    "path": "contact/ui/contact_ui.py",
    "chars": 56479,
    "preview": "import curses\nimport logging\nimport time\nimport traceback\nfrom numbers import Real\nfrom typing import Union\n\nfrom contac"
  },
  {
    "path": "contact/ui/control_ui.py",
    "chars": 33960,
    "preview": "import base64\nimport curses\nimport ipaddress\nimport logging\nimport os\nimport sys\nfrom typing import List\nfrom meshtastic"
  },
  {
    "path": "contact/ui/default_config.py",
    "chars": 12972,
    "preview": "import json\nimport logging\nimport os\nfrom typing import Dict, List, Optional\nfrom contact.ui.colors import setup_colors\n"
  },
  {
    "path": "contact/ui/dialog.py",
    "chars": 5866,
    "preview": "import curses\n\nfrom contact.utilities.i18n import t_text\nfrom contact.ui.colors import get_color\nfrom contact.ui.nav_uti"
  },
  {
    "path": "contact/ui/menus.py",
    "chars": 5864,
    "preview": "import base64\nimport logging\nfrom collections import OrderedDict\n\nfrom typing import Any, Union, Dict\n\nfrom google.proto"
  },
  {
    "path": "contact/ui/nav_utils.py",
    "chars": 17589,
    "preview": "import curses\nimport re\nfrom unicodedata import east_asian_width\n\nfrom contact.ui.colors import get_color\nfrom contact.u"
  },
  {
    "path": "contact/ui/splash.py",
    "chars": 1012,
    "preview": "import curses\nfrom contact.ui.colors import get_color\n\n\ndef draw_splash(stdscr: object) -> None:\n    \"\"\"Draw the splash "
  },
  {
    "path": "contact/ui/ui_state.py",
    "chars": 1623,
    "preview": "from typing import Any, Union, List, Dict\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass MenuState:\n    men"
  },
  {
    "path": "contact/ui/user_config.py",
    "chars": 22261,
    "preview": "import os\nimport json\nimport curses\nfrom typing import Any, List, Dict, Optional\n\nfrom contact.ui.colors import get_colo"
  },
  {
    "path": "contact/utilities/arg_parser.py",
    "chars": 1370,
    "preview": "from argparse import ArgumentParser\n\n\ndef setup_parser() -> ArgumentParser:\n    parser = ArgumentParser(\n        add_hel"
  },
  {
    "path": "contact/utilities/config_io.py",
    "chars": 11256,
    "preview": "import yaml\nimport logging\nimport time\nfrom typing import List\nfrom google.protobuf.json_format import MessageToDict\nfro"
  },
  {
    "path": "contact/utilities/control_utils.py",
    "chars": 650,
    "preview": "from typing import List\nimport re\n\n\ndef transform_menu_path(menu_path: List[str]) -> List[str]:\n    \"\"\"Applies path repl"
  },
  {
    "path": "contact/utilities/db_handler.py",
    "chars": 16910,
    "preview": "import sqlite3\nimport time\nimport logging\nfrom datetime import datetime\nfrom typing import Optional, Union, Dict\n\nfrom c"
  },
  {
    "path": "contact/utilities/demo_data.py",
    "chars": 8355,
    "preview": "import os\nimport sqlite3\nimport tempfile\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Tuple, Union\n\n"
  },
  {
    "path": "contact/utilities/emoji_utils.py",
    "chars": 1741,
    "preview": "\"\"\"Helpers for normalizing emoji sequences in width-sensitive message rendering.\"\"\"\n\n# Strip zero-width and presentation"
  },
  {
    "path": "contact/utilities/i18n.py",
    "chars": 830,
    "preview": "from typing import Optional\n\nimport contact.ui.default_config as config\nfrom contact.utilities.ini_utils import parse_in"
  },
  {
    "path": "contact/utilities/ini_utils.py",
    "chars": 2098,
    "preview": "from typing import Optional, Tuple, Dict\nfrom contact.utilities import i18n\n\n\ndef parse_ini_file(ini_file_path: str) -> "
  },
  {
    "path": "contact/utilities/input_handlers.py",
    "chars": 23851,
    "preview": "import base64\nimport binascii\nimport curses\nimport ipaddress\nfrom typing import Any, Optional, List\n\nfrom contact.ui.col"
  },
  {
    "path": "contact/utilities/interfaces.py",
    "chars": 2430,
    "preview": "import logging\nimport time\nimport meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface\n\n\ndef "
  },
  {
    "path": "contact/utilities/save_to_radio.py",
    "chars": 10418,
    "preview": "from meshtastic.protobuf import channel_pb2\nfrom google.protobuf.message import Message\nimport logging\nimport base64\nimp"
  },
  {
    "path": "contact/utilities/singleton.py",
    "chars": 190,
    "preview": "from contact.ui.ui_state import ChatUIState, InterfaceState, AppState, MenuState\n\nui_state = ChatUIState()\ninterface_sta"
  },
  {
    "path": "contact/utilities/telemetry_beautifier.py",
    "chars": 3269,
    "preview": "import datetime\n\nsensors = {\n    'temperature': {'icon':'🌡️  ','unit':'°'},\n    'relative_humidity': {'icon':'💧','unit':"
  },
  {
    "path": "contact/utilities/utils.py",
    "chars": 8123,
    "preview": "import datetime\nimport time\nfrom typing import Optional, Union\nfrom google.protobuf.message import DecodeError\n\nfrom mes"
  },
  {
    "path": "contact/utilities/validation_rules.py",
    "chars": 787,
    "preview": "validation_rules = {\n    \"shortName\": {\"max_length\": 4},\n    \"longName\": {\"max_length\": 32},\n    \"fixed_pin\": {\"min_leng"
  },
  {
    "path": "pyproject.toml",
    "chars": 730,
    "preview": "[project]\nname = \"contact\"\nversion = \"1.5.8\"\ndescription = \"This Python curses client for Meshtastic is a terminal-based"
  },
  {
    "path": "requirements.txt",
    "chars": 11,
    "preview": "meshtastic\n"
  },
  {
    "path": "tests/__init__.py",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "tests/test_arg_parser.py",
    "chars": 441,
    "preview": "import unittest\n\nfrom contact.utilities.arg_parser import setup_parser\n\n\nclass ArgParserTests(unittest.TestCase):\n    de"
  },
  {
    "path": "tests/test_bot_handler.py",
    "chars": 1094,
    "preview": "import unittest\nimport importlib\nimport sys\nimport types\nfrom unittest import mock\n\nimport contact.ui.default_config as "
  },
  {
    "path": "tests/test_config_io.py",
    "chars": 858,
    "preview": "import unittest\n\nfrom contact.utilities.config_io import _is_repeated_field, splitCompoundName\n\n\nclass ConfigIoTests(uni"
  },
  {
    "path": "tests/test_contact_ui.py",
    "chars": 14226,
    "preview": "import unittest\nfrom unittest import mock\n\nimport contact.ui.default_config as config\nfrom contact.ui import contact_ui\n"
  },
  {
    "path": "tests/test_control_ui.py",
    "chars": 4784,
    "preview": "from argparse import Namespace\nfrom types import SimpleNamespace\nimport unittest\nfrom unittest import mock\n\nfrom contact"
  },
  {
    "path": "tests/test_control_utils.py",
    "chars": 604,
    "preview": "import unittest\n\nfrom contact.utilities.control_utils import transform_menu_path\n\n\nclass ControlUtilsTests(unittest.Test"
  },
  {
    "path": "tests/test_db_handler.py",
    "chars": 4950,
    "preview": "import os\nimport sqlite3\nimport tempfile\nimport unittest\n\nimport contact.ui.default_config as config\nfrom contact.utilit"
  },
  {
    "path": "tests/test_default_config.py",
    "chars": 1777,
    "preview": "import tempfile\nimport unittest\n\nfrom contact.ui import default_config\n\n\nclass DefaultConfigTests(unittest.TestCase):\n  "
  },
  {
    "path": "tests/test_demo_data.py",
    "chars": 2399,
    "preview": "import tempfile\nimport unittest\nfrom unittest import mock\n\nimport contact.__main__ as entrypoint\nimport contact.ui.defau"
  },
  {
    "path": "tests/test_dialog.py",
    "chars": 3036,
    "preview": "import unittest\nfrom unittest import mock\n\nfrom contact.ui import dialog as dialog_module\nfrom contact.utilities.singlet"
  },
  {
    "path": "tests/test_emoji_utils.py",
    "chars": 410,
    "preview": "import unittest\n\nfrom contact.utilities.emoji_utils import normalize_message_text\n\n\nclass EmojiUtilsTests(unittest.TestC"
  },
  {
    "path": "tests/test_i18n.py",
    "chars": 3218,
    "preview": "import os\nimport tempfile\nimport unittest\nfrom unittest import mock\n\nimport contact.ui.default_config as config\nfrom con"
  },
  {
    "path": "tests/test_ini_utils.py",
    "chars": 1879,
    "preview": "import os\nimport tempfile\nimport unittest\nfrom unittest import mock\n\nfrom contact.utilities.ini_utils import parse_ini_f"
  },
  {
    "path": "tests/test_interfaces.py",
    "chars": 1128,
    "preview": "from argparse import Namespace\nimport unittest\nfrom unittest import mock\n\nfrom contact.utilities.interfaces import recon"
  },
  {
    "path": "tests/test_main.py",
    "chars": 12748,
    "preview": "from argparse import Namespace\nfrom types import SimpleNamespace\nimport unittest\nfrom unittest import mock\n\nimport conta"
  },
  {
    "path": "tests/test_menus.py",
    "chars": 1085,
    "preview": "from types import SimpleNamespace\nimport unittest\n\nfrom meshtastic.protobuf import config_pb2, module_config_pb2\n\nfrom c"
  },
  {
    "path": "tests/test_nav_utils.py",
    "chars": 1321,
    "preview": "import unittest\nfrom unittest import mock\n\nfrom contact.ui import nav_utils\nfrom contact.ui.nav_utils import truncate_wi"
  },
  {
    "path": "tests/test_rx_handler.py",
    "chars": 4237,
    "preview": "import unittest\nfrom unittest import mock\n\nimport contact.ui.default_config as config\nfrom contact.message_handlers impo"
  },
  {
    "path": "tests/test_save_to_radio.py",
    "chars": 5208,
    "preview": "from types import SimpleNamespace\nimport unittest\nfrom unittest import mock\n\nfrom contact.utilities.save_to_radio import"
  },
  {
    "path": "tests/test_settings.py",
    "chars": 4340,
    "preview": "from argparse import Namespace\nfrom types import SimpleNamespace\nimport unittest\nfrom unittest import mock\n\nimport conta"
  },
  {
    "path": "tests/test_support.py",
    "chars": 850,
    "preview": "import threading\n\nimport contact.ui.default_config as config\nfrom contact.ui.ui_state import AppState, ChatUIState, Inte"
  },
  {
    "path": "tests/test_telemetry_beautifier.py",
    "chars": 1208,
    "preview": "import unittest\nfrom unittest import mock\n\nfrom contact.utilities.telemetry_beautifier import get_chunks, humanize_wind_"
  },
  {
    "path": "tests/test_tx_handler.py",
    "chars": 5000,
    "preview": "from types import SimpleNamespace\nimport unittest\nfrom unittest import mock\n\nfrom meshtastic import BROADCAST_NUM\n\nimpor"
  },
  {
    "path": "tests/test_utils.py",
    "chars": 3997,
    "preview": "import unittest\nfrom unittest import mock\n\nimport contact.ui.default_config as config\nfrom contact.utilities.demo_data i"
  },
  {
    "path": "tests/test_validation_rules.py",
    "chars": 603,
    "preview": "import unittest\n\nfrom contact.utilities.validation_rules import get_validation_for\n\n\nclass ValidationRulesTests(unittest"
  }
]

About this extraction

This page contains the full source code of the pdxlocations/contact GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 68 files (481.3 KB), approximately 112.5k tokens, and a symbol index with 379 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!