mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-03-28 17:32:36 +01:00
Compare commits
258 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f85fe7842 | ||
|
|
6808ef2e68 | ||
|
|
5efac1d8b6 | ||
|
|
12b2fe789d | ||
|
|
8f48442f60 | ||
|
|
180e9368e9 | ||
|
|
2c7a753cb5 | ||
|
|
c5ef0b4145 | ||
|
|
ffecd2a44f | ||
|
|
d1d5d6ba30 | ||
|
|
2fe1196a90 | ||
|
|
5c72dd7aa5 | ||
|
|
2834ac3d0d | ||
|
|
55c29c36ba | ||
|
|
0cc4bbf3cd | ||
|
|
b795268d99 | ||
|
|
e60593a3d9 | ||
|
|
8b2449eded | ||
|
|
0c7e8b99a9 | ||
|
|
3931848bd9 | ||
|
|
134ec9f7df | ||
|
|
b8937c6abe | ||
|
|
0e4f0ee83a | ||
|
|
04560b0589 | ||
|
|
78c0ab6bb6 | ||
|
|
2d4f81e662 | ||
|
|
c6f9bc4a90 | ||
|
|
409ae34f93 | ||
|
|
ec9fbc9bd1 | ||
|
|
51602a7fbd | ||
|
|
a49106500d | ||
|
|
ded62343fd | ||
|
|
d096433ab7 | ||
|
|
3273e57f0b | ||
|
|
7cc70dd555 | ||
|
|
edb3208e2c | ||
|
|
60a6244c69 | ||
|
|
f06a27957f | ||
|
|
384f5a62f3 | ||
|
|
dcaf9d7fb5 | ||
|
|
99faf72408 | ||
|
|
a5a7e19ddc | ||
|
|
912617dc34 | ||
|
|
ca6d0cce4e | ||
|
|
43051076ba | ||
|
|
83091e6100 | ||
|
|
6b512db552 | ||
|
|
09b684fad8 | ||
|
|
1122d6007e | ||
|
|
f51cace2c3 | ||
|
|
78cefd3704 | ||
|
|
421efd7521 | ||
|
|
e64f6317ab | ||
|
|
18a6c9dfac | ||
|
|
a96d57580a | ||
|
|
1388771cc1 | ||
|
|
2cbfdb0b78 | ||
|
|
38bef50e12 | ||
|
|
24090ce19f | ||
|
|
14ea1e3d97 | ||
|
|
fb7bf1975b | ||
|
|
1e7887d480 | ||
|
|
398a4c6c63 | ||
|
|
9ab6b3be89 | ||
|
|
255be455b7 | ||
|
|
95a35520c2 | ||
|
|
22ec62a2f2 | ||
|
|
3f95f1d533 | ||
|
|
4e04ebee76 | ||
|
|
91fb93ca8d | ||
|
|
f690f16771 | ||
|
|
2805240abc | ||
|
|
7a5b7e64d7 | ||
|
|
0fd881aa4b | ||
|
|
932112abb2 | ||
|
|
f3d1fd0ec5 | ||
|
|
e92b1a2876 | ||
|
|
11d3c1eaf4 | ||
|
|
0361153592 | ||
|
|
0c6fcf10ef | ||
|
|
647ae92649 | ||
|
|
254eef4be9 | ||
|
|
bd0a94e2a1 | ||
|
|
2d8256d9f7 | ||
|
|
1f9b81865e | ||
|
|
17221cf37f | ||
|
|
47dd75bfb3 | ||
|
|
d4773705ce | ||
|
|
4f46e659d9 | ||
|
|
404f84f39c | ||
|
|
c07ec534a7 | ||
|
|
4d88aed0d8 | ||
|
|
b1946608f4 | ||
|
|
b92cf48fd0 | ||
|
|
227ffc94e6 | ||
|
|
b9f5a0c7f9 | ||
|
|
d56c1380c3 | ||
|
|
e8a8eefcc2 | ||
|
|
5738e8d306 | ||
|
|
11359e4016 | ||
|
|
7bb31af1d2 | ||
|
|
fd115916f5 | ||
|
|
32b60297c8 | ||
|
|
f15a871967 | ||
|
|
a346354dbc | ||
|
|
3d8007bbf6 | ||
|
|
bb254474d0 | ||
|
|
37e3790ee4 | ||
|
|
0ec380931a | ||
|
|
9cfd1bc670 | ||
|
|
a672c94303 | ||
|
|
92b3574c22 | ||
|
|
27d8e198ae | ||
|
|
11eeaa445a | ||
|
|
57efc8a69b | ||
|
|
7442ce11b4 | ||
|
|
8bb6ba4d8e | ||
|
|
da10af8d93 | ||
|
|
46a33178f6 | ||
|
|
e07c5a923e | ||
|
|
d330f3e0d6 | ||
|
|
eddb2fe08c | ||
|
|
ebe729cf13 | ||
|
|
41a45c6e9c | ||
|
|
4224579f79 | ||
|
|
aa43d4acad | ||
|
|
4406f2b86f | ||
|
|
649c959304 | ||
|
|
3529e40743 | ||
|
|
f5c2dfa5e4 | ||
|
|
1fb144ae1e | ||
|
|
7e66ffc3a0 | ||
|
|
d7371fae98 | ||
|
|
e4c51c97a1 | ||
|
|
70f072d222 | ||
|
|
8bb587cc7a | ||
|
|
313c313412 | ||
|
|
e5e8fbd0b5 | ||
|
|
2ef96f3ae3 | ||
|
|
a58605aba3 | ||
|
|
ffdd3a1ea9 | ||
|
|
185de28139 | ||
|
|
0eea36fba2 | ||
|
|
cb9e62894d | ||
|
|
9443d5fb0a | ||
|
|
1751648b12 | ||
|
|
8823d415c3 | ||
|
|
55a1d951a7 | ||
|
|
c8096107a0 | ||
|
|
5bdf1a9d6c | ||
|
|
85344db27e | ||
|
|
5990a859d9 | ||
|
|
ad6a55b9cd | ||
|
|
6fcd981eae | ||
|
|
9564c92cc8 | ||
|
|
149dc10df6 | ||
|
|
e211efca4e | ||
|
|
a974de790b | ||
|
|
777c423f17 | ||
|
|
dbcb93eabb | ||
|
|
69518ea317 | ||
|
|
11faea2b4e | ||
|
|
acb0e870d6 | ||
|
|
17cce3b98b | ||
|
|
ed768b48fe | ||
|
|
cb8dc50424 | ||
|
|
17cde0ca36 | ||
|
|
206b72ec4f | ||
|
|
eadc843e27 | ||
|
|
14709e2828 | ||
|
|
4a5d877a3d | ||
|
|
0159c90708 | ||
|
|
05648f23f2 | ||
|
|
f27fbdf3c9 | ||
|
|
998c4078bc | ||
|
|
666ae24d2c | ||
|
|
41e7c1207a | ||
|
|
41c6de4183 | ||
|
|
af83ba636f | ||
|
|
8b54c52e7f | ||
|
|
240dd4b46f | ||
|
|
7505c9ec22 | ||
|
|
14c22c8156 | ||
|
|
88dcce2b23 | ||
|
|
5bc842c7e8 | ||
|
|
f73bef5894 | ||
|
|
9371e96feb | ||
|
|
85345ca45f | ||
|
|
823554f689 | ||
|
|
5426202d51 | ||
|
|
685e0762bc | ||
|
|
8bc81cee00 | ||
|
|
82f55c6a32 | ||
|
|
be885aa00c | ||
|
|
536fd4deea | ||
|
|
eb25e55c97 | ||
|
|
b7f25c7c5c | ||
|
|
c1f1bc5eb9 | ||
|
|
a9c00e92c7 | ||
|
|
713e3102f3 | ||
|
|
25136d1dd6 | ||
|
|
3795ae17ea | ||
|
|
aef62bfbc3 | ||
|
|
cbb4bf0a3c | ||
|
|
22ebc2bdbe | ||
|
|
517c6cbf82 | ||
|
|
2b0d7267b5 | ||
|
|
ee4f910d6e | ||
|
|
49c88306a0 | ||
|
|
0f918ebccd | ||
|
|
69fac4ba98 | ||
|
|
80745bec50 | ||
|
|
5afb1df41a | ||
|
|
fbb7971cb0 | ||
|
|
23c2d701df | ||
|
|
2f1c305b06 | ||
|
|
978fa19b56 | ||
|
|
b5de21a073 | ||
|
|
f225c21c7a | ||
|
|
23ebb715c9 | ||
|
|
af0645f761 | ||
|
|
113750869f | ||
|
|
c2a18e9f9e | ||
|
|
fcaab86e71 | ||
|
|
47c84d91f1 | ||
|
|
8372817733 | ||
|
|
9683d8b79e | ||
|
|
6f16fc6afb | ||
|
|
fd971d8cc5 | ||
|
|
96193a22e8 | ||
|
|
02b0cde1c8 | ||
|
|
40f4de02d9 | ||
|
|
0b1d626f09 | ||
|
|
964883cae9 | ||
|
|
6ab1102d07 | ||
|
|
c8d8880806 | ||
|
|
21c2f7df18 | ||
|
|
cb51cf921b | ||
|
|
908e84e155 | ||
|
|
b9eaf7deb0 | ||
|
|
128ac456eb | ||
|
|
1269214264 | ||
|
|
4daf087fa5 | ||
|
|
9282c63206 | ||
|
|
710342447f | ||
|
|
8e2c3a43fb | ||
|
|
8d82823ccc | ||
|
|
27789d7508 | ||
|
|
680ba98a1c | ||
|
|
4d71a64971 | ||
|
|
d608754b5e | ||
|
|
70ab741746 | ||
|
|
b0cf5914bf | ||
|
|
434fbc3eef | ||
|
|
1186801d7e | ||
|
|
902d764ca0 | ||
|
|
00fd29e679 | ||
|
|
163920b399 |
10
.github/workflows/docker-image.yml
vendored
10
.github/workflows/docker-image.yml
vendored
@@ -25,10 +25,10 @@ jobs:
|
||||
#
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
uses: docker/login-action@28fdb31ff34708d19615a74d67103ddc2ea9725c
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
uses: docker/metadata-action@032a4b3bda1b716928481836ac5bfe36e1feaad6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
uses: docker/build-push-action@9e436ba9f2d7bcd1d038c8e55d039d37896ddc5d
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see [Using artifact attestations to establish provenance for builds](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds).
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
1
.github/workflows/greetings.yml
vendored
1
.github/workflows/greetings.yml
vendored
@@ -18,5 +18,4 @@ jobs:
|
||||
- uses: actions/first-interaction@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue_message: "Dependabot's first issue"
|
||||
pr_message: "Thank you for your pull request!"
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -2,35 +2,30 @@
|
||||
config.ini
|
||||
config_new.ini
|
||||
ini_merge_log.txt
|
||||
|
||||
# Pickle files
|
||||
*.pkl
|
||||
|
||||
# virtualenv
|
||||
venv/
|
||||
install_notes.txt
|
||||
|
||||
# logs
|
||||
logs/
|
||||
install_notes.txt
|
||||
logs/*.log
|
||||
|
||||
# modified .service files
|
||||
etc/*.service
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
|
||||
# rag data
|
||||
data/rag/*
|
||||
|
||||
# qrz db
|
||||
data/qrz.db
|
||||
|
||||
# fileMonitor test file
|
||||
bee.txt
|
||||
bible.txt
|
||||
|
||||
# .csv files
|
||||
*.csv
|
||||
# data files
|
||||
data/*.json
|
||||
data/*.txt
|
||||
data/*.pkl
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
# modules/custom_scheduler.py
|
||||
modules/custom_scheduler.py
|
||||
|
||||
# virtualenv
|
||||
venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
|
||||
25
INSTALL.md
25
INSTALL.md
@@ -196,4 +196,27 @@ From your project root, run one of the following commands:
|
||||
|
||||
- The script requires a Python virtual environment (`venv`) to be present in the project directory.
|
||||
- If `venv` is missing, the script will exit with an error message.
|
||||
- Always provide an argument (`mesh`, `pong`, `html`, `html5`, or `add`) to specify what you want to launch.
|
||||
- Always provide an argument (`mesh`, `pong`, `html`, `html5`, or `add`) to specify what you want to launch.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permissions Issues
|
||||
|
||||
If you encounter errors related to file or directory permissions (e.g., "Permission denied" or services failing to start):
|
||||
|
||||
- Ensure you are running installation scripts with sufficient privileges (use `sudo` if needed).
|
||||
- The `logs`, `data`, and `config.ini` files must be owned by the user running the bot (often `meshbot` or your current user).
|
||||
- You can manually reset permissions using the provided script:
|
||||
|
||||
```sh
|
||||
sudo bash etc/set-permissions.sh meshbot
|
||||
```
|
||||
|
||||
- If you moved the project directory, re-run the permissions script to update ownership.
|
||||
|
||||
- For systemd service issues, check logs with:
|
||||
```sh
|
||||
sudo journalctl -u mesh_bot.service
|
||||
```
|
||||
|
||||
If problems persist, double-check that the user specified in your service files matches the owner of the project files and directories.
|
||||
52
README.md
52
README.md
@@ -41,10 +41,11 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http
|
||||
|
||||
### Interactive AI and Data Lookup
|
||||
- **Weather, Earthquake, River, and Tide Data**: Get local alerts and info from NOAA/USGS; uses Open-Meteo for areas outside NOAA coverage.
|
||||
- **Wikipedia Search**: Retrieve summaries from Wikipedia.
|
||||
- **Ollama LLM Integration**: Query the [Ollama](https://github.com/ollama/ollama/tree/main/docs) AI for advanced responses.
|
||||
- **Wikipedia Search**: Retrieve summaries from Wikipedia and Kiwix
|
||||
- **OpenWebUI, Ollama LLM Integration**: Query the [Ollama](https://github.com/ollama/ollama/tree/main/docs) AI for advanced responses. Supports RAG (Retrieval Augmented Generation) with Wikipedia/Kiwix context and [OpenWebUI](https://github.com/open-webui/open-webui) integration for enhanced AI capabilities. [LLM Readme](modules/llm.md)
|
||||
- **Satellite Passes**: Find upcoming satellite passes for your location.
|
||||
- **GeoMeasuring Tools**: Calculate distances and midpoints using collected GPS data; supports Fox & Hound direction finding.
|
||||
- **RSS & News Feeds**: Receive news and data from multiple sources directly on the mesh.
|
||||
|
||||
### Proximity Alerts
|
||||
- **Location-Based Alerts**: Get notified when members arrive at a configured latitude/longitude—ideal for campsites, geo-fences, or remote locations. Optionally, trigger scripts, send emails, or automate actions (e.g., change node config, turn on lights, or drop an `alert.txt` file to start a survey or game).
|
||||
@@ -52,13 +53,28 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http
|
||||
- **High Flying Alerts**: Receive notifications when nodes with high altitude are detected on the mesh.
|
||||
- **Voice/Command Triggers**: Activate bot functions using keywords or voice commands (see [Voice Commands](#voice-commands-vox) for "Hey Chirpy!" support).
|
||||
|
||||
### EAS Alerts
|
||||
- **FEMA iPAWS/EAS Alerts**: Receive Emergency Alerts from FEMA via API on internet-connected nodes.
|
||||
- **NOAA EAS Alerts**: Get Emergency Alerts from NOAA via API.
|
||||
- **USGS Volcano Alerts**: Receive volcano alerts from USGS via API.
|
||||
- **NINA Alerts (Germany)**: Receive emergency alerts from the xrepository.de feed for Germany.
|
||||
- **Offline EAS Alerts**: Report EAS alerts over the mesh using external tools, even without internet.
|
||||
|
||||
### File Monitor Alerts
|
||||
- **File Monitoring**: Watch a text file for changes and broadcast updates to the mesh channel.
|
||||
- **News File Access**: Retrieve the contents of a news file on request; supports multiple news sources or files.
|
||||
- **Shell Command Access**: Execute shell commands via DM with replay protection (admin only).
|
||||
|
||||
#### Radio Frequency Monitoring
|
||||
- **SNR RF Activity Alerts**: Monitor radio frequencies and receive alerts when high SNR (Signal-to-Noise Ratio) activity is detected.
|
||||
- **Hamlib Integration**: Use Hamlib (rigctld) to monitor the S meter on a connected radio.
|
||||
- **Speech-to-Text Broadcasting**: Convert received audio to text using [Vosk](https://alphacephei.com/vosk/models) and broadcast it to the mesh.
|
||||
- **WSJT-X Integration**: Monitor WSJT-X (FT8, FT4, WSPR, etc.) decode messages and forward them to the mesh network with optional callsign filtering.
|
||||
- **JS8Call Integration**: Monitor JS8Call messages and forward them to the mesh network with optional callsign filtering.
|
||||
- **Meshages TTS**: The bot can speak mesh messages aloud using [KittenTTS](https://github.com/KittenML/KittenTTS). Enable this feature to have important alerts and messages read out loud on your device—ideal for hands-free operation or accessibility. See [radio.md](modules/radio.md) for setup instructions.
|
||||
|
||||
### Check-In / Check-Out & Asset Tracking
|
||||
- **Asset Tracking**: Maintain a check-in/check-out list for nodes or assets—ideal for accountability of people and equipment (e.g., Radio-Net, FEMA, trailhead groups).
|
||||
### Asset Tracking, Check-In/Check-Out, and Inventory Management
|
||||
Advanced check-in/check-out and asset tracking for people and equipment—ideal for accountability, safety monitoring, and logistics (e.g., Radio-Net, FEMA, trailhead groups). Admin approval workflows, GPS location capture, and overdue alerts. The integrated inventory and point-of-sale (POS) system enables item management, sales tracking, cart-based transactions, and daily reporting, for swaps, emergency supply management, and field operations, maker-places.
|
||||
|
||||
### Fun and Games
|
||||
- **Built-in Games**: Play classic games like DopeWars, Lemonade Stand, BlackJack, and Video Poker directly via DM.
|
||||
@@ -77,21 +93,8 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http
|
||||
- **User Feedback**: Users participate via DM; responses are logged for review.
|
||||
- **Reporting**: Retrieve survey results with `survey report` or `survey report <surveyname>`.
|
||||
|
||||
### EAS Alerts
|
||||
- **FEMA iPAWS/EAS Alerts**: Receive Emergency Alerts from FEMA via API on internet-connected nodes.
|
||||
- **NOAA EAS Alerts**: Get Emergency Alerts from NOAA via API.
|
||||
- **USGS Volcano Alerts**: Receive volcano alerts from USGS via API.
|
||||
- **Offline EAS Alerts**: Report EAS alerts over the mesh using external tools, even without internet.
|
||||
- **NINA Alerts (Germany)**: Receive emergency alerts from the xrepository.de feed for Germany.
|
||||
|
||||
### File Monitor Alerts
|
||||
- **File Monitoring**: Watch a text file for changes and broadcast updates to the mesh channel.
|
||||
- **News File Access**: Retrieve the contents of a news file on request; supports multiple news sources or files.
|
||||
- **Shell Command Access**: Execute shell commands via DM with replay protection (admin only).
|
||||
|
||||
### Data Reporting
|
||||
- **HTML Reports**: Visualize bot traffic and data flows with a built-in HTML generator. See [data reporting](logs/README.md) for details.
|
||||
- **RSS & News Feeds**: Receive news and data from multiple sources directly on the mesh.
|
||||
|
||||
### Robust Message Handling
|
||||
- **Automatic Message Chunking**: Messages over 160 characters are automatically split to ensure reliable delivery across multiple hops.
|
||||
@@ -110,13 +113,18 @@ git clone https://github.com/spudgunman/meshing-around
|
||||
- **Automated Installation**: [install.sh](INSTALL.md) will automate optional venv and requirements installation.
|
||||
- **Launch Script**: [laynch.sh](INSTALL.md) only used in a venv install, to launch the bot and the report generator.
|
||||
|
||||
### Docker Installation - Good for Windows!
|
||||
See further info on the [docker.md](script/docker/README.md)
|
||||
### Docker Installation
|
||||
Good for windows or OpenWebUI enabled bots
|
||||
|
||||
## Full list of commands for the bot
|
||||
[docker.md](script/docker/README.md)
|
||||
|
||||
## Module Help
|
||||
Configuration Guide
|
||||
[modules/README.md](modules/README.md)
|
||||
|
||||
### Games (via DM only)
|
||||
### Game Help
|
||||
Games are DM only by default
|
||||
|
||||
[modules/games/README.md](modules/games/README.md)
|
||||
|
||||
### Firmware 2.6 DM Key, and 2.7 CLIENT_BASE Favorite Nodes
|
||||
@@ -162,7 +170,7 @@ For testing and feature ideas on Discord and GitHub, if its stable its thanks to
|
||||
- **PiDiBi, Cisien, bitflip, nagu, Nestpebble, NomDeTom, Iris, Josh, GlockTuber, FJRPiolt, dj505, Woof, propstg, snydermesh, trs2982, F0X, Malice, mesb1, Hailo1999**
|
||||
- **xdep**: For the reporting html. 📊
|
||||
- **mrpatrick1991**: For OG Docker configurations. 💻
|
||||
- **[https://github.com/A-c0rN](A-c0rN)**: Assistance with iPAWS and 🚨
|
||||
- **A-c0rN**: Assistance with iPAWS and 🚨
|
||||
- **Mike O'Connell/skrrt**: For [eas_alert_parser](etc/eas_alert_parser.py) enhanced by **sheer.cold**
|
||||
- **WH6GXZ nurse dude**: Volcano Alerts 🌋
|
||||
- **mikecarper**: hamtest, leading to quiz etc.. 📋
|
||||
|
||||
140
config.template
140
config.template
@@ -62,6 +62,12 @@ rssFeedURL = http://www.hackaday.com/rss.xml,http://rss.slashdot.org/Slashdot/sl
|
||||
rssFeedNames = default,slashdot,mesh
|
||||
rssMaxItems = 3
|
||||
rssTruncate = 100
|
||||
# enable or disable the 'latest' command which uses NewsAPI.org key at https://newsapi.org/register
|
||||
enableNewsAPI = False
|
||||
newsAPI_KEY =
|
||||
newsAPIregion = us
|
||||
# could also be 'relevancy' or 'popularity' or 'publishedAt'
|
||||
sort_by = relevancy
|
||||
|
||||
# enable or disable the wikipedia search module
|
||||
wikipedia = True
|
||||
@@ -75,15 +81,28 @@ kiwixLibraryName = wikipedia_en_100_nopic_2025-09
|
||||
|
||||
# Enable ollama LLM see more at https://ollama.com
|
||||
ollama = False
|
||||
# Ollama model to use (defaults to gemma3:270m)
|
||||
# Ollama model to use (defaults to gemma3:270m) gemma2 is good for older SYSTEM prompt
|
||||
# ollamaModel = gemma3:latest
|
||||
# ollamaModel = gemma2:2b
|
||||
# server instance to use (defaults to local machine install)
|
||||
ollamaHostName = http://localhost:11434
|
||||
|
||||
# Produce LLM replies to messages that aren't commands?
|
||||
# If False, the LLM only replies to the "ask:" and "askai" commands.
|
||||
llmReplyToNonCommands = True
|
||||
# if True, the input is sent raw to the LLM, if False uses legacy template query
|
||||
rawLLMQuery = True
|
||||
# if True, the input is sent raw to the LLM, if False uses SYSTEM prompt
|
||||
rawLLMQuery = True
|
||||
|
||||
# Enable Wikipedia/Kiwix integration with LLM for RAG (Retrieval Augmented Generation)
|
||||
# When enabled, LLM will automatically search Wikipedia/Kiwix and include context in responses
|
||||
llmUseWikiContext = False
|
||||
|
||||
# Use OpenWebUI instead of direct Ollama API (enables advanced RAG features)
|
||||
useOpenWebUI = False
|
||||
# OpenWebUI server URL (e.g., http://localhost:3000)
|
||||
openWebUIURL = http://localhost:3000
|
||||
# OpenWebUI API key/token (required when useOpenWebUI is True)
|
||||
openWebUIAPIKey =
|
||||
|
||||
# StoreForward Enabled and Limits
|
||||
StoreForward = True
|
||||
@@ -190,13 +209,27 @@ useMetric = False
|
||||
# repeaterList lookup location (rbook / artsci / False)
|
||||
repeaterLookup = rbook
|
||||
|
||||
# Satalite Pass Prediction
|
||||
# Register for free API https://www.n2yo.com/login/ personal data page at bottom 'Are you developer?'
|
||||
n2yoAPIKey =
|
||||
# NORAD list https://www.n2yo.com/satellites/
|
||||
satList = 25544,7530
|
||||
|
||||
# use Open-Meteo API for weather data not NOAA useful for non US locations
|
||||
UseMeteoWxAPI = False
|
||||
|
||||
# NOAA weather forecast days
|
||||
NOAAforecastDuration = 3
|
||||
# number of weather alerts to display
|
||||
NOAAalertCount = 2
|
||||
|
||||
# use Open-Meteo API for weather data not NOAA useful for non US locations
|
||||
UseMeteoWxAPI = False
|
||||
# NOAA Weather EAS Alert Broadcast
|
||||
wxAlertBroadcastEnabled = False
|
||||
# Enable Ignore any message that includes following word list
|
||||
ignoreEASenable = False
|
||||
ignoreEASwords = test,advisory
|
||||
# Add extra location to the weather alert
|
||||
enableExtraLocationWx = False
|
||||
|
||||
# NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
|
||||
coastalEnabled = False
|
||||
@@ -212,52 +245,49 @@ coastalForecastDays = 3
|
||||
# for multiple rivers use comma separated list e.g. 12484500,14105700
|
||||
riverList =
|
||||
|
||||
# NOAA EAS Alert Broadcast
|
||||
wxAlertBroadcastEnabled = False
|
||||
# Enable Ignore any message that includes following word list
|
||||
ignoreEASenable = False
|
||||
ignoreEASwords = test,advisory
|
||||
# EAS Alert Broadcast Channels
|
||||
wxAlertBroadcastCh = 2
|
||||
# Add extra location to the weather alert
|
||||
enableExtraLocationWx = False
|
||||
|
||||
# Goverment Alert Broadcast defaults to FEMA IPAWS
|
||||
eAlertBroadcastEnabled = False
|
||||
# USA FEMA IPAWS alerts
|
||||
ipawsAlertEnabled = True
|
||||
# comma separated list of FIPS codes to trigger local alert. find your FIPS codes at https://en.wikipedia.org/wiki/Federal_Information_Processing_Standard_state_code
|
||||
myFIPSList = 57,58,53
|
||||
# find your SAME https://www.weather.gov/nwr/counties comma separated list of SAME code to further refine local alert.
|
||||
mySAMEList = 053029,053073
|
||||
# Goverment Alert Broadcast Channels
|
||||
eAlertBroadcastCh = 2
|
||||
# Enable Ignore, headline that includes following word list
|
||||
ignoreFEMAenable = True
|
||||
ignoreFEMAwords = test,exercise
|
||||
|
||||
# USGS Volcano alerts Enable USGS Volcano Alert Broadcast
|
||||
volcanoAlertBroadcastEnabled = False
|
||||
volcanoAlertBroadcastCh = 2
|
||||
# Enable Ignore any message that includes following word list
|
||||
ignoreUSGSEnable = False
|
||||
ignoreUSGSWords = test,advisory
|
||||
|
||||
# Use DE Alert Broadcast Data
|
||||
# Use Germany/DE Alert Broadcast Data
|
||||
enableDEalerts = False
|
||||
# comma separated list of regional codes trigger local alert.
|
||||
# find your regional codet at https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json
|
||||
myRegionalKeysDE = 110000000000,120510000000
|
||||
|
||||
# Satalite Pass Prediction
|
||||
# Register for free API https://www.n2yo.com/login/ personal data page at bottom 'Are you developer?'
|
||||
n2yoAPIKey =
|
||||
# NORAD list https://www.n2yo.com/satellites/
|
||||
satList = 25544,7530
|
||||
# Alerts are sent to the emergency_handler interface and channel duplicate messages are send here if set
|
||||
eAlertBroadcastCh =
|
||||
|
||||
# CheckList Checkin/Checkout
|
||||
[checklist]
|
||||
enabled = False
|
||||
checklist_db = data/checklist.db
|
||||
reverse_in_out = False
|
||||
# Auto approve new checklists
|
||||
auto_approve = True
|
||||
# Check-in reminder interval is 5min
|
||||
# Checkin broadcast interface and channel is emergency_handler interface and channel
|
||||
|
||||
# Inventory and Point of Sale System
|
||||
[inventory]
|
||||
enabled = False
|
||||
inventory_db = data/inventory.db
|
||||
# Set to True to disable penny precision and round to nickels (USA cash sales)
|
||||
# When True: cash sales round down, taxed sales round up to nearest $0.05
|
||||
# When False (default): normal penny precision ($0.01)
|
||||
disable_penny = False
|
||||
|
||||
[qrz]
|
||||
# QRZ Hello to new nodes with message
|
||||
@@ -287,7 +317,9 @@ message = "MeshBot says Hello! DM for more info."
|
||||
# enable overides the above and uses the motd as the message
|
||||
schedulerMotd = False
|
||||
# value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun.
|
||||
# value can also be 'joke' (min/interval) or 'weather' (time/day) or 'link' (hour/interval) for special auto messages
|
||||
# value can also be 'joke' (min/interval), 'weather' (time/day), 'link' (hour/interval) for special auto messages
|
||||
# or 'news' (hour/interval), 'readrss' (hour/interval), 'mwx' (time/day), 'sysinfo' (hour/interval),
|
||||
# 'tide' (time/day), 'solar' (time/day) for automated information broadcasts, matching module needs enabled!
|
||||
# 'custom' for module/scheduler.py custom schedule examples
|
||||
value =
|
||||
# interval to use when time is not set (e.g. every 2 days)
|
||||
@@ -296,14 +328,17 @@ interval =
|
||||
time =
|
||||
|
||||
[radioMon]
|
||||
# using Hamlib rig control will monitor and alert on channel use
|
||||
enabled = False
|
||||
rigControlServerAddress = localhost:4532
|
||||
# dx cluster `dx` command
|
||||
dxspotter_enabled = True
|
||||
# device interface to send the message to
|
||||
|
||||
# alerts in this module use the following interface and channel
|
||||
sigWatchBroadcastInterface = 1
|
||||
# broadcast channel can also be a comma separated list of channels
|
||||
sigWatchBroadcastCh = 2
|
||||
|
||||
# using Hamlib rig control will monitor and alert on channel use
|
||||
enabled = False
|
||||
rigControlServerAddress = 127.0.0.1:4532
|
||||
# minimum SNR as reported by radio via hamlib
|
||||
signalDetectionThreshold = -10
|
||||
# hold time for high SNR
|
||||
@@ -311,17 +346,41 @@ signalHoldTime = 10
|
||||
# the following are combined to reset the monitor
|
||||
signalCooldown = 5
|
||||
signalCycleLimit = 5
|
||||
# enable VOX detection using default input
|
||||
|
||||
# Enable VOX detection using default input
|
||||
voxDetectionEnabled = False
|
||||
# description to use in the alert message
|
||||
voxDescription = VOX
|
||||
|
||||
useLocalVoxModel = False
|
||||
# default language for VOX detection
|
||||
voxLanguage = en-us
|
||||
# sound.card input device to use for VOX detection, 'default' uses system default
|
||||
voxInputDevice = default
|
||||
# "hey chirpy"
|
||||
voxOnTrapList = True
|
||||
voxTrapList = chirpy
|
||||
# allow use of 'weather' and 'joke' commands via VOX
|
||||
voxEnableCmd = True
|
||||
|
||||
# Meshages Text-to-Speech (TTS) for incoming messages and DM
|
||||
meshagesTTS = False
|
||||
ttsChannels = 2
|
||||
|
||||
# WSJT-X UDP monitoring - listens for decode messages from WSJT-X, FT8/FT4/WSPR etc.
|
||||
wsjtxDetectionEnabled = False
|
||||
# UDP address and port where WSJT-X broadcasts (default: 127.0.0.1:2237)
|
||||
wsjtxUdpServerAddress = 127.0.0.1:2237
|
||||
# Comma-separated list of callsigns to watch (empty = all callsigns)
|
||||
wsjtxWatchedCallsigns =
|
||||
|
||||
# JS8Call TCP monitoring - connects to JS8Call API for message forwarding
|
||||
js8callDetectionEnabled = False
|
||||
# TCP address and port where JS8Call API listens (default: 127.0.0.1:2442)
|
||||
js8callServerAddress = 127.0.0.1:2442
|
||||
# Comma-separated list of callsigns to watch (empty = all callsigns)
|
||||
js8callWatchedCallsigns =
|
||||
|
||||
|
||||
[fileMon]
|
||||
filemon_enabled = False
|
||||
@@ -333,8 +392,10 @@ broadcastCh = 2
|
||||
# news command will return the contents of a text file
|
||||
enable_read_news = False
|
||||
news_file_path = ../data/news.txt
|
||||
# only return a single random line from the news file
|
||||
# only return a single random (head)line from the news file
|
||||
news_random_line = False
|
||||
# only return random news 'block' (seprated by two newlines) randomly (precidence over news_random_line)
|
||||
news_block_mode = True
|
||||
|
||||
# enable the use of exernal shell commands, this enables some data in `sysinfo`
|
||||
enable_runShellCmd = False
|
||||
@@ -342,9 +403,9 @@ enable_runShellCmd = False
|
||||
# direct shell command handler the x: command in DMs
|
||||
allowXcmd = False
|
||||
# Enable 2 factor authentication for x: commands
|
||||
2factor_enabled = True
|
||||
twoFactor_enabled = True
|
||||
# time in seconds to wait for the correct 2FA answer
|
||||
2factor_timeout = 100
|
||||
twoFactor_timeout = 100
|
||||
|
||||
[smtp]
|
||||
# enable or disable the SMTP module
|
||||
@@ -387,6 +448,7 @@ hangman = True
|
||||
hamtest = True
|
||||
tictactoe = True
|
||||
wordOfTheDay = True
|
||||
battleShip = True
|
||||
|
||||
# enable or disable the quiz game module questions are in data/quiz.json
|
||||
quiz = False
|
||||
@@ -422,3 +484,11 @@ DEBUGpacket = False
|
||||
# metaPacket detailed logging, the filter negates the port ID
|
||||
debugMetadata = False
|
||||
metadataFilter = TELEMETRY_APP,POSITION_APP
|
||||
# Enable or disable automatic banning of nodes
|
||||
autoBanEnabled = False
|
||||
# Number of offenses before auto-ban
|
||||
autoBanThreshold = 5
|
||||
# Throttle value for API requests no ban_hammer
|
||||
apiThrottleValue = 20
|
||||
# Timeframe for offenses (in seconds)
|
||||
autoBanTimeframe = 3600
|
||||
@@ -1 +1,3 @@
|
||||
database admin tool is in [./etc/db_admin.py](../etc/db_admin.py)
|
||||
database admin tool is in [./etc/db_admin.py](../etc/db_admin.py)
|
||||
this folder is populated with install.sh
|
||||
to manually populate ` cp etc/data/* data/. `
|
||||
BIN
etc/3dttt.jpg
Normal file
BIN
etc/3dttt.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -72,4 +72,28 @@ python etc/simulator.py
|
||||
**Note:**
|
||||
Edit the `projectName` variable to match the handler function you want to test. You can expand this script to test additional handlers or scenarios as needed.
|
||||
|
||||
Feel free to add or update resources here as needed for documentation, configuration, or project support.
|
||||
## yolo_vision.py
|
||||
|
||||
**Purpose:**
|
||||
`yolo_vision.py` provides real-time object detection and movement tracking using a Raspberry Pi camera and YOLOv5. It is designed for integration with the Mesh Bot project, outputting alerts to both the console and an optional `alert.txt` file for further use (such as with Meshtastic).
|
||||
|
||||
**Features:**
|
||||
- Ignores specified object classes (e.g., "bed", "chair") to reduce false positives.
|
||||
- Configurable detection confidence threshold and movement sensitivity.
|
||||
- Tracks object movement direction (left, right, stationary).
|
||||
- Fuse counter: only alerts after an object is detected for several consecutive frames.
|
||||
- Optionally writes the latest alert (without timestamp) to a specified file, overwriting previous alerts.
|
||||
|
||||
**Configuration:**
|
||||
- `LOW_RES_MODE`: Use low or high camera resolution for CPU savings.
|
||||
- `IGNORE_CLASSES`: List of object classes to ignore.
|
||||
- `CONFIDENCE_THRESHOLD`: Minimum confidence for reporting detections.
|
||||
- `MOVEMENT_THRESHOLD`: Minimum pixel movement to consider as "moving".
|
||||
- `ALERT_FUSE_COUNT`: Number of consecutive detections before alerting.
|
||||
- `ALERT_FILE_PATH`: Path to alert file (set to `None` to disable file output).
|
||||
|
||||
**Usage:**
|
||||
Run this script to monitor the camera feed and generate alerts for detected and moving objects. Alerts are printed to the console and, if configured, written to `alert.txt` for integration with other systems.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import schedule
|
||||
from modules.log import logger
|
||||
from modules.settings import MOTD
|
||||
from modules.system import send_message
|
||||
|
||||
def setup_custom_schedules(send_message, tell_joke, welcome_message, handle_wxc, MOTD, schedulerChannel, schedulerInterface):
|
||||
"""
|
||||
Set up all custom schedules. Edit this function to add or remove scheduled tasks.
|
||||
"""
|
||||
|
||||
### Example schedules
|
||||
# Send a joke every 2 minutes
|
||||
#schedule.every(2).minutes.do(send_joke, send_message, tell_joke, schedulerChannel, schedulerInterface)
|
||||
# Send a good morning message every day at 9 AM
|
||||
#schedule.every().day.at("09:00").do(send_good_morning, send_message, schedulerChannel, schedulerInterface)
|
||||
# Send weather update every day at 8 AM
|
||||
#schedule.every().day.at("08:00").do(send_wx, send_message, handle_wxc, schedulerChannel, schedulerInterface)
|
||||
# Send weather alerts every Wednesday at noon
|
||||
#schedule.every().wednesday.at("12:00").do(send_weather_alert, send_message, schedulerChannel, schedulerInterface)
|
||||
# Send configuration URL every 2 days at 10 AM
|
||||
#schedule.every(2).days.at("10:00").do(send_config_url, send_message, schedulerChannel, schedulerInterface)
|
||||
# Send net starting message every Wednesday at 7 PM
|
||||
#schedule.every().wednesday.at("19:00").do(send_net_starting, send_message, schedulerChannel, schedulerInterface)
|
||||
# Send welcome message every 2 days at 8 AM
|
||||
#schedule.every(2).days.at("08:00").do(send_welcome, send_message, schedulerChannel, schedulerInterface)
|
||||
# Send MOTD every day at 1 PM
|
||||
#schedule.every().day.at("13:00").do(send_motd, send_message, MOTD, schedulerChannel, schedulerInterface)
|
||||
# Send bbslink message every 2 days at 10 AM
|
||||
#schedule.every(2).days.at("10:00").do(send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface))
|
||||
|
||||
# Example task functions, modify as needed the channel and interface parameters default to schedulerChannel and schedulerInterface
|
||||
|
||||
def send_joke(send_message, tell_joke, channel, interface):
|
||||
send_message(tell_joke(), channel, 0, interface)
|
||||
|
||||
def send_good_morning(send_message, channel, interface):
|
||||
send_message("Good Morning", channel, 0, interface)
|
||||
|
||||
def send_wx(send_message, handle_wxc, channel, interface):
|
||||
send_message(handle_wxc(0, 1, 'wx', days=1), channel, 0, interface)
|
||||
|
||||
def send_weather_alert(send_message, channel, interface):
|
||||
send_message("Weather alerts available on 'Alerts' channel with default 'AQ==' key.", channel, 0, interface)
|
||||
|
||||
def send_config_url(send_message, channel, interface):
|
||||
send_message("Join us on Medium Fast https://meshtastic.org/e/#CgcSAQE6AggNEg4IARAEOAFAA0gBUB5oAQ", channel, 0, interface)
|
||||
|
||||
def send_net_starting(send_message, channel, interface):
|
||||
send_message("Net Starting Now", channel, 0, interface)
|
||||
|
||||
def send_welcome(send_message, channel, interface):
|
||||
send_message("Welcome to the group", channel, 0, interface)
|
||||
|
||||
def send_motd(send_message, MOTD, channel, interface):
|
||||
send_message(MOTD, channel, 0, interface)
|
||||
|
||||
def send_bbslink(send_message, channel, interface):
|
||||
send_message("bbslink MeshBot looking for peers", channel, 0, interface)
|
||||
127
etc/custom_scheduler.template
Normal file
127
etc/custom_scheduler.template
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/python3
|
||||
import schedule
|
||||
from modules.log import logger
|
||||
from modules.settings import MOTD
|
||||
from modules.system import send_message
|
||||
|
||||
def setup_custom_schedules(send_message, tell_joke, welcome_message, handle_wxc, MOTD, schedulerChannel, schedulerInterface):
|
||||
"""
|
||||
Set up custom schedules. Edit the example schedules as needed.
|
||||
|
||||
1. in config.ini set "value" under [scheduler] to: value = custom
|
||||
2. edit this file to add/remove/modify schedules
|
||||
3. restart mesh bot
|
||||
4. verify schedules are working by checking the log file
|
||||
5. Make sure to uncomment (delete the single #) the example schedules down at the end of the file to enable them
|
||||
Python is sensitive to indentation so be careful when editing this file.
|
||||
https://thonny.org is included on pi's image and is a simple IDE to use for editing python files.
|
||||
|
||||
Available functions you can import and use, be sure they are enabled modules in config.ini:
|
||||
- tell_joke() - Returns a random joke
|
||||
- welcome_message - A welcome message string
|
||||
- handle_wxc(message_from_id, deviceID, cmd, days=None) - Weather information
|
||||
- handleNews(message_from_id, deviceID, message, isDM) - News reader
|
||||
- get_rss_feed(msg) - RSS feed reader
|
||||
- handle_mwx(message_from_id, deviceID, cmd) - Marine weather
|
||||
- sysinfo(message, message_from_id, deviceID, isDM) - System information
|
||||
- handle_tide(message_from_id, deviceID, channel_number) - Tide information
|
||||
- handle_sun(message_from_id, deviceID, channel_number) - Sun information
|
||||
- MOTD - Message of the day string
|
||||
"""
|
||||
try:
|
||||
# Import additional functions for scheduling (optional, depending on your needs)
|
||||
from mesh_bot import handleNews, sysinfo, handle_mwx, handle_tide, handle_sun
|
||||
from modules.rss import get_rss_feed
|
||||
|
||||
# Example task functions, modify as needed the channel and interface parameters default to schedulerChannel and schedulerInterface
|
||||
def send_joke(channel, interface):
|
||||
## uses system.send_message to send the result of tell_joke()
|
||||
send_message(tell_joke(), channel, 0, interface)
|
||||
|
||||
def send_good_morning(channel, interface):
|
||||
## uses system.send_message to send "Good Morning"
|
||||
send_message("Good Morning", channel, 0, interface)
|
||||
|
||||
def send_wx(channel, interface):
|
||||
## uses system.send_message to send the result of handle_wxc(id,id,cmd,days_returned)
|
||||
send_message(handle_wxc(0, 1, 'wx', days=1), channel, 0, interface)
|
||||
|
||||
def send_weather_alert(channel, interface):
|
||||
## uses system.send_message to send string
|
||||
send_message("Weather alerts available on 'Alerts' channel with default 'AQ==' key.", channel, 0, interface)
|
||||
|
||||
def send_config_url(channel, interface):
|
||||
## uses system.send_message to send string
|
||||
send_message("Join us on Medium Fast https://meshtastic.org/e/#CgcSAQE6AggNEg4IARAEOAFAA0gBUB5oAQ", channel, 0, interface)
|
||||
|
||||
def send_net_starting(channel, interface):
|
||||
## uses system.send_message to send string, channel 2, interface 3
|
||||
send_message("Net Starting Now", 2, 0, 3)
|
||||
|
||||
def send_welcome(channel, interface):
|
||||
## uses system.send_message to send string, channel 2, interface 1
|
||||
send_message("Welcome to the group", 2, 0, 1)
|
||||
|
||||
def send_motd(channel, interface):
|
||||
## uses system.send_message to send message of the day string which can be updated in runtime
|
||||
send_message(MOTD, channel, 0, interface)
|
||||
|
||||
def send_news(channel, interface):
|
||||
## uses system.send_message to send the result of handleNews()
|
||||
send_message(handleNews(0, interface, 'readnews', False), channel, 0, interface)
|
||||
|
||||
def send_rss(channel, interface):
|
||||
## uses system.send_message to send the result of get_rss_feed()
|
||||
send_message(get_rss_feed(''), channel, 0, interface)
|
||||
|
||||
def send_marine_weather(channel, interface):
|
||||
## uses system.send_message to send the result of handle_mwx()
|
||||
send_message(handle_mwx(0, interface, 'mwx'), channel, 0, interface)
|
||||
|
||||
def send_sysinfo(channel, interface):
|
||||
## uses system.send_message to send the result of sysinfo()
|
||||
send_message(sysinfo('', 0, interface, False), channel, 0, interface)
|
||||
|
||||
def send_tide(channel, interface):
|
||||
## uses system.send_message to send the result of handle_tide()
|
||||
send_message(handle_tide(0, interface, channel), channel, 0, interface)
|
||||
|
||||
def send_sun(channel, interface):
|
||||
## uses system.send_message to send the result of handle_sun()
|
||||
send_message(handle_sun(0, interface, channel), channel, 0, interface)
|
||||
|
||||
### Send a joke every 2 minutes
|
||||
#schedule.every(2).minutes.do(lambda: send_joke(schedulerChannel, schedulerInterface))
|
||||
### Send a good morning message every day at 9 AM
|
||||
#schedule.every().day.at("09:00").do(lambda: send_good_morning(schedulerChannel, schedulerInterface))
|
||||
### Send a good morning message every day at 9 AM to DM node 4258675309 without above function
|
||||
#schedule.every().day.at("09:00").do(lambda: send_message("Good Morning Jenny", 0, 4258675309, schedulerInterface))
|
||||
### Send weather update every day at 8 AM
|
||||
#schedule.every().day.at("08:00").do(lambda: send_wx(schedulerChannel, schedulerInterface))
|
||||
### Send weather alerts every Wednesday at noon
|
||||
#schedule.every().wednesday.at("12:00").do(lambda: send_weather_alert(schedulerChannel, schedulerInterface))
|
||||
### Send configuration URL every 2 days at 10 AM
|
||||
#schedule.every(2).days.at("10:00").do(lambda: send_config_url(schedulerChannel, schedulerInterface))
|
||||
### Send net starting message every Wednesday at 7 PM
|
||||
#schedule.every().wednesday.at("19:00").do(lambda: send_net_starting(schedulerChannel, schedulerInterface))
|
||||
### Send welcome message every 2 days at 8 AM
|
||||
#schedule.every(2).days.at("08:00").do(lambda: send_welcome(schedulerChannel, schedulerInterface))
|
||||
### Send MOTD every day at 1 PM
|
||||
#schedule.every().day.at("13:00").do(lambda: send_motd(schedulerChannel, schedulerInterface))
|
||||
### Send bbslink message every 2 days at 10 AM
|
||||
#schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface))
|
||||
### Send news updates every 6 hours
|
||||
#schedule.every(6).hours.do(lambda: send_news(schedulerChannel, schedulerInterface))
|
||||
### Send RSS feed every day at 9 AM
|
||||
#schedule.every().day.at("09:00").do(lambda: send_rss(schedulerChannel, schedulerInterface))
|
||||
### Send marine weather every day at 6 AM
|
||||
#schedule.every().day.at("06:00").do(lambda: send_marine_weather(schedulerChannel, schedulerInterface))
|
||||
### Send system information every day at 12 PM
|
||||
#schedule.every().day.at("12:00").do(lambda: send_sysinfo(schedulerChannel, schedulerInterface))
|
||||
### Send tide information every day at 5 AM
|
||||
#schedule.every().day.at("05:00").do(lambda: send_tide(schedulerChannel, schedulerInterface))
|
||||
### Send sun information every day at 6 AM
|
||||
#schedule.every().day.at("06:00").do(lambda: send_sun(schedulerChannel, schedulerInterface))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up custom schedules: {e}")
|
||||
@@ -1,5 +1,8 @@
|
||||
# Load the bbs messages from the database file to screen for admin functions
|
||||
import pickle # pip install pickle
|
||||
import pickle
|
||||
import sqlite3
|
||||
|
||||
print ("\n Meshing-Around Database Admin Tool\n")
|
||||
|
||||
|
||||
# load the bbs messages from the database file
|
||||
@@ -106,7 +109,70 @@ except Exception as e:
|
||||
golfsim_score = "System: data/golfsim_hs.pkl not found"
|
||||
|
||||
|
||||
print ("\n Meshing-Around Database Admin Tool\n")
|
||||
# checklist.db admin display
|
||||
print("\nCurrent Check-ins Table\n")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect('../data/checklist.db')
|
||||
except Exception:
|
||||
conn = sqlite3.connect('data/checklist.db')
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT * FROM checkin
|
||||
WHERE removed = 0
|
||||
ORDER BY checkin_id DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
col_names = [desc[0] for desc in c.description]
|
||||
if rows:
|
||||
# Print header
|
||||
header = " | ".join(f"{name:<15}" for name in col_names)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
# Print rows
|
||||
for row in rows:
|
||||
print(" | ".join(f"{str(col):<15}" for col in row))
|
||||
else:
|
||||
print("No check-ins found.")
|
||||
except Exception as e:
|
||||
print(f"Error reading check-ins: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# inventory.db admin display
|
||||
print("\nCurrent Inventory Table\n")
|
||||
try:
|
||||
conn = sqlite3.connect('../data/inventory.db')
|
||||
except Exception:
|
||||
conn = sqlite3.connect('data/inventory.db')
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT * FROM inventory
|
||||
ORDER BY item_id DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
col_names = [desc[0] for desc in c.description]
|
||||
if rows:
|
||||
# Print header
|
||||
header = " | ".join(f"{name:<15}" for name in col_names)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
# Print rows
|
||||
for row in rows:
|
||||
print(" | ".join(f"{str(col):<15}" for col in row))
|
||||
else:
|
||||
print("No inventory items found.")
|
||||
except Exception as e:
|
||||
print(f"Error reading inventory: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Pickle database displays
|
||||
print ("System: bbs_messages")
|
||||
print (bbs_messages)
|
||||
print ("\nSystem: bbs_dm")
|
||||
|
||||
102
etc/fakeNode.py
Normal file
102
etc/fakeNode.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# https://github.com/pdxlocations/mudp/blob/main/examples/helloworld-example.py
|
||||
import time
|
||||
import random
|
||||
from pubsub import pub
|
||||
from meshtastic.protobuf import mesh_pb2
|
||||
from mudp import (
|
||||
conn,
|
||||
node,
|
||||
UDPPacketStream,
|
||||
send_nodeinfo,
|
||||
send_text_message,
|
||||
send_device_telemetry,
|
||||
send_position,
|
||||
send_environment_metrics,
|
||||
send_power_metrics,
|
||||
send_waypoint,
|
||||
)
|
||||
|
||||
MCAST_GRP = "224.0.0.69"
|
||||
MCAST_PORT = 4403
|
||||
KEY = "1PG7OiApB1nwvP+rz05pAQ=="
|
||||
|
||||
interface = UDPPacketStream(MCAST_GRP, MCAST_PORT, key=KEY)
|
||||
|
||||
def setup_node():
|
||||
node.node_id = "!deadbeef"
|
||||
node.long_name = "UDP Test"
|
||||
node.short_name = "UDP"
|
||||
node.channel = "LongFast"
|
||||
node.key = "AQ=="
|
||||
conn.setup_multicast(MCAST_GRP, MCAST_PORT)
|
||||
# Convert hex node_id to decimal (strip the '!' first)
|
||||
decimal_id = int(node.node_id[1:], 16)
|
||||
print(f"Node ID: {node.node_id} (decimal: {decimal_id})")
|
||||
print(f"Channel: {node.channel}, Key: {node.key}")
|
||||
|
||||
def demo_send_messages():
|
||||
print("Sending node info...")
|
||||
send_nodeinfo()
|
||||
time.sleep(3)
|
||||
print("Sending text message...")
|
||||
send_text_message("hello world")
|
||||
time.sleep(3)
|
||||
print("Sending device telemetry position...")
|
||||
send_position(latitude=37.7749, longitude=-122.4194, altitude=3000, precision_bits=3, ground_speed=5)
|
||||
time.sleep(3)
|
||||
print("Sending device telemetry local node data...")
|
||||
send_device_telemetry(battery_level=50, voltage=3.7, channel_utilization=25, air_util_tx=15, uptime_seconds=123456)
|
||||
time.sleep(3)
|
||||
print("Sending environment metrics...")
|
||||
send_environment_metrics(
|
||||
temperature=23.072298,
|
||||
relative_humidity=17.5602016,
|
||||
barometric_pressure=995.36261,
|
||||
gas_resistance=229.093369,
|
||||
voltage=5.816,
|
||||
current=-29.3,
|
||||
iaq=66,
|
||||
)
|
||||
time.sleep(3)
|
||||
print("Sending power metrics...")
|
||||
send_power_metrics(
|
||||
ch1_voltage=18.744,
|
||||
ch1_current=11.2,
|
||||
ch2_voltage=2.792,
|
||||
ch2_current=18.4,
|
||||
ch3_voltage=0,
|
||||
ch3_current=0,
|
||||
)
|
||||
time.sleep(3)
|
||||
print("Sending waypoint...")
|
||||
send_waypoint(
|
||||
id=random.randint(1, 2**32 - 1),
|
||||
latitude=45.271394,
|
||||
longitude=-121.736083,
|
||||
expire=0,
|
||||
locked_to=node.node_id,
|
||||
name="Camp",
|
||||
description="Main campsite near the lake",
|
||||
icon=0x1F3D5, # 🏕
|
||||
)
|
||||
|
||||
def main():
|
||||
setup_node()
|
||||
interface.start()
|
||||
print("MUDP Fake Node is running. Press Ctrl+C to exit.")
|
||||
print("You can send demo messages to the network.")
|
||||
try:
|
||||
while True:
|
||||
answer = input("Do you want to send demo messages? (y/n): ").strip().lower()
|
||||
if answer == "y":
|
||||
demo_send_messages()
|
||||
elif answer == "n":
|
||||
print("Exiting.")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
interface.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,16 +13,17 @@ User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=python3 mesh_bot.py
|
||||
ExecStop=pkill -f mesh_bot.py
|
||||
Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
ExecStop=
|
||||
KillSignal=SIGINT
|
||||
Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment="SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Disable Python's buffering of STDOUT and STDERR, so that output from the
|
||||
# service shows up immediately in systemd's logs
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
Restart=on-failure
|
||||
Type=notify #try simple if any problems
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -23,7 +23,6 @@ ExecStop=pkill -f report_generator5.py
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
Restart=on-failure
|
||||
Type=notify #try simple if any problems
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -8,9 +8,10 @@ User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=python3 modules/web.py
|
||||
ExecStop=pkill -f mesh_bot_w3.py
|
||||
Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
ExecStop=
|
||||
KillSignal=SIGINT
|
||||
Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment="SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Restart=on-failure
|
||||
|
||||
|
||||
@@ -13,16 +13,16 @@ User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=python3 pong_bot.py
|
||||
ExecStop=pkill -f pong_bot.py
|
||||
Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
ExecStop=
|
||||
KillSignal=SIGINT
|
||||
Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment="SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Disable Python's buffering of STDOUT and STDERR, so that output from the
|
||||
# service shows up immediately in systemd's logs
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
Restart=on-failure
|
||||
Type=notify #try simple if any problems
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
53
etc/set-permissions.sh
Normal file
53
etc/set-permissions.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Set ownership and permissions for Meshing Around application
|
||||
|
||||
# Check if run as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use first argument as user, or default to meshbot
|
||||
TARGET_USER="${1:-meshbot}"
|
||||
echo "DEBUG: TARGET_USER='$TARGET_USER'"
|
||||
|
||||
# Check if user exists
|
||||
if ! id "$TARGET_USER" >/dev/null 2>&1; then
|
||||
echo "User '$TARGET_USER' does not exist."
|
||||
CUR_USER="$(whoami)"
|
||||
printf "Would you like to use the current user (%s) instead? [y/N]: " "$CUR_USER"
|
||||
read yn
|
||||
if [ "$yn" = "y" ] || [ "$yn" = "Y" ]; then
|
||||
TARGET_USER="$CUR_USER"
|
||||
echo "Using current user: $TARGET_USER"
|
||||
if ! id "$TARGET_USER" >/dev/null 2>&1; then
|
||||
echo "Current user '$TARGET_USER' does not exist or cannot be determined."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Exiting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
id "$TARGET_USER"
|
||||
|
||||
echo "Setting ownership to $TARGET_USER:$TARGET_USER"
|
||||
|
||||
for dir in "/opt/meshing-around" "/opt/meshing-around/logs" "/opt/meshing-around/data"; do
|
||||
if [ -d "$dir" ]; then
|
||||
chown -R "$TARGET_USER:$TARGET_USER" "$dir"
|
||||
chmod 775 "$dir"
|
||||
else
|
||||
echo "Warning: Directory $dir does not exist, skipping."
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "/opt/meshing-around/config.ini" ]; then
|
||||
chown "$TARGET_USER:$TARGET_USER" "/opt/meshing-around/config.ini"
|
||||
chmod 664 "/opt/meshing-around/config.ini"
|
||||
else
|
||||
echo "Warning: /opt/meshing-around/config.ini does not exist, skipping."
|
||||
fi
|
||||
|
||||
echo "Permissions and ownership have been set."
|
||||
@@ -2,7 +2,7 @@
|
||||
# # Simulate meshing-around de K7MHI 2024
|
||||
from modules.log import logger, getPrettyTime # Import the logger; ### --> If you are reading this put the script in the project root <-- ###
|
||||
import time
|
||||
import datetime
|
||||
from datetime import datetime
|
||||
import random
|
||||
|
||||
# Initialize the tool
|
||||
@@ -51,8 +51,8 @@ def example_handler(message, nodeID, deviceID):
|
||||
msg = f"Hello {get_name_from_number(nodeID)}, simulator ready for testing {projectName} project! on device {deviceID}"
|
||||
msg += f" Your location is {location}"
|
||||
msg += f" you said: {message}"
|
||||
|
||||
|
||||
# Add timestamp
|
||||
msg += f" [Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]"
|
||||
return msg
|
||||
|
||||
|
||||
|
||||
173
etc/yolo_vision.py
Normal file
173
etc/yolo_vision.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
# YOLOv5 Object Detection with Movement Tracking using Raspberry Pi AI Camera or USB Webcam
|
||||
# YOLOv5 Requirements: yolo5 https://docs.ultralytics.com/yolov5/quickstart_tutorial/
|
||||
# PiCamera2 Requirements: picamera2 https://github.com/raspberrypi/picamera2
|
||||
# PiCamera2 may need `sudo apt install imx500-all` on Raspberry Pi OS
|
||||
# NVIDIA GPU PyTorch: https://developer.nvidia.com/cuda-downloads
|
||||
# Adjust settings below as needed, indended for meshing-around alert.txt output to meshtastic
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
|
||||
PI_CAM = 1 # 1 for Raspberry Pi AI Camera, 0 for USB webcam
|
||||
YOLO_MODEL = "yolov5s" # e.g., 'yolov5s', 'yolov5m', 'yolov5l', 'yolov5x'
|
||||
LOW_RES_MODE = 0 # 1 for low res (320x240), 0 for high res (640x480)
|
||||
IGNORE_CLASSES = ["bed", "chair"] # Add object names to ignore
|
||||
CONFIDENCE_THRESHOLD = 0.8 # Only show detections above this confidence
|
||||
MOVEMENT_THRESHOLD = 50 # Pixels to consider as movement (adjust as needed)
|
||||
IGNORE_STATIONARY = True # Whether to ignore stationary objects in output
|
||||
ALERT_FUSE_COUNT = 5 # Number of consecutive detections before alerting
|
||||
ALERT_FILE_PATH = "alert.txt" # e.g., "/opt/meshing-around/alert.txt" or None for no file output
|
||||
|
||||
try:
|
||||
import torch # YOLOv5 https://docs.ultralytics.com/yolov5/quickstart_tutorial/
|
||||
from PIL import Image # pip install pillow
|
||||
import numpy as np # pip install numpy
|
||||
import time
|
||||
import warnings
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
if PI_CAM:
|
||||
from picamera2 import Picamera2 # pip install picamera2
|
||||
else:
|
||||
import cv2
|
||||
except ImportError as e:
|
||||
print(f"Missing required module: {e.name}. Please review the comments in program, and try again.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Suppress FutureWarnings from imports upstream noise
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
CAMERA_TYPE = "RaspPi AI-Cam" if PI_CAM else "USB Webcam"
|
||||
RESOLUTION = "320x240" if LOW_RES_MODE else "640x480"
|
||||
|
||||
# Load YOLOv5
|
||||
model = torch.hub.load("ultralytics/yolov5", YOLO_MODEL)
|
||||
|
||||
if PI_CAM:
|
||||
picam2 = Picamera2()
|
||||
if LOW_RES_MODE:
|
||||
picam2.preview_configuration.main.size = (320, 240)
|
||||
else:
|
||||
picam2.preview_configuration.main.size = (640, 480)
|
||||
picam2.preview_configuration.main.format = "RGB888"
|
||||
picam2.configure("preview")
|
||||
picam2.start()
|
||||
else:
|
||||
if LOW_RES_MODE:
|
||||
cam_res = (320, 240)
|
||||
else:
|
||||
cam_res = (640, 480)
|
||||
cap = cv2.VideoCapture(0)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, cam_res[0])
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cam_res[1])
|
||||
|
||||
print("="*40)
|
||||
print(f" Sentinal Vision 3000 Booting Up!")
|
||||
print(f" Model: {YOLO_MODEL} | Camera: {CAMERA_TYPE} | Resolution: {RESOLUTION}")
|
||||
print("="*40)
|
||||
time.sleep(1)
|
||||
|
||||
def alert_output(msg, alert_file_path=ALERT_FILE_PATH):
|
||||
print(msg)
|
||||
if alert_file_path:
|
||||
# Remove timestamp for file output
|
||||
msg_no_time = " ".join(msg.split("] ")[1:]) if "] " in msg else msg
|
||||
with open(alert_file_path, "w") as f: # Use "a" to append instead of overwrite
|
||||
f.write(msg_no_time + "\n")
|
||||
|
||||
try:
|
||||
i = 0 # Frame counter if zero will be infinite
|
||||
system_normal_printed = False # system nominal flag, if true disables printing
|
||||
while True:
|
||||
i += 1
|
||||
if PI_CAM:
|
||||
frame = picam2.capture_array()
|
||||
else:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print("Failed to grab frame from webcam.")
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
img = Image.fromarray(frame)
|
||||
|
||||
results = model(img)
|
||||
df = results.pandas().xyxy[0]
|
||||
df = df[df['confidence'] >= CONFIDENCE_THRESHOLD] # Filter by confidence
|
||||
df = df[~df['name'].isin(IGNORE_CLASSES)] # Filter out ignored classes
|
||||
counts = df['name'].value_counts()
|
||||
if counts.empty:
|
||||
if not system_normal_printed:
|
||||
print("System nominal: No objects detected.")
|
||||
system_normal_printed = True
|
||||
continue # Skip the rest of the loop if nothing detected
|
||||
if counts.sum() > ALERT_FUSE_COUNT:
|
||||
system_normal_printed = False # Reset flag if something is detected
|
||||
|
||||
# Movement tracking
|
||||
if not hasattr(__builtins__, 'prev_centers'):
|
||||
__builtins__.prev_centers = {}
|
||||
if not hasattr(__builtins__, 'stationary_reported'):
|
||||
__builtins__.stationary_reported = set()
|
||||
if not hasattr(__builtins__, 'fuse_counters'):
|
||||
__builtins__.fuse_counters = {}
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_centers = {}
|
||||
detected_this_frame = set()
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
obj_id = f"{row['name']}_{idx}"
|
||||
x_center = (row['xmin'] + row['xmax']) / 2
|
||||
current_centers[obj_id] = x_center
|
||||
detected_this_frame.add(obj_id)
|
||||
|
||||
prev_x = __builtins__.prev_centers.get(obj_id)
|
||||
direction = ""
|
||||
count = counts[row['name']]
|
||||
|
||||
# Fuse logic
|
||||
fuse_counters = __builtins__.fuse_counters
|
||||
if obj_id not in fuse_counters:
|
||||
fuse_counters[obj_id] = 1
|
||||
else:
|
||||
fuse_counters[obj_id] += 1
|
||||
|
||||
if fuse_counters[obj_id] < ALERT_FUSE_COUNT:
|
||||
continue # Don't alert yet
|
||||
|
||||
if prev_x is not None:
|
||||
delta = x_center - prev_x
|
||||
if abs(delta) < MOVEMENT_THRESHOLD:
|
||||
direction = "stationary"
|
||||
if IGNORE_STATIONARY:
|
||||
if obj_id not in __builtins__.stationary_reported:
|
||||
alert_output(f"[{timestamp}] {count} {row['name']} {direction}")
|
||||
__builtins__.stationary_reported.add(obj_id)
|
||||
else:
|
||||
alert_output(f"[{timestamp}] {count} {row['name']} {direction}")
|
||||
else:
|
||||
direction = "moving right" if delta > 0 else "moving left"
|
||||
alert_output(f"[{timestamp}] {count} {row['name']} {direction}")
|
||||
__builtins__.stationary_reported.discard(obj_id)
|
||||
else:
|
||||
direction = "detected"
|
||||
alert_output(f"[{timestamp}] {count} {row['name']} {direction}")
|
||||
|
||||
# Reset fuse counters for objects not detected in this frame
|
||||
for obj_id in list(__builtins__.fuse_counters.keys()):
|
||||
if obj_id not in detected_this_frame:
|
||||
__builtins__.fuse_counters[obj_id] = 0
|
||||
|
||||
__builtins__.prev_centers = current_centers
|
||||
|
||||
time.sleep(1) # Adjust frame rate as needed
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user. Shutting down...")
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {e}", file=sys.stderr)
|
||||
finally:
|
||||
if PI_CAM:
|
||||
picam2.close()
|
||||
print("Camera closed. Goodbye!")
|
||||
else:
|
||||
cap.release()
|
||||
print("Webcam released. Goodbye!")
|
||||
199
install.sh
199
install.sh
@@ -13,8 +13,10 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
if [[ $NOPE -eq 1 ]]; then
|
||||
echo "Uninstalling Meshing Around and all related services..."
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Uninstalling Meshing Around ..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
sudo systemctl stop mesh_bot || true
|
||||
sudo systemctl disable mesh_bot || true
|
||||
|
||||
@@ -66,48 +68,71 @@ fi
|
||||
|
||||
# install.sh, Meshing Around installer script
|
||||
# Thanks for using Meshing Around!
|
||||
printf "\n########################"
|
||||
printf "\nMeshing Around Installer\n"
|
||||
printf "########################\n"
|
||||
printf "\nThis script will try and install the Meshing Around Bot and its dependencies.\n"
|
||||
printf "Installer works best in raspian/debian/ubuntu or foxbuntu embedded systems.\n"
|
||||
printf "If there is a problem, try running the installer again.\n"
|
||||
printf "\nChecking for dependencies...\n"
|
||||
|
||||
# fuse check for existing installation
|
||||
echo "=============================================="
|
||||
echo " Meshing Around Automated Installer "
|
||||
echo "=============================================="
|
||||
echo
|
||||
echo "This script will attempt to install the Meshing Around Bot and its dependencies."
|
||||
echo "Recommended for Raspbian, Debian, Ubuntu, or Foxbuntu embedded systems."
|
||||
echo "If you encounter any issues, try running the installer again."
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Checking for dependencies..."
|
||||
echo "----------------------------------------------"
|
||||
# check if we have an existing installation
|
||||
if [[ -f config.ini ]]; then
|
||||
printf "\nDetected existing installation, please backup and remove existing installation before proceeding\n"
|
||||
echo
|
||||
echo "=========================================================="
|
||||
echo " Detected existing installation of Meshing Around."
|
||||
echo " Please backup and remove the existing installation"
|
||||
echo " before proceeding with a new install."
|
||||
echo "=========================================================="
|
||||
exit 1
|
||||
fi
|
||||
# check if we have write access to the install path
|
||||
if [[ ! -w ${program_path} ]]; then
|
||||
echo
|
||||
echo "=========================================================="
|
||||
echo " ERROR: Install path not writable."
|
||||
echo " Try running the installer with sudo?"
|
||||
echo "=========================================================="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# check if we are in /opt/meshing-around
|
||||
if [[ "$program_path" != "/opt/meshing-around" ]]; then
|
||||
printf "\nIt is suggested to project path to /opt/meshing-around\n"
|
||||
printf "Do you want to move the project to /opt/meshing-around? (y/n)"
|
||||
echo "----------------------------------------------"
|
||||
echo " Project Path Decision"
|
||||
echo "----------------------------------------------"
|
||||
printf "\nIt is recommended to install Meshing Around in /opt/meshing-around if used as a service.\n"
|
||||
printf "Do you want to move the project to /opt/meshing-around now? (y/n): "
|
||||
read move
|
||||
if [[ $(echo "$move" | grep -i "^y") ]]; then
|
||||
sudo mv "$program_path" /opt/meshing-around
|
||||
cd /opt/meshing-around
|
||||
printf "\nProject moved to /opt/meshing-around. re-run the installer\n"
|
||||
printf "\nProject moved to /opt/meshing-around.\n"
|
||||
printf "Please re-run the installer from the new location.\n"
|
||||
exit 0
|
||||
else
|
||||
echo "Continuing installation in current directory: $program_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# check write access to program path
|
||||
if [[ ! -w ${program_path} ]]; then
|
||||
printf "\nInstall path not writable, try running the installer with sudo\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# if hostname = femtofox, then we are on embedded
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Embedded install? auto answers install stuff..."
|
||||
echo "----------------------------------------------"
|
||||
if [[ $(hostname) == "femtofox" ]]; then
|
||||
printf "\nDetected femtofox embedded system\n"
|
||||
printf "\n[INFO] Detected femtofox embedded system.\n"
|
||||
embedded="y"
|
||||
else
|
||||
# check if running on embedded
|
||||
printf "\nAre You installing into an embedded system like a luckfox or -native? most should say no here (y/n)"
|
||||
printf "\nAre you installing on an embedded system (like Luckfox)?\n"
|
||||
printf "Most users should answer 'n' here. (y/n): "
|
||||
read embedded
|
||||
fi
|
||||
|
||||
|
||||
if [[ $(echo "${embedded}" | grep -i "^y") ]]; then
|
||||
printf "\nDetected embedded skipping dependency installation\n"
|
||||
else
|
||||
@@ -137,6 +162,12 @@ else
|
||||
printf "\nDependencies installed\n"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Installing service files and templates..."
|
||||
echo "----------------------------------------------"
|
||||
# bootstrap
|
||||
mkdir -p "$program_path/logs"
|
||||
mkdir -p "$program_path/data"
|
||||
|
||||
# copy service files
|
||||
cp etc/pong_bot.tmp etc/pong_bot.service
|
||||
@@ -153,10 +184,14 @@ sed -i "$replace" etc/mesh_bot_w3_server.service
|
||||
|
||||
# copy modules/custom_scheduler.py template if it does not exist
|
||||
if [[ ! -f modules/custom_scheduler.py ]]; then
|
||||
cp etc/custom_scheduler.py modules/custom_scheduler.py
|
||||
cp etc/custom_scheduler.template modules/custom_scheduler.py
|
||||
printf "\nCustom scheduler template copied to modules/custom_scheduler.py\n"
|
||||
fi
|
||||
|
||||
# copy contents of etc/data to data/
|
||||
printf "\nCopying data templates to data/ directory\n"
|
||||
cp -r etc/data/* data/
|
||||
|
||||
# generate config file, check if it exists
|
||||
if [[ -f config.ini ]]; then
|
||||
printf "\nConfig file already exists, moving to backup config.old\n"
|
||||
@@ -166,6 +201,10 @@ fi
|
||||
cp config.template config.ini
|
||||
printf "\nConfig files generated!\n"
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Customizing configuration..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# update lat,long in config.ini
|
||||
latlong=$(curl --silent --max-time 20 https://ipinfo.io/loc || echo "48.50,-123.0")
|
||||
IFS=',' read -r lat lon <<< "$latlong"
|
||||
@@ -233,6 +272,10 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Installing bot service? - mesh or pong or none"
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# if $1 is passed
|
||||
if [[ $1 == "pong" ]]; then
|
||||
bot="pong"
|
||||
@@ -247,31 +290,38 @@ else
|
||||
read bot
|
||||
fi
|
||||
|
||||
# ask if we should add a user for the bot
|
||||
if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
|
||||
printf "\nDo you want to add a local user (meshbot) no login, for the bot? (y/n)"
|
||||
read meshbotservice
|
||||
# Decide which user to use for the service
|
||||
if [[ $(echo "${bot}" | grep -i "^n") ]]; then
|
||||
# Not installing as a service, use current user
|
||||
bot_user=$(whoami)
|
||||
else
|
||||
# Installing as a service (meshbot or pongbot), always use meshbot account
|
||||
if ! id meshbot &>/dev/null; then
|
||||
sudo useradd -M meshbot
|
||||
sudo usermod -L meshbot
|
||||
if ! getent group meshbot &>/dev/null; then
|
||||
sudo groupadd meshbot
|
||||
fi
|
||||
sudo usermod -a -G meshbot meshbot
|
||||
echo "Added user meshbot with no home directory"
|
||||
else
|
||||
echo "User meshbot already exists"
|
||||
fi
|
||||
bot_user="meshbot"
|
||||
fi
|
||||
|
||||
if [[ $(echo "${meshbotservice}" | grep -i "^y") ]] || [[ $(echo "${embedded}" | grep -i "^y") ]]; then
|
||||
sudo useradd -M meshbot
|
||||
sudo usermod -L meshbot
|
||||
sudo groupadd meshbot
|
||||
sudo usermod -a -G meshbot meshbot
|
||||
whoami="meshbot"
|
||||
echo "Added user meshbot with no home directory"
|
||||
else
|
||||
whoami=$(whoami)
|
||||
fi
|
||||
echo "----------------------------------------------"
|
||||
echo "Finalizing service installation..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# set the correct user in the service file
|
||||
replace="s|User=pi|User=$whoami|g"
|
||||
replace="s|User=pi|User=$bot_user|g"
|
||||
sed -i "$replace" etc/pong_bot.service
|
||||
sed -i "$replace" etc/mesh_bot.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.timer
|
||||
# set the correct group in the service file
|
||||
replace="s|Group=pi|Group=$whoami|g"
|
||||
replace="s|Group=pi|Group=$bot_user|g"
|
||||
sed -i "$replace" etc/pong_bot.service
|
||||
sed -i "$replace" etc/mesh_bot.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.service
|
||||
@@ -280,14 +330,10 @@ printf "\n service files updated\n"
|
||||
|
||||
# add user to groups for serial access
|
||||
printf "\nAdding user to dialout, bluetooth, and tty groups for serial access\n"
|
||||
sudo usermod -a -G dialout "$whoami"
|
||||
sudo usermod -a -G tty "$whoami"
|
||||
sudo usermod -a -G bluetooth "$whoami"
|
||||
echo "Added user $whoami to dialout, tty, and bluetooth groups"
|
||||
|
||||
sudo chown -R "$whoami:$whoami" "$program_path/logs"
|
||||
sudo chown -R "$whoami:$whoami" "$program_path/data"
|
||||
echo "Permissions set for meshbot on logs and data directories"
|
||||
sudo usermod -a -G dialout "$bot_user"
|
||||
sudo usermod -a -G tty "$bot_user"
|
||||
sudo usermod -a -G bluetooth "$bot_user"
|
||||
echo "Added user $bot_user to dialout, tty, and bluetooth groups"
|
||||
|
||||
# check and see if some sort of NTP is running
|
||||
if ! systemctl is-active --quiet ntp.service && \
|
||||
@@ -338,6 +384,10 @@ echo ""
|
||||
# echo "Check service status with: systemctl status mesh_bot_w3_server.service"
|
||||
# echo ""
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Extra options for installation..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# check if running on embedded for final steps
|
||||
if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
|
||||
# ask if emoji font should be installed for linux
|
||||
@@ -406,18 +456,13 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
|
||||
printf "List all timers: systemctl list-timers\n" >> install_notes.txt
|
||||
printf "View timer logs: journalctl -u mesh_bot_reporting.timer\n" >> install_notes.txt
|
||||
printf "*** Stay Up to date using 'bash update.sh' ***\n" >> install_notes.txt
|
||||
printf "sudo ./update.sh && sudo -u meshbot ./launch.sh mesh_bot.py\n" >> install_notes.txt
|
||||
|
||||
if [[ $(echo "${venv}" | grep -i "^y") ]]; then
|
||||
printf "\nFor running on venv, virtual launch bot with './launch.sh mesh' in path $program_path\n" >> install_notes.txt
|
||||
fi
|
||||
|
||||
read -p "Press enter to complete the installation, these commands saved to install_notes.txt"
|
||||
|
||||
printf "\nGood time to reboot? (y/n)"
|
||||
read reboot
|
||||
if [[ $(echo "${reboot}" | grep -i "^y") ]]; then
|
||||
sudo reboot
|
||||
fi
|
||||
else
|
||||
# we are on embedded
|
||||
# replace "type = serial" with "type = tcp" in config.ini
|
||||
@@ -461,10 +506,29 @@ else
|
||||
printf "Check timer status: systemctl status mesh_bot_reporting.timer\n" >> install_notes.txt
|
||||
printf "List all timers: systemctl list-timers\n" >> install_notes.txt
|
||||
printf "*** Stay Up to date using 'bash update.sh' ***\n" >> install_notes.txt
|
||||
printf "sudo ./update.sh && sudo -u meshbot ./launch.sh mesh_bot.py\n" >> install_notes.txt
|
||||
fi
|
||||
|
||||
printf "\nInstallation complete?\n"
|
||||
echo "----------------------------------------------"
|
||||
echo "Finalizing permissions..."
|
||||
echo "----------------------------------------------"
|
||||
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
sudo chown -R "$bot_user:$bot_user" "$program_path/logs"
|
||||
sudo chown -R "$bot_user:$bot_user" "$program_path/data"
|
||||
sudo chown "$bot_user:$bot_user" "$program_path/config.ini"
|
||||
sudo chmod 664 "$program_path/config.ini"
|
||||
echo "Permissions set for meshbot on config.ini"
|
||||
sudo chmod 775 "$program_path/logs"
|
||||
sudo chmod 775 "$program_path/data"
|
||||
echo "Permissions set for meshbot on logs and data directories"
|
||||
|
||||
printf "\nGood time to reboot? (y/n)"
|
||||
read reboot
|
||||
if [[ $(echo "${reboot}" | grep -i "^y") ]]; then
|
||||
sudo reboot
|
||||
fi
|
||||
printf "\nInstallation complete! 73\n"
|
||||
exit 0
|
||||
|
||||
# to uninstall the product run the following commands as needed
|
||||
@@ -507,6 +571,25 @@ exit 0
|
||||
# sudo rm -rf ~/.ollama
|
||||
|
||||
|
||||
# after install shenannigans
|
||||
# if install done manually
|
||||
# copy modules/custom_scheduler.py template if it does not exist
|
||||
# copy data files from etc/data to data/
|
||||
|
||||
|
||||
#### after install shenannigans
|
||||
# add 'bee = True' to config.ini General section.
|
||||
# wget https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a -O bee.txt
|
||||
# wget https://gist.githubusercontent.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a/raw/2411e31293a35f3e565f61e7490a806d4720ea7e/bee%2520movie%2520script -O bee.txt
|
||||
# place bee.txt in project root
|
||||
|
||||
####
|
||||
# download bible in text from places like https://www.biblesupersearch.com/bible-downloads/
|
||||
# in the project root place bible.txt and use verse = True
|
||||
# to use machine reading format like this
|
||||
# Genesis 1:1 In the beginning God created the heavens and the earth.
|
||||
# Genesis 1:2 And the earth was waste and void..
|
||||
# or simple format like this (less preferred)
|
||||
# Chapter 1
|
||||
# 1 In the beginning God created the heavens and the earth.
|
||||
# 2 And the earth was waste and void..
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# launch the application
|
||||
if [[ "$1" == pong* ]]; then
|
||||
python3 pong_bot.py
|
||||
@@ -28,8 +31,12 @@ elif [[ "$1" == "html5" ]]; then
|
||||
python3 etc/report_generator5.py
|
||||
elif [[ "$1" == add* ]]; then
|
||||
python3 script/addFav.py
|
||||
elif [[ "$1" == "game" ]]; then
|
||||
python3 script/game_serve.py
|
||||
elif [[ "$1" == "display" ]]; then
|
||||
python3 script/game_serve.py
|
||||
else
|
||||
echo "Please provide a bot to launch (pong/mesh) or a report to generate (html/html5) or addFav"
|
||||
echo "Please provide a bot to launch (pong/mesh/display) or a report to generate (html/html5) or addFav"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
398
mesh_bot.py
398
mesh_bot.py
@@ -16,7 +16,7 @@ import modules.settings as my_settings
|
||||
from modules.system import *
|
||||
|
||||
# list of commands to remove from the default list for DM only
|
||||
restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe", "quiz", "q:", "survey", "s:"]
|
||||
restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe", "tic-tac-toe", "quiz", "q:", "survey", "s:", "battleship"]
|
||||
restrictedResponse = "🤖only available in a Direct Message📵" # "" for none
|
||||
|
||||
def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM):
|
||||
@@ -31,6 +31,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
|
||||
"ask:": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel),
|
||||
"askai": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel),
|
||||
"bannode": lambda: handle_bbsban(message, message_from_id, isDM),
|
||||
"battleship": lambda: handleBattleship(message, message_from_id, deviceID),
|
||||
"bbsack": lambda: bbs_sync_posts(message, message_from_id, deviceID),
|
||||
"bbsdelete": lambda: handle_bbsdelete(message, message_from_id),
|
||||
"bbshelp": bbs_help,
|
||||
@@ -40,6 +41,8 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
|
||||
"bbspost": lambda: handle_bbspost(message, message_from_id, deviceID),
|
||||
"bbsread": lambda: handle_bbsread(message),
|
||||
"blackjack": lambda: handleBlackJack(message, message_from_id, deviceID),
|
||||
"approvecl": lambda: handle_checklist(message, message_from_id, deviceID),
|
||||
"denycl": lambda: handle_checklist(message, message_from_id, deviceID),
|
||||
"checkin": lambda: handle_checklist(message, message_from_id, deviceID),
|
||||
"checklist": lambda: handle_checklist(message, message_from_id, deviceID),
|
||||
"checkout": lambda: handle_checklist(message, message_from_id, deviceID),
|
||||
@@ -65,7 +68,24 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
|
||||
"history": lambda: handle_history(message, message_from_id, deviceID, isDM),
|
||||
"howfar": lambda: handle_howfar(message, message_from_id, deviceID, isDM),
|
||||
"howtall": lambda: handle_howtall(message, message_from_id, deviceID, isDM),
|
||||
"item": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemadd": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemlist": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemloan": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemremove": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemreset": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemreturn": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemsell": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"itemstats": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cart": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cartadd": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cartbuy": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cartclear": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cartlist": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cartremove": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"cartsell": lambda: handle_inventory(message, message_from_id, deviceID),
|
||||
"joke": lambda: tell_joke(message_from_id),
|
||||
"latest": lambda: get_newsAPI(message, message_from_id, deviceID, isDM),
|
||||
"leaderboard": lambda: get_mesh_leaderboard(message, message_from_id, deviceID),
|
||||
"lemonstand": lambda: handleLemonade(message, message_from_id, deviceID),
|
||||
"lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM),
|
||||
@@ -100,6 +120,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
|
||||
"tic-tac-toe": lambda: handleTicTacToe(message, message_from_id, deviceID),
|
||||
"tide": lambda: handle_tide(message_from_id, deviceID, channel_number),
|
||||
"valert": lambda: get_volcano_usgs(),
|
||||
"verse": lambda: read_verse(),
|
||||
"videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID),
|
||||
"whereami": lambda: handle_whereami(message_from_id, deviceID, channel_number),
|
||||
"whoami": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus),
|
||||
@@ -230,7 +251,11 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
|
||||
global multiPing
|
||||
myNodeNum = globals().get(f'myNodeNum{deviceID}', 777)
|
||||
if "?" in message and isDM:
|
||||
return message.split("?")[0].title() + " command returns SNR and RSSI, or hopcount from your message. Try adding e.g. @place or #tag"
|
||||
pingHelp = "🤖Ping Command Help:\n" \
|
||||
"🏓 Send 'ping' or 'ack' or 'test' to get a response.\n" \
|
||||
"🏓 Send 'ping <number>' to get multiple pings in DM"
|
||||
"🏓 ping @USERID to send a Joke from the bot"
|
||||
return pingHelp
|
||||
|
||||
msg = ""
|
||||
type = ''
|
||||
@@ -311,8 +336,11 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
|
||||
# no autoping in channels
|
||||
pingCount = 1
|
||||
|
||||
if pingCount > 51:
|
||||
if pingCount > 51 and pingCount <= 101:
|
||||
pingCount = 50
|
||||
if pingCount > 800:
|
||||
ban_hammer(message_from_id, deviceID, reason="Excessive auto-ping request")
|
||||
return "🚫⛔️auto-ping request denied."
|
||||
except ValueError:
|
||||
pingCount = -1
|
||||
|
||||
@@ -339,7 +367,8 @@ def handle_emergency(message_from_id, deviceID, message):
|
||||
# if user in bbs_ban_list return
|
||||
if str(message_from_id) in my_settings.bbs_ban_list:
|
||||
# silent discard
|
||||
logger.warning(f"System: {message_from_id} on spam list, no emergency responder alert sent")
|
||||
hammer_value = ban_hammer(message_from_id, deviceID, reason="Emergency Alert from banned node")
|
||||
logger.warning(f"System: {message_from_id} on spam list, no emergency responder alert sent. Ban hammer value: {hammer_value}")
|
||||
return ''
|
||||
# trgger alert to emergency_responder_alert_channel
|
||||
if message_from_id != 0:
|
||||
@@ -371,33 +400,73 @@ def handle_motd(message, message_from_id, isDM):
|
||||
return msg
|
||||
|
||||
def handle_echo(message, message_from_id, deviceID, isDM, channel_number):
|
||||
# Check if user is admin
|
||||
isAdmin = isNodeAdmin(message_from_id)
|
||||
|
||||
# Admin extended syntax: echo <string> c=<channel> d=<device>
|
||||
if isAdmin and message.strip().lower().startswith("echo ") and not message.strip().endswith("?"):
|
||||
msg_to_echo = message.split(" ", 1)[1]
|
||||
target_channel = channel_number
|
||||
target_device = deviceID
|
||||
|
||||
# Split into words to find c= and d=, but preserve spaces in message
|
||||
words = msg_to_echo.split()
|
||||
new_words = []
|
||||
for w in words:
|
||||
if w.startswith("c=") and w[2:].isdigit():
|
||||
target_channel = int(w[2:])
|
||||
elif w.startswith("d=") and w[2:].isdigit():
|
||||
target_device = int(w[2:])
|
||||
else:
|
||||
new_words.append(w)
|
||||
msg_to_echo = " ".join(new_words).strip()
|
||||
# Replace motd/MOTD with the current MOTD from settings
|
||||
msg_to_echo = " ".join(my_settings.MOTD if w.lower() == "motd" else w for w in msg_to_echo.split())
|
||||
# Replace welcome! with the current welcome_message from settings
|
||||
msg_to_echo = " ".join(my_settings.welcome_message if w.lower() == "welcome!" else w for w in msg_to_echo.split())
|
||||
|
||||
# Send echo to specified channel/device
|
||||
logger.debug(f"System: Admin Echo to channel {target_channel} device {target_device} message: {msg_to_echo}")
|
||||
time.sleep(splitDelay) # throttle for 2x send
|
||||
send_message(msg_to_echo, target_channel, 0, target_device)
|
||||
time.sleep(splitDelay) # throttle for 2x send
|
||||
return f"🐬echoed to channel {target_channel} device {target_device}"
|
||||
|
||||
# dev echoBinary off
|
||||
echoBinary = False
|
||||
if echoBinary:
|
||||
try:
|
||||
#send_raw_bytes echo the data to the channel with synch word:
|
||||
port_num = 256
|
||||
synch_word = b"echo:"
|
||||
message = message.split("echo ")[1]
|
||||
raw_bytes = synch_word + message.encode('utf-8')
|
||||
send_raw_bytes(message_from_id, raw_bytes, nodeInt=deviceID, channel=channel_number, portnum=port_num)
|
||||
parts = message.split("echo ", 1)
|
||||
if len(parts) > 1 and parts[1].strip() != "":
|
||||
msg_to_echo = parts[1]
|
||||
raw_bytes = synch_word + msg_to_echo.encode('utf-8')
|
||||
send_raw_bytes(message_from_id, raw_bytes, nodeInt=deviceID, channel=channel_number, portnum=port_num)
|
||||
return f"Sent binary echo message to {message_from_id} to {port_num} on channel {channel_number} device {deviceID}"
|
||||
except Exception as e:
|
||||
logger.error(f"System: Echo Exception {e}")
|
||||
return f"Sent binary echo message to {message_from_id} to {port_num} on channel {channel_number} device {deviceID}"
|
||||
|
||||
if "?" in message.lower():
|
||||
return "command returns your message back to you. Example:echo Hello World"
|
||||
elif "echo " in message.lower():
|
||||
parts = message.lower().split("echo ", 1)
|
||||
if "?" in message:
|
||||
isAdmin = isNodeAdmin(message_from_id)
|
||||
if isAdmin:
|
||||
return (
|
||||
"Admin usage: echo <message> c=<channel> d=<device>\n"
|
||||
"Example: echo Hello world c=1 d=2"
|
||||
)
|
||||
return "command returns your message back to you. Example: echo Hello World"
|
||||
|
||||
# process normal echo back to user
|
||||
elif message.strip().lower().startswith("echo "):
|
||||
parts = message.split("echo ", 1)
|
||||
if len(parts) > 1 and parts[1].strip() != "":
|
||||
echo_msg = parts[1]
|
||||
if channel_number != my_settings.echoChannel and not isDM:
|
||||
echo_msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + echo_msg
|
||||
return echo_msg
|
||||
else:
|
||||
return "Please provide a message to echo back to you. Example:echo Hello World"
|
||||
else:
|
||||
return "Please provide a message to echo back to you. Example:echo Hello World"
|
||||
return "Please provide a message to echo back to you. Example: echo Hello World"
|
||||
return "🐬echo.."
|
||||
|
||||
def handle_wxalert(message_from_id, deviceID, message):
|
||||
if my_settings.use_meteo_wxApi:
|
||||
@@ -416,15 +485,26 @@ def handle_wxalert(message_from_id, deviceID, message):
|
||||
|
||||
def handleNews(message_from_id, deviceID, message, isDM):
|
||||
news = ''
|
||||
# if news source is provided pass that to read_news()
|
||||
if "?" in message.lower():
|
||||
return "returns the news. Add a source e.g. 📰readnews mesh"
|
||||
elif "readnews" in message.lower():
|
||||
source = message.lower().replace("readnews", "").strip()
|
||||
if source:
|
||||
news = read_news(source)
|
||||
# if news source is provided pass that to read_news()
|
||||
if my_settings.news_block_mode:
|
||||
news = read_news(source=source, news_block_mode=True)
|
||||
elif my_settings.news_random_line_only:
|
||||
news = read_news(source=source, random_line_only=True)
|
||||
else:
|
||||
news = read_news(source=source)
|
||||
else:
|
||||
news = read_news()
|
||||
# no source provided, use news.txt
|
||||
if my_settings.news_block_mode:
|
||||
news = read_news(news_block_mode=True)
|
||||
elif my_settings.news_random_line_only:
|
||||
news = read_news(random_line_only=True)
|
||||
else:
|
||||
news = read_news()
|
||||
|
||||
if news:
|
||||
# if not a DM add the username to the beginning of msg
|
||||
@@ -519,6 +599,11 @@ def handle_satpass(message_from_id, deviceID, message='', vox=False):
|
||||
satList = my_settings.satListConfig
|
||||
message = message.lower()
|
||||
|
||||
# check api_throttle
|
||||
check_throttle = api_throttle(message_from_id, deviceID, apiName='satpass')
|
||||
if check_throttle:
|
||||
return check_throttle
|
||||
|
||||
# if user has a NORAD ID in the message
|
||||
if "satpass " in message:
|
||||
try:
|
||||
@@ -979,32 +1064,149 @@ def handleHamtest(message, nodeID, deviceID):
|
||||
|
||||
def handleTicTacToe(message, nodeID, deviceID):
|
||||
global tictactoeTracker
|
||||
index = 0
|
||||
msg = ''
|
||||
|
||||
# Find or create player tracker entry
|
||||
for i in range(len(tictactoeTracker)):
|
||||
if tictactoeTracker[i]['nodeID'] == nodeID:
|
||||
tictactoeTracker[i]["last_played"] = time.time()
|
||||
index = i+1
|
||||
break
|
||||
|
||||
tracker_entry = next((entry for entry in tictactoeTracker if entry['nodeID'] == nodeID), None)
|
||||
|
||||
# Handle end/exit command
|
||||
if message.lower().startswith('e'):
|
||||
if index:
|
||||
if tracker_entry:
|
||||
tictactoe.end(nodeID)
|
||||
tictactoeTracker.pop(index-1)
|
||||
tictactoeTracker.remove(tracker_entry)
|
||||
return "Thanks for playing! 🎯"
|
||||
|
||||
if not index:
|
||||
# If not found, create new tracker entry and ask for 2D/3D if not specified
|
||||
if not tracker_entry:
|
||||
mode = "2D"
|
||||
if "3d" in message.lower():
|
||||
mode = "3D"
|
||||
elif "2d" in message.lower():
|
||||
mode = "2D"
|
||||
tictactoeTracker.append({
|
||||
"nodeID": nodeID,
|
||||
"last_played": time.time()
|
||||
"last_played": time.time(),
|
||||
"mode": mode
|
||||
})
|
||||
msg = "🎯Tic-Tac-Toe🤖 '(e)nd'\n"
|
||||
|
||||
msg += tictactoe.play(nodeID, message)
|
||||
msg = f"🎯Tic-Tac-Toe🤖 '{mode}' mode. (e)nd to quit\n"
|
||||
msg += tictactoe.new_game(nodeID, mode=mode)
|
||||
return msg
|
||||
else:
|
||||
tracker_entry["last_played"] = time.time()
|
||||
|
||||
msg = tictactoe.play(nodeID, message)
|
||||
return msg
|
||||
|
||||
|
||||
def handleBattleship(message, nodeID, deviceID):
|
||||
global battleshipTracker
|
||||
from modules.games import battleship
|
||||
|
||||
# Helper to get short_name from tracker
|
||||
def get_short_name(nid):
|
||||
entry = next((e for e in battleshipTracker if e['nodeID'] == nid), None)
|
||||
return entry['short_name'] if entry and 'short_name' in entry else get_name_from_number(nid, 'short', deviceID)
|
||||
|
||||
msg_lower = message.lower().strip()
|
||||
tracker_entry = next((entry for entry in battleshipTracker if entry['nodeID'] == nodeID), None)
|
||||
|
||||
# End/exit command
|
||||
if msg_lower.startswith('end') or msg_lower.startswith('exit'):
|
||||
if tracker_entry:
|
||||
if 'session_id' in tracker_entry:
|
||||
battleship.Battleship.end_game(tracker_entry['session_id'])
|
||||
battleshipTracker.remove(tracker_entry)
|
||||
return "Thanks for playing Battleship! 🚢"
|
||||
|
||||
# Create new P2P game with short code
|
||||
if msg_lower.startswith("battleship new"):
|
||||
short_name = get_name_from_number(nodeID, 'short', deviceID)
|
||||
msg, code = battleship.Battleship.new_game(nodeID, vs_ai=False)
|
||||
battleshipTracker.append({
|
||||
"nodeID": nodeID,
|
||||
"short_name": short_name,
|
||||
"last_played": time.time(),
|
||||
"session_id": battleship.Battleship.short_codes.get(code, code)
|
||||
})
|
||||
return f"{msg}"
|
||||
|
||||
# Show open P2P games waiting for a player
|
||||
if msg_lower.startswith("battleship lobby"):
|
||||
open_codes = []
|
||||
for code, session_id in battleship.Battleship.short_codes.items():
|
||||
session = battleship.Battleship.sessions.get(session_id)
|
||||
if session and session.player2_id is None:
|
||||
open_codes.append(code)
|
||||
if not open_codes:
|
||||
return "No open Battleship games waiting for players."
|
||||
return "Open Battleship games (join with 'battleship join <code>'):\n" + ", ".join(open_codes)
|
||||
|
||||
# Join existing P2P game using short code
|
||||
if msg_lower.startswith("battleship join"):
|
||||
try:
|
||||
code = msg_lower.split("join", 1)[1].strip()
|
||||
except IndexError:
|
||||
return "Usage: battleship join <code>"
|
||||
session = battleship.Battleship.get_session(code)
|
||||
if not session:
|
||||
return "Session not found."
|
||||
if session.player2_id is not None:
|
||||
return "Session already has two players."
|
||||
session.player2_id = nodeID
|
||||
session.next_turn = nodeID # Make joining player go first!
|
||||
short_name = get_name_from_number(nodeID, 'short', deviceID)
|
||||
battleshipTracker.append({
|
||||
"nodeID": nodeID,
|
||||
"short_name": short_name,
|
||||
"last_played": time.time(),
|
||||
"session_id": session.session_id
|
||||
})
|
||||
p1_short_name = get_short_name(session.player1_id)
|
||||
send_message(
|
||||
f"{p1_short_name}, your opponent {short_name} has joined the game! It's their turn first.",
|
||||
0, # channel 0 for DM
|
||||
session.player1_id, # recipient nodeID
|
||||
deviceID
|
||||
)
|
||||
time.sleep(splitDelay) # slight delay to avoid message overlap
|
||||
return "You joined the game! It's your turn. Enter your move (e.g., 'B4')."
|
||||
|
||||
# If not found, create new tracker entry and new game vs AI (default)
|
||||
if not tracker_entry:
|
||||
short_name = get_name_from_number(nodeID, 'short', deviceID)
|
||||
msg, session_id = battleship.Battleship.new_game(nodeID)
|
||||
battleshipTracker.append({
|
||||
"nodeID": nodeID,
|
||||
"short_name": short_name,
|
||||
"last_played": time.time(),
|
||||
"session_id": session_id
|
||||
})
|
||||
return msg
|
||||
|
||||
# Update last played
|
||||
tracker_entry["last_played"] = time.time()
|
||||
session_id = tracker_entry.get("session_id")
|
||||
|
||||
# Play the game and check if we need to alert the next player
|
||||
response = battleship.playBattleship(message, nodeID, deviceID, session_id=session_id)
|
||||
|
||||
# --- Notify the next player when it's their turn in P2P ---
|
||||
session = battleship.Battleship.get_session(session_id)
|
||||
if session and not session.vs_ai and session.player1_id and session.player2_id:
|
||||
# Only notify if the game is not over (optional: add a game-over check)
|
||||
if getattr(session, "last_move", None):
|
||||
next_player_id = session.next_turn
|
||||
# Only notify if it's not the player who just moved
|
||||
if next_player_id != nodeID:
|
||||
next_player_short_name = get_short_name(next_player_id)
|
||||
send_message(
|
||||
f"{next_player_short_name}, it's your turn in Battleship! Enter your move (e.g., 'B4').",
|
||||
0, # channel 0 for DM
|
||||
next_player_id,
|
||||
deviceID
|
||||
)
|
||||
time.sleep(splitDelay) # slight delay to avoid message overlap
|
||||
|
||||
return response
|
||||
|
||||
def quizHandler(message, nodeID, deviceID):
|
||||
global quizGamePlayer
|
||||
user_name = get_name_from_number(nodeID)
|
||||
@@ -1184,6 +1386,10 @@ def handle_checklist(message, message_from_id, deviceID):
|
||||
location = get_node_location(message_from_id, deviceID)
|
||||
return process_checklist_command(message_from_id, message, name, location)
|
||||
|
||||
def handle_inventory(message, message_from_id, deviceID):
|
||||
name = get_name_from_number(message_from_id, 'short', deviceID)
|
||||
return process_inventory_command(message_from_id, message, name)
|
||||
|
||||
def handle_bbspost(message, message_from_id, deviceID):
|
||||
if "$" in message and not "example:" in message:
|
||||
subject = message.split("$")[1].split("#")[0]
|
||||
@@ -1387,10 +1593,18 @@ def handle_history(message, nodeid, deviceID, isDM, lheard=False):
|
||||
|
||||
def handle_whereami(message_from_id, deviceID, channel_number):
|
||||
location = get_node_location(message_from_id, deviceID, channel_number)
|
||||
# check api_throttle
|
||||
check_throttle = api_throttle(message_from_id, deviceID, apiName='whereami')
|
||||
if check_throttle:
|
||||
return check_throttle
|
||||
return where_am_i(str(location[0]), str(location[1]))
|
||||
|
||||
def handle_repeaterQuery(message_from_id, deviceID, channel_number):
|
||||
location = get_node_location(message_from_id, deviceID, channel_number)
|
||||
# check api_throttle
|
||||
check_throttle = api_throttle(message_from_id, deviceID, apiName='repeaterQuery')
|
||||
if check_throttle:
|
||||
return check_throttle
|
||||
if repeater_lookup == "rbook":
|
||||
return getRepeaterBook(str(location[0]), str(location[1]))
|
||||
elif repeater_lookup == "artsci":
|
||||
@@ -1486,10 +1700,21 @@ def handle_boot(mesh=True):
|
||||
f"{get_name_from_number(myNodeNum, 'short', i)}. NodeID: {myNodeNum}, {decimal_to_hex(myNodeNum)}")
|
||||
|
||||
if llm_enabled:
|
||||
logger.debug(f"System: Ollama LLM Enabled, loading model {my_settings.llmModel} please wait")
|
||||
llmLoad = llm_query(" ")
|
||||
msg = f"System: LLM Enabled"
|
||||
llmLoad = llm_query(" ", init=True)
|
||||
if "trouble" not in llmLoad:
|
||||
logger.debug(f"System: LLM Model {my_settings.llmModel} loaded")
|
||||
if my_settings.llmReplyToNonCommands:
|
||||
msg += " | Reply to DM's Enabled"
|
||||
if my_settings.llmUseWikiContext:
|
||||
wiki_source = "Kiwixpedia" if my_settings.use_kiwix_server else "Wikipedia"
|
||||
msg += f" | {wiki_source} Context Enabled"
|
||||
if my_settings.useOpenWebUI:
|
||||
msg += " | OpenWebUI API Enabled"
|
||||
else:
|
||||
msg += f" | Ollama API Model {my_settings.llmModel} loaded. Use {'RAW' if my_settings.rawLLMQuery else 'SYSTEM'} prompt mode."
|
||||
logger.debug(msg)
|
||||
else:
|
||||
logger.debug(f"System: Bad response from LLM: {llmLoad}")
|
||||
|
||||
if my_settings.bbs_enabled:
|
||||
logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}")
|
||||
@@ -1501,6 +1726,9 @@ def handle_boot(mesh=True):
|
||||
|
||||
if my_settings.solar_conditions_enabled:
|
||||
logger.debug("System: Celestial Telemetry Enabled")
|
||||
|
||||
if my_settings.meshagesTTS:
|
||||
logger.debug("System: Meshages TTS Text-to-Speech Enabled")
|
||||
|
||||
if my_settings.location_enabled:
|
||||
if my_settings.use_meteo_wxApi:
|
||||
@@ -1519,15 +1747,17 @@ def handle_boot(mesh=True):
|
||||
|
||||
if my_settings.wikipedia_enabled:
|
||||
if my_settings.use_kiwix_server:
|
||||
logger.debug(f"System: Wikipedia search Enabled using Kiwix server at {kiwix_url}")
|
||||
logger.debug(f"System: Wikipedia search Enabled using Kiwix server at {my_settings.kiwix_url}")
|
||||
else:
|
||||
logger.debug("System: Wikipedia search Enabled")
|
||||
|
||||
if my_settings.rssEnable:
|
||||
logger.debug(f"System: RSS Feed Reader Enabled for feeds: {rssFeedNames}")
|
||||
logger.debug(f"System: RSS Feed Reader Enabled for feeds: {my_settings.rssFeedNames}")
|
||||
if my_settings.enable_headlines:
|
||||
logger.debug("System: News Headlines Enabled from NewsAPI.org")
|
||||
|
||||
if my_settings.radio_detection_enabled:
|
||||
logger.debug(f"System: Radio Detection Enabled using rigctld at {my_settings.rigControlServerAddress} broadcasting to channels: {my_settings.sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}")
|
||||
logger.debug(f"System: Radio Detection Enabled using rigctld at {my_settings.rigControlServerAddress} broadcasting to channels: {my_settings.sigWatchBroadcastCh}")
|
||||
|
||||
if my_settings.file_monitor_enabled:
|
||||
logger.warning(f"System: File Monitor Enabled for {my_settings.file_monitor_file_path}, broadcasting to channels: {my_settings.file_monitor_broadcastCh}")
|
||||
@@ -1538,21 +1768,23 @@ def handle_boot(mesh=True):
|
||||
if my_settings.read_news_enabled:
|
||||
logger.debug(f"System: File Monitor News Reader Enabled for {my_settings.news_file_path}")
|
||||
if my_settings.bee_enabled:
|
||||
logger.debug("System: File Monitor Bee Monitor Enabled for bee.txt")
|
||||
|
||||
if my_settings.wxAlertBroadcastEnabled:
|
||||
logger.debug(f"System: Weather Alert Broadcast Enabled on channels {my_settings.wxAlertBroadcastChannel}")
|
||||
|
||||
if my_settings.emergencyAlertBrodcastEnabled:
|
||||
logger.debug(f"System: Emergency Alert Broadcast Enabled on channels {my_settings.emergencyAlertBroadcastCh} for FIPS codes {my_settings.myStateFIPSList}")
|
||||
if my_settings.myStateFIPSList == ['']:
|
||||
logger.warning("System: No FIPS codes set for iPAWS Alerts")
|
||||
|
||||
if my_settings.emergency_responder_enabled:
|
||||
logger.debug(f"System: Emergency Responder Enabled on channels {my_settings.emergency_responder_alert_channel} for interface {my_settings.emergency_responder_alert_interface}")
|
||||
|
||||
logger.debug("System: File Monitor Bee Monitor Enabled for 🐝bee.txt")
|
||||
if my_settings.bible_enabled:
|
||||
logger.debug("System: File Monitor Bible Verse Enabled for bible.txt")
|
||||
if my_settings.usAlerts:
|
||||
logger.debug(f"System: Emergency Alert Broadcast Enabled on channel {my_settings.emergency_responder_alert_channel} for interface {my_settings.emergency_responder_alert_interface}")
|
||||
if my_settings.enableDEalerts:
|
||||
logger.debug(f"System: NINA Alerts Enabled with counties {my_settings.myRegionalKeysDE}")
|
||||
if my_settings.volcanoAlertBroadcastEnabled:
|
||||
logger.debug(f"System: Volcano Alert Broadcast Enabled on channels {my_settings.volcanoAlertBroadcastChannel}")
|
||||
logger.debug(f"System: Volcano Alert Broadcast Enabled on channels {my_settings.emergency_responder_alert_channel} ignoreUSGSWords {my_settings.ignoreUSGSWords}")
|
||||
if my_settings.ipawsAlertEnabled:
|
||||
logger.debug(f"System: iPAWS Alerts Enabled with FIPS codes {my_settings.myStateFIPSList} ignorelist {my_settings.ignoreFEMAwords}")
|
||||
if my_settings.enableDEalerts:
|
||||
logger.debug(f"System: NINA Alerts Enabled with counties {my_settings.myRegionalKeysDE}")
|
||||
if my_settings.wxAlertBroadcastEnabled:
|
||||
logger.debug(f"System: Weather Alert Broadcast Enabled on channels {my_settings.emergency_responder_alert_channel} ignoreEASwords {my_settings.ignoreEASwords}")
|
||||
if my_settings.emergency_responder_enabled:
|
||||
logger.debug(f"System: Emergency Responder Enabled on channels {my_settings.emergency_responder_alert_channel}")
|
||||
|
||||
if my_settings.qrz_hello_enabled:
|
||||
if my_settings.train_qrz:
|
||||
@@ -1570,6 +1802,10 @@ def handle_boot(mesh=True):
|
||||
if my_settings.useDMForResponse:
|
||||
logger.debug("System: Respond by DM only")
|
||||
|
||||
if my_settings.autoBanEnabled:
|
||||
logger.debug(f"System: Auto-Ban Enabled for {my_settings.autoBanThreshold} messages in {my_settings.autoBanTimeframe} seconds")
|
||||
load_bbsBanList()
|
||||
|
||||
if my_settings.log_messages_to_file:
|
||||
logger.debug("System: Logging Messages to disk")
|
||||
if my_settings.syslog_to_file:
|
||||
@@ -1599,7 +1835,8 @@ def handle_boot(mesh=True):
|
||||
|
||||
if my_settings.checklist_enabled:
|
||||
logger.debug("System: CheckList Module Enabled")
|
||||
|
||||
if my_settings.inventory_enabled:
|
||||
logger.debug("System: Inventory Module Enabled")
|
||||
if my_settings.ignoreChannels:
|
||||
logger.debug(f"System: Ignoring Channels: {my_settings.ignoreChannels}")
|
||||
|
||||
@@ -1701,9 +1938,14 @@ def onReceive(packet, interface):
|
||||
message_from_id = packet['from']
|
||||
|
||||
# if message_from_id is not in the seenNodes list add it
|
||||
if not any(node['nodeID'] == message_from_id for node in seenNodes):
|
||||
seenNodes.append({'nodeID': message_from_id, 'rxInterface': rxNode, 'channel': channel_number, 'welcome': False, 'lastSeen': time.time()})
|
||||
|
||||
if not any(node.get('nodeID') == message_from_id for node in seenNodes):
|
||||
seenNodes.append({'nodeID': message_from_id, 'rxInterface': rxNode, 'channel': channel_number, 'welcome': False, 'first_seen': time.time(), 'lastSeen': time.time()})
|
||||
else:
|
||||
# update lastSeen time
|
||||
for node in seenNodes:
|
||||
if node.get('nodeID') == message_from_id:
|
||||
node['lastSeen'] = time.time()
|
||||
break
|
||||
# BBS DM MAIL CHECKER
|
||||
if bbs_enabled and 'decoded' in packet:
|
||||
msg = bbs_check_dm(message_from_id)
|
||||
@@ -1712,7 +1954,12 @@ def onReceive(packet, interface):
|
||||
message = "Mail: " + msg[1] + " From: " + get_name_from_number(msg[2], 'long', rxNode)
|
||||
bbs_delete_dm(msg[0], msg[1])
|
||||
send_message(message, channel_number, message_from_id, rxNode)
|
||||
|
||||
|
||||
# CHECK with ban_hammer() if the node is banned
|
||||
if str(message_from_id) in my_settings.bbs_ban_list or str(message_from_id) in my_settings.autoBanlist:
|
||||
logger.warning(f"System: Banned Node {message_from_id} tried to send a message. Ignored. Try adding to node firmware-blocklist")
|
||||
return
|
||||
|
||||
# handle TEXT_MESSAGE_APP
|
||||
try:
|
||||
if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
|
||||
@@ -1792,7 +2039,7 @@ def onReceive(packet, interface):
|
||||
logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start} calculated_hop_count:{hop_count} final_hop_value:{hop} via_mqtt:{via_mqtt} transport_mechanism:{transport_mechanism} Hostname:{rxNodeHostName}")
|
||||
|
||||
# check with stringSafeChecker if the message is safe
|
||||
if stringSafeCheck(message_string) is False:
|
||||
if stringSafeCheck(message_string, message_from_id) is False:
|
||||
logger.warning(f"System: Possibly Unsafe Message from {get_name_from_number(message_from_id, 'long', rxNode)}")
|
||||
|
||||
if help_message in message_string or welcome_message in message_string or "CMD?:" in message_string:
|
||||
@@ -1848,7 +2095,13 @@ def onReceive(packet, interface):
|
||||
else:
|
||||
# respond with help message on DM
|
||||
send_message(help_message, channel_number, message_from_id, rxNode)
|
||||
|
||||
|
||||
# add message to tts queue
|
||||
if meshagesTTS:
|
||||
# add to the tts_read_queue
|
||||
readMe = f"DM from {get_name_from_number(message_from_id, 'short', rxNode)}: {message_string}"
|
||||
tts_read_queue.append(readMe)
|
||||
|
||||
# log the message to the message log
|
||||
if log_messages_to_file:
|
||||
msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | DM | " + message_string.replace('\n', '-nl-'))
|
||||
@@ -1945,13 +2198,19 @@ def onReceive(packet, interface):
|
||||
msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} found the Word of the Day🎊:\n {wordWas}, {metaWas}"
|
||||
send_message(msg, channel_number, 0, rxNode)
|
||||
if bingo_win:
|
||||
msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} scored BINGO!🥳 {bingo_message}"
|
||||
msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} scored word-search-BINGO!🥳 {bingo_message}"
|
||||
send_message(msg, channel_number, 0, rxNode)
|
||||
|
||||
slotMachine = theWordOfTheDay.emojiMiniGame(message_string, emojiSeen=emojiSeen, nodeID=message_from_id, nodeInt=rxNode)
|
||||
if slotMachine:
|
||||
msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} played the Slot Machine and got: {slotMachine} 🥳"
|
||||
msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} played the emote-Fruit-Machine and got: {slotMachine} 🥳"
|
||||
send_message(msg, channel_number, 0, rxNode)
|
||||
|
||||
# add message to tts queue
|
||||
if my_settings.meshagesTTS and channel_number == my_settings.ttsChannels:
|
||||
# add to the tts_read_queue
|
||||
readMe = f"DM from {get_name_from_number(message_from_id, 'short', rxNode)}: {message_string}"
|
||||
tts_read_queue.append(readMe)
|
||||
else:
|
||||
# Evaluate non TEXT_MESSAGE_APP packets
|
||||
consumeMetadata(packet, rxNode, channel_number)
|
||||
@@ -1982,6 +2241,7 @@ gameTrackers = [
|
||||
(hamtestTracker, "HamTest", handleHamtest),
|
||||
(tictactoeTracker, "TicTacToe", handleTicTacToe),
|
||||
(surveyTracker, "Survey", surveyHandler),
|
||||
(battleshipTracker, "Battleship", handleBattleship),
|
||||
# quiz does not use a tracker (quizGamePlayer) always active
|
||||
]
|
||||
|
||||
@@ -2003,8 +2263,18 @@ async def main():
|
||||
tasks.append(asyncio.create_task(handleSignalWatcher(), name="hamlib"))
|
||||
|
||||
if my_settings.voxDetectionEnabled:
|
||||
from modules.radio import voxMonitor
|
||||
tasks.append(asyncio.create_task(voxMonitor(), name="vox_detection"))
|
||||
|
||||
if my_settings.meshagesTTS:
|
||||
tasks.append(asyncio.create_task(handleTTS(), name="tts_handler"))
|
||||
|
||||
if my_settings.wsjtx_detection_enabled:
|
||||
tasks.append(asyncio.create_task(handleWsjtxWatcher(), name="wsjtx_monitor"))
|
||||
|
||||
if my_settings.js8call_detection_enabled:
|
||||
tasks.append(asyncio.create_task(handleJs8callWatcher(), name="js8call_monitor"))
|
||||
|
||||
if my_settings.scheduler_enabled:
|
||||
from modules.scheduler import run_scheduler_loop, setup_scheduler
|
||||
setup_scheduler(schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface,
|
||||
|
||||
@@ -10,17 +10,19 @@ This document provides an overview of all modules available in the Mesh-Bot proj
|
||||
- [Games](#games)
|
||||
- [BBS (Bulletin Board System)](#bbs-bulletin-board-system)
|
||||
- [Checklist](#checklist)
|
||||
- [Inventory & Point of Sale](#inventory--point-of-sale)
|
||||
- [Location & Weather](#location--weather)
|
||||
- [Map Command](#map-command)
|
||||
- [EAS & Emergency Alerts](#eas--emergency-alerts)
|
||||
- [File Monitoring & News](#file-monitoring--news)
|
||||
- [Radio Monitoring](#radio-monitoring)
|
||||
- [Voice Commands (VOX)](#voice-commands-vox)
|
||||
- [Ollama LLM/AI](#ollama-llmai)
|
||||
- [Wikipedia Search](#wikipedia-search)
|
||||
- [News & Headlines (`latest` Command)](#news--headlines-latest-command)
|
||||
- [DX Spotter Module](#dx-spotter-module)
|
||||
- [Mesh Bot Scheduler User Guide](#mesh-bot-scheduler-user-guide)
|
||||
- [Mesh Bot Scheduler](#-mesh-bot-scheduler-user-guide)
|
||||
- [Other Utilities](#other-utilities)
|
||||
- [Echo Command](#echo-command)
|
||||
- [Messaging Settings](#messaging-settings)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Configuration Guide](#configuration-guide)
|
||||
@@ -37,29 +39,85 @@ See [modules/adding_more.md](adding_more.md) for developer notes.
|
||||
|
||||
### ping / pinging / test / testing / ack
|
||||
|
||||
- **Usage:** `ping`, `pinging`, `test`, `testing`, `ack`, `ping @user`, `ping #tag`
|
||||
- **Description:** Sends a ping to the bot. The bot responds with signal information such as SNR (Signal-to-Noise Ratio), RSSI (Received Signal Strength Indicator), and hop count. Used for making field report etc.
|
||||
- **Targeted Ping:**
|
||||
You can direct a ping to a specific user or group by mentioning their short name or tag:
|
||||
- `ping @NODE` — Pings a Joke to specific node by its short name.
|
||||
- **Example:**
|
||||
- **Usage:**
|
||||
- `ping`, `pinging`, `test`, `testing`, `ack`
|
||||
- `ping <number>` — Request multiple auto-pings (DM only)
|
||||
- `ping @user` — Target a specific user (can trigger a joke via BBS DM)
|
||||
- `ping ?` — Get help (DM only)
|
||||
- `ping stop` — Stop auto-ping
|
||||
|
||||
- **Description:**
|
||||
Sends a ping to the bot. The bot responds with signal and routing information such as SNR (Signal-to-Noise Ratio), RSSI (Received Signal Strength Indicator), hop count, and gateway status. Used for field reports, connectivity checks, and diagnostics.
|
||||
|
||||
#### **Response Types and Examples**
|
||||
|
||||
- **Basic Ping:**
|
||||
```
|
||||
ping
|
||||
```
|
||||
Response:
|
||||
```
|
||||
SNR: 12.5, RSSI: -80, Hops: 2
|
||||
🏓PONG [RF]
|
||||
SNR:12.5 RSSI:-80
|
||||
```
|
||||
- `[GW]` = Received via Gateway (internet or MQTT)
|
||||
- `[RF]` = Received via direct radio
|
||||
- `[F]` = Received via mesh/flood route
|
||||
|
||||
- **Meta Ping:**
|
||||
```
|
||||
ping @Top of the hill
|
||||
ping @Top Of Hill
|
||||
```
|
||||
Response:
|
||||
```
|
||||
PING @Top of the hill SNR: 10.2, RSSI: -85, Hops: 1
|
||||
🏓PONG @Top Of Hill [RF]
|
||||
SNR: 12.5, RSSI: -80, Hops: 2
|
||||
```
|
||||
- **Help:**
|
||||
Send `ping?` in a Direct Message (DM) for usage instructions.
|
||||
|
||||
- **Multi-ping (auto-ping):**
|
||||
```
|
||||
ping 10
|
||||
```
|
||||
Response:
|
||||
```
|
||||
🚦Initalizing 10 auto-ping
|
||||
```
|
||||
- The bot will send 10 pings at intervals (DM only).
|
||||
- Use `ping stop` to cancel.
|
||||
|
||||
- **Help:**
|
||||
```
|
||||
ping?
|
||||
```
|
||||
Response (DM only):
|
||||
```
|
||||
🤖Ping Command Help:
|
||||
🏓 Send 'ping' or 'ack' or 'test' to get a response.
|
||||
🏓 Send 'ping <number>' to get multiple pings in DM
|
||||
🏓 ping @USERID to send a Joke from the bot
|
||||
```
|
||||
|
||||
#### **Response Field Explanations**
|
||||
|
||||
- **SNR:** Signal-to-Noise Ratio (dB) — higher is better.
|
||||
- **RSSI:** Received Signal Strength Indicator (dBm) — closer to 0 is stronger.
|
||||
- **[GW]:** Message received via Gateway (internet/MQTT).
|
||||
- **[RF]:** Message received via direct radio.
|
||||
- **[F]:** Message received via mesh/flood route.
|
||||
|
||||
- **Joke via BBS DM:** If you ping `@'shortname'` and BBS is enabled, the bot will DM a joke to that user.
|
||||
|
||||
#### **Notes**
|
||||
|
||||
- You can mention users or tags in your ping/test messages (e.g., `ping @user`) to target specific nodes.
|
||||
- Some commands (like multi-ping) are only available in Direct Messages, depending on configuration.
|
||||
- If you request too many auto-pings, the bot may throttle or deny the request.
|
||||
- Use `ping stop` to cancel an ongoing auto-ping.
|
||||
|
||||
---
|
||||
|
||||
**Tip:**
|
||||
Use `ping?` in DM for a quick help message on all ping options.
|
||||
---
|
||||
|
||||
### Notes
|
||||
@@ -127,35 +185,175 @@ more at [meshBBS: How-To & API Documentation](bbstools.md)
|
||||
|
||||
## Checklist
|
||||
|
||||
### Enhanced Check-in/Check-out System
|
||||
|
||||
The checklist module provides asset tracking and accountability features with safety monitoring capabilities.
|
||||
|
||||
#### Basic Commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `checkin` | Check in a node/asset |
|
||||
| `checkout` | Check out a node/asset |
|
||||
| `checklist` | Show checklist database |
|
||||
| `checklist` | Show active check-ins |
|
||||
| `approvecl` | Admin Approve id |
|
||||
| `denycl` | Admin Remove id |
|
||||
|
||||
Enable in `[checklist]` section of `config.ini`.
|
||||
#### Advanced Features
|
||||
|
||||
- **Safety Monitoring with Time Intervals**
|
||||
- Check in with an expected interval: `checkin 60 Hunting in tree stand`
|
||||
- The system will track if you don't check back in within the specified time (in minutes)
|
||||
- Ideal for solo activities, remote work, or safety accountability
|
||||
|
||||
- **Approval Workflow**
|
||||
- `approvecl <id>` - Approve a pending check-in (admin)
|
||||
- `denycl <id>` - Deny/remove a check-in (admin)
|
||||
|
||||
more at [modules/checklist.md](checklist.md)
|
||||
|
||||
#### Examples
|
||||
|
||||
```
|
||||
# Basic check-in
|
||||
checkin Arrived at campsite
|
||||
|
||||
# Check-in with 30-minute monitoring interval
|
||||
checkin 30 Solo hiking on north trail
|
||||
|
||||
# Check out when done
|
||||
checkout Heading back to base
|
||||
|
||||
# View all active check-ins
|
||||
checklist
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
Enable in `[checklist]` section of `config.ini`:
|
||||
|
||||
```ini
|
||||
[checklist]
|
||||
enabled = True
|
||||
checklist_db = data/checklist.db
|
||||
reverse_in_out = False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Inventory & Point of Sale
|
||||
|
||||
### Complete Inventory Management System
|
||||
|
||||
The inventory module provides a full point-of-sale (POS) system with inventory tracking, cart management, and transaction logging.
|
||||
|
||||
#### Item Management Commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `itemadd <name> <qty> [price] [loc]` | Add new item to inventory |
|
||||
| `itemremove <name>` | Remove item from inventory |
|
||||
| `itemadd <name> <qty> [price] [loc]` | Update item price or quantity |
|
||||
| `itemsell <name> <qty> [notes]` | Quick sale (bypasses cart) |
|
||||
| `itemloan <name> <note>` - Loan/checkout an item |
|
||||
| `itemreturn <transaction_id>` | Reverse a transaction |
|
||||
| `itemlist` | View all inventory items |
|
||||
| `itemstats` | View today's sales statistics |
|
||||
|
||||
#### Cart Commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `cartadd <name> <qty>` | Add item to your cart |
|
||||
| `cartremove <name>` | Remove item from cart |
|
||||
| `cartlist` or `cart` | View your cart |
|
||||
| `cartbuy` or `cartsell` | Complete transaction |
|
||||
| `cartclear` | Empty your cart |
|
||||
|
||||
more at [modules/inventory.py](inventory.py)
|
||||
|
||||
#### Features
|
||||
|
||||
- **Transaction Tracking**: All sales are logged with timestamps and user information
|
||||
- **Cart Management**: Build up orders before completing transactions
|
||||
- **Penny Rounding**: Optional rounding for cash sales (USA mode)
|
||||
- Cash sales round down
|
||||
- Taxed sales round up
|
||||
- **Hot Item Stats**: Track best-selling items
|
||||
- **Location Tracking**: Optional warehouse/location field for items
|
||||
- **Transaction History**: Full audit trail of all sales and returns
|
||||
|
||||
#### Examples
|
||||
|
||||
```
|
||||
# Add items to inventory
|
||||
itemadd Radio 149.99 5 Shelf-A
|
||||
itemadd Battery 12.50 20 Warehouse-B
|
||||
|
||||
# View inventory
|
||||
itemlist
|
||||
|
||||
# Add items to cart
|
||||
cartadd Radio 2
|
||||
cartadd Battery 4
|
||||
|
||||
# View cart
|
||||
cartlist
|
||||
|
||||
# Complete sale
|
||||
cartsell Customer purchase
|
||||
|
||||
# Quick sale without cart
|
||||
itemsell Battery 1 Emergency sale
|
||||
|
||||
# View today's stats
|
||||
itemstats
|
||||
|
||||
# Process a return
|
||||
itemreturn 123
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
Enable in `[inventory]` section of `config.ini`:
|
||||
|
||||
```ini
|
||||
[inventory]
|
||||
enabled = True
|
||||
inventory_db = data/inventory.db
|
||||
# Set to True to enable penny rounding for USA cash sales
|
||||
disable_penny = False
|
||||
```
|
||||
|
||||
#### Database Schema
|
||||
|
||||
The system uses SQLite with four tables:
|
||||
- **items**: Product inventory
|
||||
- **transactions**: Sales records
|
||||
- **transaction_items**: Line items for each transaction
|
||||
- **carts**: Temporary shopping carts
|
||||
|
||||
---
|
||||
|
||||
## Location & Weather
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `wx` | Local weather forecast (NOAA/Open-Meteo) |
|
||||
| `wxc` | Weather in metric/imperial |
|
||||
| `wxa` | NOAA alerts |
|
||||
| `wxalert` | NOAA alerts (expanded) |
|
||||
| `mwx` | NOAA Coastal Marine Forecast |
|
||||
| `tide` | NOAA tide info |
|
||||
| `riverflow` | NOAA river flow info |
|
||||
| `earthquake` | USGS earthquake info |
|
||||
| `valert` | USGS volcano alerts |
|
||||
| `rlist` | Nearby repeaters from RepeaterBook |
|
||||
| `satpass` | Satellite pass info |
|
||||
| `howfar` | Distance traveled since last check |
|
||||
| `howtall` | Calculate height using sun angle |
|
||||
| `whereami` | Show current location |
|
||||
|
||||
| Command | Description |
|
||||
|--------------|---------------------------------------------------------|
|
||||
| `wx` | Local weather forecast (NOAA/Open-Meteo) |
|
||||
| `wxc` | Weather in metric/imperial units |
|
||||
| `wxa` | NOAA weather alerts (summary) |
|
||||
| `wxalert` | NOAA weather alerts (detailed/expanded) |
|
||||
| `mwx` | NOAA Coastal Marine Forecast |
|
||||
| `tide` | NOAA tide information |
|
||||
| `riverflow` | NOAA river flow information |
|
||||
| `earthquake` | USGS earthquake information |
|
||||
| `valert` | USGS volcano alerts |
|
||||
| `rlist` | Nearby repeaters from RepeaterBook |
|
||||
| `satpass` | Satellite pass information |
|
||||
| `howfar` | Distance traveled since last check |
|
||||
| `howtall` | Calculate height using sun angle |
|
||||
| `whereami` | Show current location/address |
|
||||
| `map` | Log/view location data to map.csv |
|
||||
Configure in `[location]` section of `config.ini`.
|
||||
|
||||
Certainly! Here’s a README help section for your `mapHandler` command, suitable for users of your meshbot:
|
||||
@@ -200,7 +398,6 @@ The `map` command allows you to log your current GPS location with a custom desc
|
||||
|--------------|-----------------------------------------------|
|
||||
| `ea`/`ealert`| FEMA iPAWS/EAS alerts (USA/DE) |
|
||||
|
||||
Enable in `[eas]` section of `config.ini`.
|
||||
|
||||
---
|
||||
|
||||
@@ -218,11 +415,73 @@ Configure in `[fileMon]` section of `config.ini`.
|
||||
|
||||
## Radio Monitoring
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `radio` | Monitor radio SNR via Hamlib |
|
||||
The Radio Monitoring module provides several ways to integrate amateur radio software with the mesh network.
|
||||
|
||||
Configure in `[radioMon]` section of `config.ini`.
|
||||
### Hamlib Integration
|
||||
|
||||
Monitors signal strength (S-meter) from a connected radio via Hamlib's `rigctld` daemon. When the signal exceeds a configured threshold, it broadcasts an alert to the mesh network with frequency and signal strength information.
|
||||
|
||||
### WSJT-X Integration
|
||||
|
||||
Monitors WSJT-X decode messages (FT8, FT4, WSPR, etc.) via UDP and forwards them to the mesh network. You can optionally filter by specific callsigns.
|
||||
|
||||
**Features:**
|
||||
- Listens to WSJT-X UDP broadcasts (default port 2237)
|
||||
- Decodes WSJT-X protocol messages
|
||||
- Filters by watched callsigns (or monitors all if no filter is set)
|
||||
- Forwards decode messages with SNR information to configured mesh channels
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
WSJT-X FT8: CQ K7MHI CN87 (+12dB)
|
||||
```
|
||||
|
||||
### JS8Call Integration
|
||||
|
||||
Monitors JS8Call messages via TCP API and forwards them to the mesh network. You can optionally filter by specific callsigns.
|
||||
|
||||
**Features:**
|
||||
- Connects to JS8Call TCP API (default port 2442)
|
||||
- Listens for directed and activity messages
|
||||
- Filters by watched callsigns (or monitors all if no filter is set)
|
||||
- Forwards messages with SNR information to configured mesh channels
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
JS8Call from W1ABC: HELLO WORLD (+8dB)
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Configure all radio monitoring features in the `[radioMon]` section of `config.ini`:
|
||||
|
||||
```ini
|
||||
[radioMon]
|
||||
# Hamlib monitoring
|
||||
enabled = False
|
||||
rigControlServerAddress = localhost:4532
|
||||
signalDetectionThreshold = -10
|
||||
|
||||
# WSJT-X monitoring
|
||||
wsjtxDetectionEnabled = False
|
||||
wsjtxUdpServerAddress = 127.0.0.1:2237
|
||||
wsjtxWatchedCallsigns = K7MHI,W1AW
|
||||
|
||||
# JS8Call monitoring
|
||||
js8callDetectionEnabled = False
|
||||
js8callServerAddress = 127.0.0.1:2442
|
||||
js8callWatchedCallsigns = K7MHI,W1AW
|
||||
|
||||
# Broadcast settings (shared by all radio monitoring)
|
||||
sigWatchBroadcastCh = 2
|
||||
sigWatchBroadcastInterface = 1
|
||||
```
|
||||
|
||||
**Configuration Notes:**
|
||||
- Leave `wsjtxWatchedCallsigns` or `js8callWatchedCallsigns` empty to monitor all callsigns
|
||||
- Callsigns are comma-separated, case-insensitive
|
||||
- Both services can run simultaneously
|
||||
- Messages are broadcast to the same channels as Hamlib alerts
|
||||
|
||||
---
|
||||
|
||||
@@ -250,9 +509,8 @@ Enable and configure VOX features in the `[vox]` section of `config.ini`.
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `askai` | Ask Ollama LLM AI |
|
||||
| `ask:` | Ask Ollama LLM AI (raw) |
|
||||
|
||||
Configure in `[ollama]` section of `config.ini`.
|
||||
More at [LLM Readme](llm.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -262,11 +520,66 @@ Configure in `[ollama]` section of `config.ini`.
|
||||
|--------------|-----------------------------------------------|
|
||||
| `wiki` | Search Wikipedia or local Kiwix server |
|
||||
|
||||
Configure in `[wikipedia]` section of `config.ini`.
|
||||
Configure in `[general]` section of `config.ini`.
|
||||
|
||||
---
|
||||
|
||||
## News & Headlines (`latest` Command)
|
||||
|
||||
The `latest` command allows you to fetch current news headlines or articles on any topic using the NewsAPI integration. This is useful for quickly checking the latest developments on a subject, even from the mesh.
|
||||
|
||||
### Usage
|
||||
|
||||
- **Get the latest headlines on a topic:**
|
||||
```
|
||||
latest <topic>
|
||||
```
|
||||
Example:
|
||||
```
|
||||
latest meshtastic
|
||||
```
|
||||
This will return the most recent news articles about "meshtastic".
|
||||
|
||||
- **General latest news:**
|
||||
```
|
||||
latest
|
||||
```
|
||||
Returns the latest general news headlines.
|
||||
|
||||
### How It Works
|
||||
|
||||
- The bot queries NewsAPI.org for the most recent articles matching your topic.
|
||||
- Each result includes the article title and a short description.
|
||||
|
||||
You need to go register for the developer key and read terms of use.
|
||||
|
||||
```ini
|
||||
# enable or disable the headline command which uses NewsAPI.org
|
||||
enableNewsAPI = True
|
||||
newsAPI_KEY = key at https://newsapi.org/register
|
||||
newsAPIregion = us
|
||||
```
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
🗞️:📰Meshtastic project launches new firmware
|
||||
The open-source mesh radio project Meshtastic has released a major firmware update...
|
||||
|
||||
📰How Meshtastic is changing off-grid communication
|
||||
A look at how Meshtastic devices are being used for emergency response...
|
||||
|
||||
📰Meshtastic featured at DEF CON 2025
|
||||
The Meshtastic team presented new features at DEF CON, drawing large crowds...
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- You can search for any topic, e.g., `latest wildfire`, `latest ham radio`, etc.
|
||||
- The number of results can be adjusted in the configuration.
|
||||
- Requires internet access for the bot to fetch news.
|
||||
|
||||
___
|
||||
## DX Spotter Module
|
||||
|
||||
The DX Spotter module allows you to fetch and display recent DX cluster spots from [spothole.app](https://spothole.app) directly in your mesh-bot.
|
||||
@@ -332,17 +645,23 @@ Configure in `[scheduler]` section of `config.ini`.
|
||||
See modules/custom_scheduler.py for advanced scheduling using python
|
||||
|
||||
**Purpose:**
|
||||
`scheduler.py` provides automated scheduling for Mesh Bot, allowing you to send messages, jokes, weather updates, and custom actions at specific times or intervals.
|
||||
`scheduler.py` provides automated scheduling for Mesh Bot, allowing you to send messages, jokes, weather updates, news, RSS feeds, marine weather, system info, tide info, sun info, and custom actions at specific times or intervals.
|
||||
|
||||
**How to Use:**
|
||||
- The scheduler is configured via your bot’s settings or commands, specifying what to send, when, and on which channel/interface.
|
||||
- Supports daily, weekly, hourly, and minutely schedules, as well as special jobs like jokes and weather.
|
||||
- Supports daily, weekly, hourly, and minutely schedules, as well as special jobs like jokes, weather, news, RSS feeds, marine weather, system info, tide info, and sun info.
|
||||
- For advanced automation, you can define your own schedules in `etc/custom_scheduler.py` (copied to `modules/custom_scheduler.py` at install).
|
||||
|
||||
**Features:**
|
||||
- **Basic Scheduling:** Send messages on a set schedule (e.g., every day at 09:00, every Monday at noon, every hour, etc.).
|
||||
- **Joke Scheduler:** Automatically send jokes every x min
|
||||
- **Weather Scheduler:** Send weather updates at time of day, daily.
|
||||
- **News Scheduler:** Send news updates at specified intervals.
|
||||
- **RSS Scheduler:** Send RSS feed updates at specified intervals.
|
||||
- **Marine Weather Scheduler:** Send marine weather forecasts at time of day, daily.
|
||||
- **System Info Scheduler:** Send system information at specified intervals.
|
||||
- **Tide Scheduler:** Send tide information at time of day, daily.
|
||||
- **Sun Scheduler:** Send sun information (sunrise/sunset) at time of day, daily.
|
||||
- **Custom Scheduler:** run your own scheduled jobs by editing `custom_scheduler.py`.
|
||||
- **Logging:** All scheduling actions are logged for debugging and monitoring.
|
||||
|
||||
@@ -409,6 +728,48 @@ You can schedule messages or actions using the following options in your configu
|
||||
- Time: `08:00`
|
||||
- → Sends a weather update daily at 8:00a.
|
||||
|
||||
#### **news**
|
||||
- Schedules the bot to send news updates at the specified interval (in hours).
|
||||
- **Example:**
|
||||
- Option: `news`
|
||||
- Interval: `6`
|
||||
- → Sends news updates every 6 hours.
|
||||
|
||||
#### **readrss**
|
||||
- Schedules the bot to send RSS feed updates at the specified interval (in hours).
|
||||
- **Example:**
|
||||
- Option: `readrss`
|
||||
- Interval: `4`
|
||||
- → Sends RSS feed updates every 4 hours.
|
||||
|
||||
#### **mwx**
|
||||
- Schedules the bot to send marine weather updates at the specified time of day, daily.
|
||||
- **Example:**
|
||||
- Option: `mwx`
|
||||
- Time: `06:00`
|
||||
- → Sends marine weather updates daily at 6:00a.
|
||||
|
||||
#### **sysinfo**
|
||||
- Schedules the bot to send system information at the specified interval (in hours).
|
||||
- **Example:**
|
||||
- Option: `sysinfo`
|
||||
- Interval: `12`
|
||||
- → Sends system information every 12 hours.
|
||||
|
||||
#### **tide**
|
||||
- Schedules the bot to send tide information at the specified time of day, daily.
|
||||
- **Example:**
|
||||
- Option: `tide`
|
||||
- Time: `05:00`
|
||||
- → Sends tide information daily at 5:00a.
|
||||
|
||||
#### **solar**
|
||||
- Schedules the bot to send sun information (sunrise/sunset) at the specified time of day, daily.
|
||||
- **Example:**
|
||||
- Option: `solar`
|
||||
- Time: `06:00`
|
||||
- → Sends sun information daily at 6:00a.
|
||||
|
||||
---
|
||||
|
||||
### Days of the Week
|
||||
@@ -431,6 +792,73 @@ You can use any of these options to schedule messages on specific days:
|
||||
- `history` — Command history
|
||||
- `cmd`/`cmd?` — Show help message (the bot avoids the use of saying or using help)
|
||||
|
||||
|
||||
|
||||
| Command | Description | ✅ Works Off-Grid |
|
||||
|--------------|-------------|------------------|
|
||||
| `echo` | Echo string back. Admins can use `echo <message> c=<channel> d=<device>` to send to any channel/device. | ✅ |
|
||||
---
|
||||
|
||||
### Echo Command
|
||||
|
||||
The `echo` command returns your message back to you.
|
||||
**Admins** can use an extended syntax to send a message to any channel and device.
|
||||
|
||||
#### Usage
|
||||
|
||||
- **Basic Echo (all users):**
|
||||
```
|
||||
echo Hello World
|
||||
```
|
||||
Response:
|
||||
```
|
||||
Hello World
|
||||
```
|
||||
|
||||
- **Admin Extended Syntax:**
|
||||
```
|
||||
echo <message> c=<channel> d=<device>
|
||||
```
|
||||
Example:
|
||||
```
|
||||
echo Hello world c=1 d=2
|
||||
```
|
||||
This will send "Hello world" to channel 1, device 2.
|
||||
|
||||
#### Special Keyword Substitution
|
||||
|
||||
- In admin echo, if you include the word `motd` or `MOTD` (case-insensitive), it will be replaced with the current Message of the Day.
|
||||
- If you include the word `welcome!` (case-insensitive), it will be replaced with the current Welcome Message as set in your configuration.
|
||||
|
||||
- Example:
|
||||
```
|
||||
echo Today's message is motd c=1 d=2
|
||||
```
|
||||
If the MOTD is "Potatos Are Cool!", the message sent will be:
|
||||
```
|
||||
Today's message is Potatos Are Cool!
|
||||
```
|
||||
|
||||
#### Notes
|
||||
- Only admins can use the `c=<channel>` and `d=<device>` override.
|
||||
- If you omit `c=<channel>` and `d=<device>`, the message is echoed back to your current channel/device.
|
||||
- MOTD substitution works for any standalone `motd` or `MOTD` in the message.
|
||||
|
||||
#### Help
|
||||
|
||||
- Send `echo?` for usage instructions.
|
||||
- Admins will see this help message:
|
||||
```
|
||||
Admin usage: echo <message> c=<channel> d=<device>
|
||||
Example: echo Hello world c=1 d=2
|
||||
```
|
||||
|
||||
#### Notes
|
||||
- Only admins can use the `c=<channel>` and `d=<device>` override.
|
||||
- If you omit `c=<channel>` and `d=<device>`, the message is echoed back to your current channel/device.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -444,7 +872,7 @@ You can use any of these options to schedule messages on specific days:
|
||||
## Troubleshooting
|
||||
|
||||
- Use the `logger` module for debug output.
|
||||
- See [modules/README.md](modules/README.md) for developer help.
|
||||
- See [modules/README.md](adding_more.md) for developer help.
|
||||
- Use `etc/simulator.py` for local testing.
|
||||
- Check the logs in the `logs/` directory for errors.
|
||||
|
||||
@@ -715,7 +1143,6 @@ This uses USA: SAME, FIPS, to locate the alerts in the feed. By default ignoring
|
||||
|
||||
```ini
|
||||
eAlertBroadcastEnabled = False # Goverment IPAWS/CAP Alert Broadcast
|
||||
eAlertBroadcastCh = 2,3 # Goverment Emergency IPAWS/CAP Alert Broadcast Channels
|
||||
ignoreFEMAenable = True # Ignore any headline that includes followig word list
|
||||
ignoreFEMAwords = test,exercise
|
||||
# comma separated list of FIPS codes to trigger local alert. find your FIPS codes at https://en.wikipedia.org/wiki/Federal_Information_Processing_Standard_state_code
|
||||
@@ -762,29 +1189,6 @@ enabled = True
|
||||
repeater_channels = [2, 3]
|
||||
```
|
||||
|
||||
### Ollama (LLM/AI) Settings
|
||||
For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma3:270m`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time.
|
||||
|
||||
```ini
|
||||
# Enable ollama LLM see more at https://ollama.com
|
||||
ollama = True # Ollama model to use (defaults to gemma2:2b)
|
||||
ollamaModel = gemma3:latest # Ollama model to use (defaults to gemma3:270m)
|
||||
ollamaHostName = http://localhost:11434 # server instance to use (defaults to local machine install)
|
||||
```
|
||||
|
||||
Also see `llm.py` for changing the defaults of:
|
||||
|
||||
```ini
|
||||
# LLM System Variables
|
||||
rawQuery = True # if True, the input is sent raw to the LLM if False, it is processed by the meshBotAI template
|
||||
|
||||
# Used in the meshBotAI template (legacy)
|
||||
llmEnableHistory = True # enable history for the LLM model to use in responses adds to compute time
|
||||
llmContext_fromGoogle = True # enable context from google search results helps with responses accuracy
|
||||
googleSearchResults = 3 # number of google search results to include in the context more results = more compute time
|
||||
```
|
||||
Note for LLM in docker with [NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/docker-specialized.html). Needed for the container with ollama running.
|
||||
|
||||
### Wikipedia Search Settings
|
||||
The Wikipedia search module can use either the online Wikipedia API or a local Kiwix server for offline wiki access. Kiwix is especially useful for mesh networks operating in remote or offline environments.
|
||||
|
||||
@@ -808,15 +1212,18 @@ To set up a local Kiwix server:
|
||||
1. Install Kiwix tools: https://kiwix.org/en/ `sudo apt install kiwix-tools -y`
|
||||
2. Download a Wikipedia ZIM file to `data/`: https://library.kiwix.org/ `wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_nopic_2025-09.zim`
|
||||
3. Run the server: `kiwix-serve --port 8080 wikipedia_en_100_nopic_2025-09.zim`
|
||||
4. Set `useKiwixServer = True` in your config.ini
|
||||
4. Set `useKiwixServer = True` in your config.ini with `wikipedia = True`
|
||||
|
||||
The bot will automatically extract and truncate content to fit Meshtastic's message size limits (~500 characters).
|
||||
|
||||
### Radio Monitoring
|
||||
A module allowing a Hamlib compatible radio to connect to the bot. When functioning, it will message the configured channel with a message of in use. **Requires hamlib/rigctld to be running as a service.**
|
||||
|
||||
Additionally, the module supports monitoring WSJT-X and JS8Call for amateur radio digital modes.
|
||||
|
||||
```ini
|
||||
[radioMon]
|
||||
# Hamlib monitoring
|
||||
enabled = True
|
||||
rigControlServerAddress = localhost:4532
|
||||
sigWatchBroadcastCh = 2 # channel to broadcast to can be 2,3
|
||||
@@ -824,8 +1231,30 @@ signalDetectionThreshold = -10 # minimum SNR as reported by radio via hamlib
|
||||
signalHoldTime = 10 # hold time for high SNR
|
||||
signalCooldown = 5 # the following are combined to reset the monitor
|
||||
signalCycleLimit = 5
|
||||
|
||||
# WSJT-X monitoring (FT8, FT4, WSPR, etc.)
|
||||
# Monitors WSJT-X UDP broadcasts and forwards decode messages to mesh
|
||||
wsjtxDetectionEnabled = False
|
||||
wsjtxUdpServerAddress = 127.0.0.1:2237 # UDP address and port where WSJT-X broadcasts
|
||||
wsjtxWatchedCallsigns = # Comma-separated list of callsigns to watch (empty = all)
|
||||
|
||||
# JS8Call monitoring
|
||||
# Connects to JS8Call TCP API and forwards messages to mesh
|
||||
js8callDetectionEnabled = False
|
||||
js8callServerAddress = 127.0.0.1:2442 # TCP address and port where JS8Call API listens
|
||||
js8callWatchedCallsigns = # Comma-separated list of callsigns to watch (empty = all)
|
||||
|
||||
# Broadcast settings (shared by Hamlib, WSJT-X, and JS8Call)
|
||||
sigWatchBroadcastInterface = 1
|
||||
```
|
||||
|
||||
**Setup Notes:**
|
||||
- **WSJT-X**: Enable UDP Server in WSJT-X settings (File → Settings → Reporting → Enable UDP Server)
|
||||
- **JS8Call**: Enable TCP Server in JS8Call settings (File → Settings → Reporting → Enable TCP Server API)
|
||||
- Both services can run simultaneously
|
||||
- Leave callsign filters empty to monitor all activity
|
||||
- Callsigns are case-insensitive and comma-separated (e.g., `K7MHI,W1AW`)
|
||||
|
||||
### File Monitoring
|
||||
Some dev notes for ideas of use
|
||||
|
||||
@@ -881,6 +1310,4 @@ enabled = True # QRZ Hello to new nodes
|
||||
qrz_hello_string = "send CMD or DM me for more info." # will be sent to all heard nodes once
|
||||
training = True # Training mode will not send the hello message to new nodes, use this to build up database
|
||||
```
|
||||
|
||||
|
||||
Happy meshing!
|
||||
442
modules/checklist.md
Normal file
442
modules/checklist.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# Enhanced Check-in/Check-out System
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced checklist module provides asset tracking and accountability features with advanced safety monitoring capabilities. This system is designed for scenarios where tracking people, equipment, or assets is critical for safety, accountability, or logistics.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🔐 Basic Check-in/Check-out
|
||||
- Simple interface for tracking when people or assets are checked in or out
|
||||
- Automatic duration calculation
|
||||
- Location tracking (GPS coordinates if available)
|
||||
- Notes support for additional context
|
||||
|
||||
### ⏰ Safety Monitoring with Time Intervals
|
||||
- Set expected check-in intervals for safety (minimal 20min)
|
||||
- Automatic tracking of overdue check-ins
|
||||
- Ideal for solo activities, remote work, or high-risk operations
|
||||
- Get alerts when someone hasn't checked in within their expected timeframe
|
||||
|
||||
### ✅ Approval Workflow
|
||||
- Admin approval system for check-ins
|
||||
- Deny/remove unauthorized check-ins
|
||||
- Maintain accountability and control
|
||||
|
||||
### 📍 Location Tracking
|
||||
- Automatic GPS location capture when checking in/out
|
||||
- View last known location in checklist
|
||||
|
||||
- **Time Window Monitoring**: Check-in with safety intervals (e.g., `checkin 60 Hunting in tree stand`)
|
||||
- Tracks if users don't check in within expected timeframe
|
||||
- Ideal for solo activities, remote work, or safety accountability
|
||||
- Provides `get_overdue_checkins()` function for alert integration
|
||||
|
||||
- **Approval Workflow**:
|
||||
- `approvecl <id>` - Approve pending check-ins (admin)
|
||||
- `denycl <id>` - Deny/remove check-ins (admin)
|
||||
- Support for approval-based workflows
|
||||
|
||||
#### New Commands:
|
||||
- `approvecl <id>` - Approve a check-in
|
||||
- `denycl <id>` - Deny a check-in
|
||||
- Enhanced `checkin [interval] [note]` - Now supports interval parameter
|
||||
|
||||
### Enhanced Check Out Options
|
||||
|
||||
You can now check out in three ways:
|
||||
|
||||
#### 1. Check Out the Most Recent Active Check-in
|
||||
```
|
||||
checkout [notes]
|
||||
```
|
||||
Checks out your most recent active check-in.
|
||||
*Example:*
|
||||
```
|
||||
checkout Heading back to camp
|
||||
```
|
||||
|
||||
#### 2. Check Out All Active Check-ins
|
||||
```
|
||||
checkout all [notes]
|
||||
```
|
||||
Checks out **all** of your active check-ins at once.
|
||||
*Example:*
|
||||
```
|
||||
checkout all Done for the day
|
||||
```
|
||||
*Response:*
|
||||
```
|
||||
Checked out 2 check-ins for Hunter1. Durations: 01:23:45, 00:15:30
|
||||
```
|
||||
|
||||
#### 3. Check Out a Specific Check-in by ID
|
||||
```
|
||||
checkout <checkin_id> [notes]
|
||||
```
|
||||
Checks out a specific check-in using its ID (as shown in the `checklist` command).
|
||||
*Example:*
|
||||
```
|
||||
checkout 123 Leaving early
|
||||
```
|
||||
*Response:*
|
||||
```
|
||||
Checked out check-in ID 123 for Hunter1. Duration: 00:45:12
|
||||
```
|
||||
|
||||
**Tip:**
|
||||
- Use `checklist` to see your current check-in IDs and durations.
|
||||
- You can always add a note to any checkout command for context.
|
||||
|
||||
---
|
||||
|
||||
These options allow you to manage your check-ins more flexibly, whether you want to check out everything at once or just a specific session.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `config.ini`:
|
||||
|
||||
```ini
|
||||
[checklist]
|
||||
enabled = True
|
||||
checklist_db = data/checklist.db
|
||||
# Set to True to reverse the meaning of checkin/checkout
|
||||
reverse_in_out = False
|
||||
```
|
||||
|
||||
## Commands Reference
|
||||
|
||||
### Basic Commands
|
||||
|
||||
#### Check In
|
||||
```
|
||||
checkin [interval] [notes]
|
||||
```
|
||||
|
||||
Check in to the system. Optionally specify a monitoring interval in minutes.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
checkin Arrived at base camp
|
||||
checkin 30 Solo hiking on north trail
|
||||
checkin 60 Working alone in tree stand
|
||||
checkin Going hunting
|
||||
```
|
||||
|
||||
#### Check Out
|
||||
```
|
||||
checkout [notes]
|
||||
```
|
||||
|
||||
Check out from the system. Shows duration since check-in.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
checkout Heading back
|
||||
checkout Mission complete
|
||||
checkout
|
||||
```
|
||||
|
||||
#### View Checklist
|
||||
```
|
||||
checklist
|
||||
```
|
||||
|
||||
Shows all active check-ins with durations.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
ID: Hunter1 checked-In for 01:23:45📝Solo hunting
|
||||
ID: Tech2 checked-In for 00:15:30📝Equipment repair
|
||||
```
|
||||
|
||||
|
||||
### Admin Commands
|
||||
|
||||
#### Approve Check-in
|
||||
```
|
||||
approvecl <checkin_id>
|
||||
```
|
||||
|
||||
Approve a pending check-in (requires admin privileges).
|
||||
|
||||
**Example:**
|
||||
```
|
||||
approvecl 123
|
||||
```
|
||||
|
||||
#### Deny Check-in
|
||||
```
|
||||
denycl <checkin_id>
|
||||
```
|
||||
|
||||
Deny and remove a check-in (requires admin privileges).
|
||||
|
||||
**Example:**
|
||||
```
|
||||
denycl 456
|
||||
```
|
||||
|
||||
## Safety Monitoring Feature
|
||||
|
||||
### How Time Intervals Work
|
||||
|
||||
When checking in with an interval parameter, the system will track whether you check in again or check out within that timeframe.
|
||||
|
||||
```
|
||||
checkin 60 Hunting in remote area
|
||||
```
|
||||
|
||||
This tells the system:
|
||||
- You're checking in now
|
||||
- You expect to check in again or check out within 60 minutes
|
||||
- If 60 minutes pass without activity, you'll be marked as overdue alert
|
||||
|
||||
### Use Cases for Time Intervals
|
||||
|
||||
1. **Solo Activities**: Hunting, hiking, or working alone
|
||||
```
|
||||
checkin 30 Solo patrol north sector
|
||||
```
|
||||
|
||||
2. **High-Risk Operations**: Tree work, equipment maintenance
|
||||
```
|
||||
checkin 45 Climbing tower for antenna work
|
||||
```
|
||||
|
||||
3. **Remote Work**: Working in isolated areas
|
||||
```
|
||||
checkin 120 Survey work in remote canyon
|
||||
```
|
||||
|
||||
4. **Check-in Points**: Regular status updates during long operations
|
||||
```
|
||||
checkin 15 Descending cliff
|
||||
```
|
||||
|
||||
5. **Check-in a reminder**: Reminders to check in on something like a pot roast
|
||||
```
|
||||
checkin 30 🍠🍖
|
||||
```
|
||||
|
||||
### Overdue Check-ins
|
||||
|
||||
The system tracks all check-ins with time intervals and can identify who is overdue. The module provides the `get_overdue_checkins()` function that returns a list of overdue users. It alerts on the 20min watchdog.
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Hunting Scenario
|
||||
|
||||
Hunter checks in before going into the field:
|
||||
```
|
||||
checkin 60 Hunting deer stand #3, north 40
|
||||
```
|
||||
|
||||
System response:
|
||||
```
|
||||
Checked✅In: Hunter1 (monitoring every 60min)
|
||||
```
|
||||
|
||||
If the hunter doesn't check out or check in again within 60 minutes, they will appear on the overdue list.
|
||||
|
||||
When done hunting:
|
||||
```
|
||||
checkout Heading back to camp
|
||||
```
|
||||
|
||||
System response:
|
||||
```
|
||||
Checked⌛️Out: Hunter1 duration 02:15:30
|
||||
```
|
||||
|
||||
### Example 2: Emergency Response Team
|
||||
|
||||
Team leader tracks team members:
|
||||
|
||||
```
|
||||
# Team members check in
|
||||
checkin 30 Search grid A-1
|
||||
checkin 30 Search grid A-2
|
||||
checkin 30 Search grid A-3
|
||||
```
|
||||
|
||||
Team leader views status:
|
||||
```
|
||||
checklist
|
||||
```
|
||||
|
||||
Response shows all active searchers with their durations.
|
||||
|
||||
### Example 3: Equipment Checkout
|
||||
|
||||
Track equipment loans:
|
||||
|
||||
```
|
||||
checkin Radio #5 for field ops
|
||||
```
|
||||
|
||||
When equipment is returned:
|
||||
```
|
||||
checkout Equipment returned
|
||||
```
|
||||
|
||||
### Example 4: Site Survey
|
||||
|
||||
Field technicians checking in at locations:
|
||||
|
||||
```
|
||||
# At first site
|
||||
checkin 45 Site survey tower location 1
|
||||
|
||||
# Moving to next site (automatically checks out from first)
|
||||
checkin 45 Site survey tower location 2
|
||||
```
|
||||
|
||||
## Integration with Other Systems
|
||||
|
||||
### Geo-Location Awareness
|
||||
|
||||
The checklist system automatically captures GPS coordinates when available. This can be used for:
|
||||
- Tracking last known position
|
||||
- Asset location management
|
||||
|
||||
### Alert Systems
|
||||
|
||||
The overdue check-in feature can trigger:
|
||||
- Notifications to supervisors
|
||||
- Automated messages to response teams
|
||||
- Email/SMS notifications (if configured)
|
||||
|
||||
### Scheduling Integration
|
||||
|
||||
Combine with the scheduler module to:
|
||||
- Send reminders to check in
|
||||
- Schedule periodic check-in requirements
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Always Include Context**: Add notes when checking in
|
||||
```
|
||||
checkin 30 North trail maintenance
|
||||
```
|
||||
Not just:
|
||||
```
|
||||
checkin
|
||||
```
|
||||
|
||||
2. **Set Realistic Intervals**: Don't set intervals too short or too long
|
||||
- Too short: False alarms
|
||||
- Too long: Defeats safety purpose
|
||||
|
||||
3. **Check Out Promptly**: Always check out when done to clear your status
|
||||
|
||||
4. **Use Consistent Naming**: If tracking equipment, use consistent names
|
||||
|
||||
### For Administrators
|
||||
|
||||
1. **Review Checklist Regularly**: Monitor who is checked in
|
||||
```
|
||||
checklist
|
||||
```
|
||||
|
||||
The list will show ✅ approved and ☑️ unapproved
|
||||
The alarm will only alert on approved.
|
||||
|
||||
in config.ini
|
||||
```ini
|
||||
# Auto approve new checklists
|
||||
auto_approve = True
|
||||
# Check-in reminder interval is 5min
|
||||
# Checkin broadcast interface and channel is emergency_handler interface and channel
|
||||
```
|
||||
|
||||
2. **Respond to Overdue Situations**: Act on overdue check-ins promptly
|
||||
|
||||
3. **Set Clear Policies**: Establish when and how to use the system
|
||||
|
||||
4. **Train Users**: Ensure everyone knows how to use time intervals
|
||||
|
||||
5. **Test the System**: Regularly verify the system is working
|
||||
|
||||
## Safety Scenarios
|
||||
|
||||
### Scenario 1: Tree Stand Hunting
|
||||
```
|
||||
checkin 60 Hunting from tree stand at north plot
|
||||
```
|
||||
If hunter falls or has medical emergency, they'll be marked overdue after 60 minutes.
|
||||
|
||||
### Scenario 2: Equipment Maintenance
|
||||
```
|
||||
checkin 30 Generator maintenance at remote site
|
||||
```
|
||||
If technician encounters danger, overdue status can be detected. Note: Requires alert system integration to send notifications.
|
||||
|
||||
### Scenario 3: Hiking
|
||||
```
|
||||
checkin 120 Day hike to mountain peak
|
||||
```
|
||||
Longer interval for extended activity, but still provides safety net.
|
||||
|
||||
### Scenario 4: Watchstanding
|
||||
```
|
||||
checkin 240 Night watch duty
|
||||
```
|
||||
Regular check-ins every 4 hours ensure person is alert and safe.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### checkin Table
|
||||
```sql
|
||||
CREATE TABLE checkin (
|
||||
checkin_id INTEGER PRIMARY KEY,
|
||||
checkin_name TEXT,
|
||||
checkin_date TEXT,
|
||||
checkin_time TEXT,
|
||||
location TEXT,
|
||||
checkin_notes TEXT,
|
||||
approved INTEGER DEFAULT 1,
|
||||
expected_checkin_interval INTEGER DEFAULT 0
|
||||
)
|
||||
```
|
||||
|
||||
### checkout Table
|
||||
```sql
|
||||
CREATE TABLE checkout (
|
||||
checkout_id INTEGER PRIMARY KEY,
|
||||
checkout_name TEXT,
|
||||
checkout_date TEXT,
|
||||
checkout_time TEXT,
|
||||
location TEXT,
|
||||
checkout_notes TEXT
|
||||
)
|
||||
```
|
||||
|
||||
## Reverse Mode
|
||||
|
||||
Setting `reverse_in_out = True` in config swaps the meaning of checkin and checkout commands. This is useful if your organization uses opposite terminology.
|
||||
|
||||
With `reverse_in_out = True`:
|
||||
- `checkout` command performs a check-in
|
||||
- `checkin` command performs a check-out
|
||||
|
||||
## Migration from Basic Checklist
|
||||
|
||||
The enhanced checklist is backward compatible with the basic version. Existing check-ins will continue to work, and new features are optional. The database will automatically upgrade to add new columns when first accessed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Not Seeing Overdue Alerts
|
||||
The overdue detection is built into the module, but alerts need to be configured in the main bot scheduler. Check your scheduler configuration.
|
||||
|
||||
### Wrong Duration Shown
|
||||
Duration is calculated from check-in time to current time. If system clock is wrong, durations will be incorrect. Ensure system time is accurate.
|
||||
|
||||
### Can't Approve/Deny Check-ins
|
||||
These are admin-only commands. Check that your node ID is in the `bbs_admin_list`.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or feature requests, please file an issue on the GitHub repository.
|
||||
@@ -3,41 +3,50 @@
|
||||
|
||||
import sqlite3
|
||||
from modules.log import logger
|
||||
from modules.settings import checklist_db, reverse_in_out, bbs_ban_list
|
||||
from modules.settings import checklist_db, reverse_in_out, bbs_ban_list, bbs_admin_list, checklist_auto_approve
|
||||
import time
|
||||
|
||||
trap_list_checklist = ("checkin", "checkout", "checklist", "purgein", "purgeout")
|
||||
trap_list_checklist = ("checkin", "checkout", "checklist", "approvecl", "denycl",)
|
||||
|
||||
def initialize_checklist_database():
|
||||
try:
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
# Check if the checkin table exists, and create it if it doesn't
|
||||
logger.debug("System: Checklist: Initializing database...")
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS checkin
|
||||
(checkin_id INTEGER PRIMARY KEY, checkin_name TEXT, checkin_date TEXT, checkin_time TEXT, location TEXT, checkin_notes TEXT)''')
|
||||
# Check if the checkout table exists, and create it if it doesn't
|
||||
(checkin_id INTEGER PRIMARY KEY, checkin_name TEXT, checkin_date TEXT,
|
||||
checkin_time TEXT, location TEXT, checkin_notes TEXT,
|
||||
approved INTEGER DEFAULT 1, expected_checkin_interval INTEGER DEFAULT 0,
|
||||
removed INTEGER DEFAULT 0)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS checkout
|
||||
(checkout_id INTEGER PRIMARY KEY, checkout_name TEXT, checkout_date TEXT, checkout_time TEXT, location TEXT, checkout_notes TEXT)''')
|
||||
(checkout_id INTEGER PRIMARY KEY, checkout_name TEXT, checkout_date TEXT,
|
||||
checkout_time TEXT, location TEXT, checkout_notes TEXT,
|
||||
checkin_id INTEGER, removed INTEGER DEFAULT 0)''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Checklist: Failed to initialize database: {e}")
|
||||
logger.error(f"Checklist: Failed to initialize database: {e} Please delete old checklist database file. rm data/checklist.db")
|
||||
return False
|
||||
|
||||
def checkin(name, date, time, location, notes):
|
||||
location = ", ".join(map(str, location))
|
||||
# checkin a user
|
||||
# Auto-approve if setting is enabled
|
||||
approved_value = 1 if checklist_auto_approve else 0
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("INSERT INTO checkin (checkin_name, checkin_date, checkin_time, location, checkin_notes) VALUES (?, ?, ?, ?, ?)", (name, date, time, location, notes))
|
||||
# # remove any checkouts that are older than the checkin
|
||||
# c.execute("DELETE FROM checkout WHERE checkout_date < ? OR (checkout_date = ? AND checkout_time < ?)", (date, date, time))
|
||||
c.execute(
|
||||
"INSERT INTO checkin (checkin_name, checkin_date, checkin_time, location, checkin_notes, removed, approved) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
(name, date, time, location, notes, approved_value)
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
initialize_checklist_database()
|
||||
c.execute("INSERT INTO checkin (checkin_name, checkin_date, checkin_time, location, checkin_notes) VALUES (?, ?, ?, ?, ?)", (name, date, time, location, notes))
|
||||
c.execute(
|
||||
"INSERT INTO checkin (checkin_name, checkin_date, checkin_time, location, checkin_notes, removed, approved) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
(name, date, time, location, notes, approved_value)
|
||||
)
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
@@ -47,85 +56,260 @@ def checkin(name, date, time, location, notes):
|
||||
else:
|
||||
return "Checked✅In: " + str(name)
|
||||
|
||||
def delete_checkin(checkin_id):
|
||||
# delete a checkin
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM checkin WHERE checkin_id = ?", (checkin_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return "Checkin deleted." + str(checkin_id)
|
||||
|
||||
def checkout(name, date, time_str, location, notes):
|
||||
def checkout(name, date, time_str, location, notes, all=False, checkin_id=None):
|
||||
location = ", ".join(map(str, location))
|
||||
# checkout a user
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
checked_out_ids = []
|
||||
durations = []
|
||||
try:
|
||||
# Check if the user has a checkin before checking out
|
||||
c.execute("""
|
||||
SELECT checkin_id FROM checkin
|
||||
WHERE checkin_name = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM checkout
|
||||
WHERE checkout_name = checkin_name
|
||||
AND (checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time))
|
||||
)
|
||||
ORDER BY checkin_date DESC, checkin_time DESC
|
||||
LIMIT 1
|
||||
""", (name,))
|
||||
checkin_record = c.fetchone()
|
||||
if checkin_record:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes) VALUES (?, ?, ?, ?, ?)", (name, date, time_str, location, notes))
|
||||
# calculate length of time checked in
|
||||
c.execute("SELECT checkin_time FROM checkin WHERE checkin_id = ?", (checkin_record[0],))
|
||||
checkin_time = c.fetchone()[0]
|
||||
checkin_datetime = time.strptime(date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
timeCheckedIn = time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds))
|
||||
# # remove the checkin record older than the checkout
|
||||
# c.execute("DELETE FROM checkin WHERE checkin_date < ? OR (checkin_date = ? AND checkin_time < ?)", (date, date, time_str))
|
||||
if checkin_id is not None:
|
||||
# Check out a specific check-in by ID
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_time, checkin_date FROM checkin
|
||||
WHERE checkin_id = ? AND checkin_name = ?
|
||||
""", (checkin_id, name))
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes, checkin_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, date, time_str, location, notes, row[0]))
|
||||
checkin_time, checkin_date = row[1], row[2]
|
||||
checkin_datetime = time.strptime(checkin_date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
durations.append(time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds)))
|
||||
checked_out_ids.append(row[0])
|
||||
elif all:
|
||||
# Check out all active check-ins for this user
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_time, checkin_date FROM checkin
|
||||
WHERE checkin_name = ?
|
||||
AND removed = 0
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout WHERE checkin_id IS NOT NULL
|
||||
)
|
||||
""", (name,))
|
||||
rows = c.fetchall()
|
||||
for row in rows:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes, checkin_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, date, time_str, location, notes, row[0]))
|
||||
checkin_time, checkin_date = row[1], row[2]
|
||||
checkin_datetime = time.strptime(checkin_date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
durations.append(time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds)))
|
||||
checked_out_ids.append(row[0])
|
||||
else:
|
||||
# Default: check out the most recent active check-in
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_time, checkin_date FROM checkin
|
||||
WHERE checkin_name = ?
|
||||
AND removed = 0
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout WHERE checkin_id IS NOT NULL
|
||||
)
|
||||
ORDER BY checkin_date DESC, checkin_time DESC
|
||||
LIMIT 1
|
||||
""", (name,))
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes, checkin_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, date, time_str, location, notes, row[0]))
|
||||
checkin_time, checkin_date = row[1], row[2]
|
||||
checkin_datetime = time.strptime(checkin_date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
durations.append(time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds)))
|
||||
checked_out_ids.append(row[0])
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
conn.close()
|
||||
initialize_checklist_database()
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes) VALUES (?, ?, ?, ?, ?)", (name, date, time_str, location, notes))
|
||||
return checkout(name, date, time_str, location, notes, all=all, checkin_id=checkin_id)
|
||||
else:
|
||||
conn.close()
|
||||
raise
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if checkin_record:
|
||||
if reverse_in_out:
|
||||
return "Checked⌛️In: " + str(name) + " duration " + timeCheckedIn
|
||||
if checked_out_ids:
|
||||
if all:
|
||||
return f"Checked out {len(checked_out_ids)} check-ins for {name}. Durations: {', '.join(durations)}"
|
||||
elif checkin_id is not None:
|
||||
return f"Checked out check-in ID {checkin_id} for {name}. Duration: {durations[0]}"
|
||||
else:
|
||||
return "Checked⌛️Out: " + str(name) + " duration " + timeCheckedIn
|
||||
if reverse_in_out:
|
||||
return f"Checked⌛️In: {name} duration {durations[0]}"
|
||||
else:
|
||||
return f"Checked⌛️Out: {name} duration {durations[0]}"
|
||||
else:
|
||||
return "None found for " + str(name)
|
||||
return f"None found for {name}"
|
||||
|
||||
def delete_checkout(checkout_id):
|
||||
# delete a checkout
|
||||
def approve_checkin(checkin_id):
|
||||
"""Approve a pending check-in"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM checkout WHERE checkout_id = ?", (checkout_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return "Checkout deleted." + str(checkout_id)
|
||||
try:
|
||||
c.execute("UPDATE checkin SET approved = 1 WHERE checkin_id = ?", (checkin_id,))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Check-in ID {checkin_id} not found."
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"✅ Check-in {checkin_id} approved."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Checklist: Error approving check-in: {e}")
|
||||
return "Error approving check-in."
|
||||
|
||||
def deny_checkin(checkin_id):
|
||||
"""Deny/delete a pending check-in"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("DELETE FROM checkin WHERE checkin_id = ?", (checkin_id,))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Check-in ID {checkin_id} not found."
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"❌ Check-in {checkin_id} denied and removed."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Checklist: Error denying check-in: {e}")
|
||||
return "Error denying check-in."
|
||||
|
||||
def set_checkin_interval(name, interval_minutes):
|
||||
"""Set expected check-in interval for a user (for safety monitoring)"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
# Update the most recent active check-in for this user
|
||||
c.execute("""
|
||||
UPDATE checkin
|
||||
SET expected_checkin_interval = ?
|
||||
WHERE checkin_name = ?
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout
|
||||
WHERE checkout_name = checkin_name
|
||||
AND (checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time))
|
||||
)
|
||||
ORDER BY checkin_date DESC, checkin_time DESC
|
||||
LIMIT 1
|
||||
""", (interval_minutes, name))
|
||||
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"No active check-in found for {name}."
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"⏰ Check-in interval set to {interval_minutes} minutes for {name}."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Checklist: Error setting check-in interval: {e}")
|
||||
return "Error setting check-in interval."
|
||||
|
||||
def get_overdue_checkins():
|
||||
"""Get list of users who haven't checked in within their expected interval"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
current_time = time.time()
|
||||
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_name, checkin_date, checkin_time, expected_checkin_interval, location, checkin_notes
|
||||
FROM checkin
|
||||
WHERE expected_checkin_interval > 0
|
||||
AND approved = 1
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout
|
||||
WHERE checkout_name = checkin_name
|
||||
AND (checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time))
|
||||
)
|
||||
""")
|
||||
|
||||
active_checkins = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
overdue_list = []
|
||||
for checkin_id, name, date, time_str, interval, location, notes in active_checkins:
|
||||
checkin_datetime = time.mktime(time.strptime(f"{date} {time_str}", "%Y-%m-%d %H:%M:%S"))
|
||||
time_since_checkin = (current_time - checkin_datetime) / 60 # in minutes
|
||||
|
||||
if time_since_checkin > interval:
|
||||
overdue_minutes = int(time_since_checkin - interval)
|
||||
overdue_list.append({
|
||||
'id': checkin_id,
|
||||
'name': name,
|
||||
'location': location,
|
||||
'overdue_minutes': overdue_minutes,
|
||||
'interval': interval,
|
||||
'checkin_notes': notes
|
||||
})
|
||||
|
||||
return overdue_list
|
||||
except sqlite3.OperationalError as e:
|
||||
conn.close()
|
||||
if "no such table" in str(e):
|
||||
initialize_checklist_database()
|
||||
return get_overdue_checkins()
|
||||
logger.error(f"Checklist: Error getting overdue check-ins: {e}")
|
||||
return []
|
||||
|
||||
def format_overdue_alert():
|
||||
header = "⚠️ OVERDUE CHECK-INS:\a\n"
|
||||
alert = ""
|
||||
try:
|
||||
"""Format overdue check-ins as an alert message"""
|
||||
overdue = get_overdue_checkins()
|
||||
if not overdue:
|
||||
return None
|
||||
for entry in overdue:
|
||||
hours = entry['overdue_minutes'] // 60
|
||||
minutes = entry['overdue_minutes'] % 60
|
||||
if hours > 0:
|
||||
alert += f"{entry['name']}: {hours}h {minutes}m overdue"
|
||||
else:
|
||||
alert += f"{entry['name']}: {minutes}m overdue"
|
||||
# if entry['location']:
|
||||
# alert += f" @ {entry['location']}"
|
||||
if entry['checkin_notes']:
|
||||
alert += f" 📝{entry['checkin_notes']}"
|
||||
alert += "\n"
|
||||
if alert:
|
||||
return header + alert.rstrip()
|
||||
except Exception as e:
|
||||
logger.error(f"Checklist: Error formatting overdue alert: {e}")
|
||||
return None
|
||||
|
||||
def list_checkin():
|
||||
# list checkins
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
SELECT * FROM checkin
|
||||
WHERE checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout
|
||||
WHERE checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time)
|
||||
)
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT * FROM checkin
|
||||
WHERE removed = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM checkout
|
||||
WHERE checkout.checkin_id = checkin.checkin_id
|
||||
)
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
conn.close()
|
||||
initialize_checklist_database()
|
||||
return list_checkin()
|
||||
else:
|
||||
conn.close()
|
||||
initialize_checklist_database()
|
||||
return "Error listing checkins."
|
||||
conn.close()
|
||||
timeCheckedIn = ""
|
||||
|
||||
# Get overdue info
|
||||
overdue = {entry['id']: entry for entry in get_overdue_checkins()}
|
||||
|
||||
checkin_list = ""
|
||||
for row in rows:
|
||||
checkin_id = row[0]
|
||||
# Calculate length of time checked in, including days
|
||||
total_seconds = time.time() - time.mktime(time.strptime(row[2] + " " + row[3], "%Y-%m-%d %H:%M:%S"))
|
||||
days = int(total_seconds // 86400)
|
||||
@@ -136,9 +320,31 @@ def list_checkin():
|
||||
timeCheckedIn = f"{days}d {hours:02}:{minutes:02}:{seconds:02}"
|
||||
else:
|
||||
timeCheckedIn = f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
checkin_list += "ID: " + row[1] + " checked-In for " + timeCheckedIn
|
||||
|
||||
# Add ⏰ if routine check-ins are required
|
||||
routine = ""
|
||||
if len(row) > 7 and row[7] and int(row[7]) > 0:
|
||||
routine = f" ⏰({row[7]}m)"
|
||||
|
||||
# Indicate approval status
|
||||
approved_marker = "✅" if row[6] == 1 else "☑️"
|
||||
|
||||
# Check if overdue
|
||||
if checkin_id in overdue:
|
||||
overdue_minutes = overdue[checkin_id]['overdue_minutes']
|
||||
overdue_hours = overdue_minutes // 60
|
||||
overdue_mins = overdue_minutes % 60
|
||||
if overdue_hours > 0:
|
||||
overdue_str = f"overdue by {overdue_hours}h {overdue_mins}m"
|
||||
else:
|
||||
overdue_str = f"overdue by {overdue_mins}m"
|
||||
status = f"{row[1]} {overdue_str}{routine}"
|
||||
else:
|
||||
status = f"{row[1]} checked-In for {timeCheckedIn}{routine}"
|
||||
|
||||
checkin_list += f"ID: {checkin_id} {approved_marker} {status}"
|
||||
if row[5] != "":
|
||||
checkin_list += "📝" + row[5]
|
||||
checkin_list += " 📝" + row[5]
|
||||
if row != rows[-1]:
|
||||
checkin_list += "\n"
|
||||
# if empty list
|
||||
@@ -153,31 +359,113 @@ def process_checklist_command(nodeID, message, name="none", location="none"):
|
||||
if str(nodeID) in bbs_ban_list:
|
||||
logger.warning("System: Checklist attempt from the ban list")
|
||||
return "unable to process command"
|
||||
is_admin = False
|
||||
if str(nodeID) in bbs_admin_list:
|
||||
is_admin = True
|
||||
|
||||
message_lower = message.lower()
|
||||
parts = message.split()
|
||||
|
||||
try:
|
||||
comment = message.split(" ", 1)[1]
|
||||
comment = message.split(" ", 1)[1] if len(parts) > 1 else ""
|
||||
except IndexError:
|
||||
comment = ""
|
||||
|
||||
# handle checklist commands
|
||||
if ("checkin" in message.lower() and not reverse_in_out) or ("checkout" in message.lower() and reverse_in_out):
|
||||
return checkin(name, current_date, current_time, location, comment)
|
||||
elif ("checkout" in message.lower() and not reverse_in_out) or ("checkin" in message.lower() and reverse_in_out):
|
||||
return checkout(name, current_date, current_time, location, comment)
|
||||
elif "purgein" in message.lower():
|
||||
return delete_checkin(nodeID)
|
||||
elif "purgeout" in message.lower():
|
||||
return delete_checkout(nodeID)
|
||||
elif "?" in message.lower():
|
||||
if ("checkin" in message_lower and not reverse_in_out) or ("checkout" in message_lower and reverse_in_out):
|
||||
# Check if interval is specified: checkin 60 comment
|
||||
interval = 0
|
||||
actual_comment = comment
|
||||
if comment and parts[1].isdigit():
|
||||
interval = int(parts[1])
|
||||
actual_comment = " ".join(parts[2:]) if len(parts) > 2 else ""
|
||||
|
||||
result = checkin(name, current_date, current_time, location, actual_comment)
|
||||
|
||||
# Set interval if specified
|
||||
if interval > 0:
|
||||
set_checkin_interval(name, interval)
|
||||
result += f" (monitoring every {interval}min)"
|
||||
|
||||
return result
|
||||
|
||||
elif ("checkout" in message_lower and not reverse_in_out) or ("checkin" in message_lower and reverse_in_out):
|
||||
# Support: checkout all, checkout <id>, or checkout [note]
|
||||
all_flag = False
|
||||
checkin_id = None
|
||||
actual_comment = comment
|
||||
|
||||
# Split the command into parts after the keyword
|
||||
checkout_args = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
if checkout_args:
|
||||
if checkout_args[0].lower() == "all":
|
||||
all_flag = True
|
||||
actual_comment = " ".join(checkout_args[1:]) if len(checkout_args) > 1 else ""
|
||||
elif checkout_args[0].isdigit():
|
||||
checkin_id = int(checkout_args[0])
|
||||
actual_comment = " ".join(checkout_args[1:]) if len(checkout_args) > 1 else ""
|
||||
else:
|
||||
actual_comment = " ".join(checkout_args)
|
||||
|
||||
return checkout(name, current_date, current_time, location, actual_comment, all=all_flag, checkin_id=checkin_id)
|
||||
|
||||
# elif "purgein" in message_lower:
|
||||
# return mark_checkin_removed_by_name(name)
|
||||
|
||||
# elif "purgeout" in message_lower:
|
||||
# return mark_checkout_removed_by_name(name)
|
||||
|
||||
elif "approvecl " in message_lower:
|
||||
if not is_admin:
|
||||
return "You do not have permission to approve check-ins."
|
||||
try:
|
||||
checkin_id = int(parts[1])
|
||||
return approve_checkin(checkin_id)
|
||||
except (ValueError, IndexError):
|
||||
return "Usage: checklistapprove <checkin_id>"
|
||||
|
||||
elif "denycl " in message_lower:
|
||||
if not is_admin:
|
||||
return "You do not have permission to deny check-ins."
|
||||
try:
|
||||
checkin_id = int(parts[1])
|
||||
return deny_checkin(checkin_id)
|
||||
except (ValueError, IndexError):
|
||||
return "Usage: checklistdeny <checkin_id>"
|
||||
|
||||
elif "?" in message_lower:
|
||||
if not reverse_in_out:
|
||||
return ("Command: checklist followed by\n"
|
||||
"checkout to check out\n"
|
||||
"purgeout to delete your checkout record\n"
|
||||
"Example: checkin Arrived at park")
|
||||
"checkin [interval] [note]\n"
|
||||
"checkout [all] [note]\n"
|
||||
"Example: checkin 60 Leaving for a hike")
|
||||
else:
|
||||
return ("Command: checklist followed by\n"
|
||||
"checkin to check out\n"
|
||||
"purgeout to delete your checkin record\n"
|
||||
"Example: checkout Leaving park")
|
||||
elif "checklist" in message.lower():
|
||||
"checkout [all] [interval] [note]\n"
|
||||
"checkin [note]\n"
|
||||
"Example: checkout 60 Leaving for a hike")
|
||||
|
||||
elif message_lower.strip() == "checklist":
|
||||
return list_checkin()
|
||||
|
||||
else:
|
||||
return "Invalid command."
|
||||
return "Invalid command."
|
||||
|
||||
def mark_checkin_removed_by_name(name):
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE checkin SET removed = 1 WHERE checkin_name = ?", (name,))
|
||||
affected = c.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"Marked {affected} check-in(s) as removed for {name}."
|
||||
|
||||
def mark_checkout_removed_by_name(name):
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE checkout SET removed = 1 WHERE checkout_name = ?", (name,))
|
||||
affected = c.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"Marked {affected} checkout(s) as removed for {name}."
|
||||
@@ -2,7 +2,7 @@
|
||||
# Fetches DX spots from Spothole API based on user commands
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import requests
|
||||
import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from modules.log import logger
|
||||
from modules.settings import latitudeValue, longitudeValue
|
||||
|
||||
@@ -69,7 +69,6 @@ def get_spothole_spots(source=None, band=None, mode=None, date=None, dx_call=Non
|
||||
url = "https://spothole.app/api/v1/spots"
|
||||
params = {}
|
||||
fetched_count = 0
|
||||
|
||||
|
||||
# Add administrative filters if provided
|
||||
qrt = False # Always fetch active spots
|
||||
@@ -83,7 +82,7 @@ def get_spothole_spots(source=None, band=None, mode=None, date=None, dx_call=Non
|
||||
params["needs_sig"] = str(needs_sig).lower()
|
||||
params["needs_sig_ref"] = 'true'
|
||||
# Only get spots from last 9 hours
|
||||
received_since_dt = datetime.datetime.utcnow() - datetime.timedelta(hours=9)
|
||||
received_since_dt = datetime.utcnow() - timedelta(hours=9)
|
||||
received_since = int(received_since_dt.timestamp())
|
||||
params["received_since"] = received_since
|
||||
|
||||
@@ -170,7 +169,7 @@ def get_spothole_spots(source=None, band=None, mode=None, date=None, dx_call=Non
|
||||
return spots
|
||||
|
||||
def handle_post_dxspot():
|
||||
time = int(datetime.datetime.utcnow().timestamp())
|
||||
time = int(datetime.utcnow().timestamp())
|
||||
freq = 14200000 # 14 MHz
|
||||
comment = "Test spot please ignore"
|
||||
de_spot = "N0CALL"
|
||||
|
||||
@@ -6,6 +6,7 @@ from modules.settings import (
|
||||
file_monitor_file_path,
|
||||
news_file_path,
|
||||
news_random_line_only,
|
||||
news_block_mode,
|
||||
allowXcmd,
|
||||
bbs_admin_list,
|
||||
xCmd2factorEnabled,
|
||||
@@ -23,16 +24,38 @@ trap_list_filemon = ("readnews",)
|
||||
NEWS_DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
|
||||
newsSourcesList = []
|
||||
|
||||
def read_file(file_monitor_file_path, random_line_only=False):
|
||||
def read_file(file_monitor_file_path, random_line_only=False, news_block_mode=False, verse_only=False):
|
||||
try:
|
||||
if not os.path.exists(file_monitor_file_path):
|
||||
if file_monitor_file_path == "bee.txt":
|
||||
return "🐝buzz 💐buzz buzz🍯"
|
||||
if random_line_only:
|
||||
if file_monitor_file_path == 'bible.txt':
|
||||
return "🐝Go, and make disciples of all nations."
|
||||
if verse_only:
|
||||
# process verse/bible file
|
||||
verse = get_verses(file_monitor_file_path)
|
||||
return verse
|
||||
elif news_block_mode:
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().replace('\r\n', '\n').replace('\r', '\n')
|
||||
blocks = []
|
||||
block = []
|
||||
for line in content.split('\n'):
|
||||
if line.strip() == '':
|
||||
if block:
|
||||
blocks.append('\n'.join(block).strip())
|
||||
block = []
|
||||
else:
|
||||
block.append(line)
|
||||
if block:
|
||||
blocks.append('\n'.join(block).strip())
|
||||
blocks = [b for b in blocks if b]
|
||||
return random.choice(blocks) if blocks else None
|
||||
elif random_line_only:
|
||||
# read a random line from the file
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
return random.choice(lines)
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
return random.choice(lines) if lines else None
|
||||
else:
|
||||
# read the whole file
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
@@ -42,13 +65,67 @@ def read_file(file_monitor_file_path, random_line_only=False):
|
||||
logger.warning(f"FileMon: Error reading file: {file_monitor_file_path}")
|
||||
return None
|
||||
|
||||
def read_news(source=None):
|
||||
def read_news(source=None, random_line_only=False, news_block_mode=False):
|
||||
# Reads the news file. If a source is provided, reads {source}_news.txt.
|
||||
if source:
|
||||
file_path = os.path.join(NEWS_DATA_DIR, f"{source}_news.txt")
|
||||
else:
|
||||
file_path = os.path.join(NEWS_DATA_DIR, news_file_path)
|
||||
return read_file(file_path, news_random_line_only)
|
||||
# Block mode takes precedence over line mode
|
||||
if news_block_mode:
|
||||
return read_file(file_path, random_line_only=False, news_block_mode=True)
|
||||
elif random_line_only:
|
||||
return read_file(file_path, random_line_only=True, news_block_mode=False)
|
||||
else:
|
||||
return read_file(file_path)
|
||||
|
||||
def read_verse():
|
||||
# Reads a random verse from the file bible.txt in the data/ directory
|
||||
verses = get_verses('bible.txt')
|
||||
if verses:
|
||||
return random.choice(verses)
|
||||
return None
|
||||
|
||||
def get_verses(file_monitor_file_path):
|
||||
# Handles both "4 ..." and "1 Timothy 4:15 ..." style verse starts
|
||||
verses = []
|
||||
current_verse = []
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
# Check for "number space" OR "Book Chapter:Verse" at start
|
||||
is_numbered = stripped and len(stripped) > 1 and stripped[0].isdigit() and stripped[1] == ' '
|
||||
is_reference = (
|
||||
stripped and
|
||||
':' in stripped and
|
||||
any(stripped.startswith(book + ' ') for book in [
|
||||
"Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy", "Joshua", "Judges", "Ruth",
|
||||
"1 Samuel", "2 Samuel", "1 Kings", "2 Kings", "1 Chronicles", "2 Chronicles", "Ezra", "Nehemiah",
|
||||
"Esther", "Job", "Psalms", "Proverbs", "Ecclesiastes", "Song of Solomon", "Isaiah", "Jeremiah",
|
||||
"Lamentations", "Ezekiel", "Daniel", "Hosea", "Joel", "Amos", "Obadiah", "Jonah", "Micah",
|
||||
"Nahum", "Habakkuk", "Zephaniah", "Haggai", "Zechariah", "Malachi", "Matthew", "Mark", "Luke",
|
||||
"John", "Acts", "Romans", "1 Corinthians", "2 Corinthians", "Galatians", "Ephesians", "Philippians",
|
||||
"Colossians", "1 Thessalonians", "2 Thessalonians", "1 Timothy", "2 Timothy", "Titus", "Philemon",
|
||||
"Hebrews", "James", "1 Peter", "2 Peter", "1 John", "2 John", "3 John", "Jude", "Revelation"
|
||||
])
|
||||
)
|
||||
if is_numbered or is_reference:
|
||||
if current_verse:
|
||||
verses.append(' '.join(current_verse).strip())
|
||||
current_verse = []
|
||||
# For numbered, drop the number; for reference, keep the whole line
|
||||
if is_numbered:
|
||||
current_verse.append(stripped.split(' ', 1)[1])
|
||||
else:
|
||||
current_verse.append(stripped)
|
||||
elif stripped and not stripped.lower().startswith('psalm'):
|
||||
current_verse.append(stripped)
|
||||
elif not stripped and current_verse:
|
||||
verses.append(' '.join(current_verse).strip())
|
||||
current_verse = []
|
||||
if current_verse:
|
||||
verses.append(' '.join(current_verse).strip())
|
||||
return verses
|
||||
|
||||
def write_news(content, append=False):
|
||||
# write the news file on demand
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
- [DopeWars](#dopewars-game-module)
|
||||
- [GolfSim](#golfsim-game-module)
|
||||
- [Lemonade Stand](#lemonade-stand-game-module)
|
||||
- [Tic-Tac-Toe](#tic-tac-toe-game-module)
|
||||
- [Tic-Tac-Toe (2D/3D)](#tic-tac-toe-game-module)
|
||||
- [MasterMind](#mastermind-game-module)
|
||||
- [Battleship](#battleship-game-module)
|
||||
- [Video Poker](#video-poker-game-module)
|
||||
- [Hangman](#hangman-game-module)
|
||||
- [Quiz](#quiz-game-module)
|
||||
- [Survey](#survey--module-game)
|
||||
- [Word of the Day Game](#word-of-the-day-game--rules--features)
|
||||
- [Game Server](#game-server-configuration-gameini)
|
||||
- [PyGame Help](#pygame-help)
|
||||
---
|
||||
|
||||
|
||||
@@ -305,31 +308,45 @@ Play another week🥤? or (E)nd Game
|
||||
|
||||
A classic Tic-Tac-Toe game for the Meshtastic mesh-bot. Play against the bot, track your stats, and see if you can beat the AI!
|
||||
|
||||

|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `tictactoe` via DM to the bot to begin a new game.
|
||||
|
||||
- **3D Mode:**
|
||||
You can play in 3D mode by sending `new 3d` during a game session. The board expands to 27 positions (1-27) and supports 3D win lines.
|
||||
|
||||
- **Run as a Game Server (Optional):**
|
||||
For UDP/visual/remote play, you can run the dedicated game server:
|
||||
```sh
|
||||
python3 script/game_serve.py
|
||||
```
|
||||
This enables networked play and visual board updates if supported.
|
||||
[PyGame Help](#pygame-help)
|
||||
|
||||
- **Objective:**
|
||||
Get three of your marks in a row (horizontally, vertically, or diagonally) before the bot does.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Board Layout:**
|
||||
- The board is numbered 1-9, left to right, top to bottom.
|
||||
- Example:
|
||||
- The board is numbered 1-9 (2D) or 1-27 (3D), left to right, top to bottom.
|
||||
- Example (2D):
|
||||
```
|
||||
1 | 2 | 3
|
||||
4 | 5 | 6
|
||||
7 | 8 | 9
|
||||
```
|
||||
2. **Making Moves:**
|
||||
- On your turn, type the number (1-9) where you want to place your mark.
|
||||
- On your turn, type the number (1-9 or 1-27) where you want to place your mark.
|
||||
- The bot will respond with the updated board and make its move.
|
||||
3. **Commands:**
|
||||
- `n` — Start a new game.
|
||||
- `new 2d` or `new 3d` — Start a new game in 2D or 3D mode.
|
||||
- `e` or `q` — End the current game.
|
||||
- `b` — Show the current board.
|
||||
- Enter a number (1-9) to make a move.
|
||||
- Enter a number (1-9 or 1-27) to make a move.
|
||||
4. **Winning:**
|
||||
- The first to get three in a row wins.
|
||||
- If the board fills with no winner, it’s a tie.
|
||||
@@ -356,12 +373,12 @@ Your turn! Pick 1-9:
|
||||
- Emojis are used for X and O unless disabled in settings.
|
||||
- Your win/loss stats are tracked across games.
|
||||
- The bot will try to win, block you, or pick a random move.
|
||||
- Play via DM for best experience.
|
||||
- Play via DM for best experience, or run the game server for network/visual play.
|
||||
- Only one game session per player at a time.
|
||||
|
||||
## Credits
|
||||
|
||||
- Written for Meshtastic mesh-bot by Martin
|
||||
- Written for Meshtastic mesh-bot by Martin, refactored by K7MHI
|
||||
|
||||
# MasterMind Game Module
|
||||
|
||||
@@ -504,6 +521,77 @@ Place your Bet, or (L)eave Table.
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
|
||||
# Battleship Game Module
|
||||
|
||||
A classic Battleship game for the Meshtastic mesh-bot. Play solo against the AI or challenge another user in peer-to-peer (P2P) mode!
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start a New Game (vs AI):**
|
||||
Send `battleship` via DM to the bot to start a new game against the AI.
|
||||
|
||||
- **Start a New P2P Game:**
|
||||
Send `battleship new` to create a game and receive a join code.
|
||||
Share the code with another user.
|
||||
|
||||
- **Join a P2P Game:**
|
||||
Send `battleship join <code>` (replace `<code>` with the provided number) to join a waiting game.
|
||||
|
||||
- **View Open Games:**
|
||||
Send `battleship lobby` to see a list of open P2P games waiting for players.
|
||||
|
||||
- **Gameplay:**
|
||||
- Enter your move using coordinates:
|
||||
- Format: `B4` or `B,4` (row letter, column number)
|
||||
- Example: `C7`
|
||||
- The bot will show your radar, ship status, and results after each move.
|
||||
- In P2P, you and your opponent take turns. The bot will notify you when it’s your turn.
|
||||
|
||||
- **End Game:**
|
||||
Send `end` or `exit` to leave your current game.
|
||||
|
||||
## Rules & Features
|
||||
|
||||
- 10x10 grid, classic ship sizes (Carrier, Battleship, Cruiser, Submarine, Destroyer).
|
||||
- Ships are placed randomly.
|
||||
- In P2P, the joining player goes first.
|
||||
- Radar view shows a 4x4 grid centered on your last move.
|
||||
- Game tracks whose turn it is and notifies the next player in P2P mode.
|
||||
- Game ends when all ships of one player are sunk.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
New 🚢Battleship🤖 game started!
|
||||
Enter your move using coordinates: row-letter, column-number.
|
||||
Example: B5 or C,7
|
||||
Type 'exit' or 'end' to quit the game.
|
||||
|
||||
> B4
|
||||
|
||||
Your move: 💥Hit!
|
||||
AI ships: 5/5 afloat
|
||||
Radar:
|
||||
🗺️3 4 5 6
|
||||
B ~ ~ * ~
|
||||
C ~ ~ ~ ~
|
||||
D ~ ~ ~ ~
|
||||
E ~ ~ ~ ~
|
||||
AI move: D7 (missed)
|
||||
Your ships: 5/5 afloat
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one Battleship session per player at a time.
|
||||
- Play via DM for best experience.
|
||||
- In P2P, share the join code with your opponent.
|
||||
- Coordinates are not case-sensitive.
|
||||
|
||||
## Credits
|
||||
|
||||
- Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025
|
||||
|
||||
# Word of the Day Game — Rules & Features
|
||||
|
||||
- **Word of the Day:**
|
||||
@@ -718,4 +806,54 @@ This module implements a survey system for the Meshtastic mesh-bot.
|
||||
|
||||
---
|
||||
|
||||
**Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025**
|
||||
**Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025**
|
||||
|
||||
___
|
||||
|
||||
# Game Server Configuration (`game.ini`)
|
||||
|
||||
The game server (`script/game_serve.py`) supports configuration via a `game.ini` file placed in the same directory as the script. This allows you to customize network and node settings without modifying the Python code.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create a `game.ini` file** in the `script/` directory (next to `game_serve.py`).
|
||||
|
||||
If `game.ini` is not present, the server will use built-in default values.
|
||||
|
||||
---
|
||||
|
||||
|
||||
# PyGame Help
|
||||
|
||||
'pygame - Community Edition' ('pygame-ce' for short) is a fork of the original 'pygame' library by former 'pygame' core contributors.
|
||||
|
||||
It offers many new features and optimizations, receives much better maintenance and runs under a better governance model, while being highly compatible with code written for upstream pygame (`import pygame` still works).
|
||||
|
||||
**Details**
|
||||
- [Initial announcement on Reddit](<https://www.reddit.com/r/pygame/comments/1112q10/pygame_community_edition_announcement/>) (or https://discord.com/channels/772505616680878080/772506385304649738/1074593440148500540)
|
||||
- [Why the forking happened](<https://www.reddit.com/r/pygame/comments/18xy7nf/what_was_the_disagreement_that_led_to_pygamece/>)
|
||||
|
||||
**Helpful Links**
|
||||
- https://discord.com/channels/772505616680878080/772506385304649738
|
||||
- [Our GitHub releases](<https://github.com/pygame-community/pygame-ce/releases>)
|
||||
- [Our docs](https://pyga.me/docs/)
|
||||
|
||||
**Installation**
|
||||
```sh
|
||||
pip uninstall pygame # Uninstall pygame first since it would conflict with pygame-ce
|
||||
pip install pygame-ce
|
||||
```
|
||||
-# Because 'pygame' installs to the same location as 'pygame-ce', it must first be uninstalled.
|
||||
-# Note that the `import pygame` syntax has not changed with pygame-ce.
|
||||
|
||||
# mUDP Help
|
||||
|
||||
mUDP library provides UDP-based broadcasting of Meshtastic-compatible packets. MeshBot uses this for the game_server_display server.
|
||||
|
||||
**Details**
|
||||
- [pdxlocations/mudp](https://github.com/pdxlocations/mudp)
|
||||
|
||||
**Installation**
|
||||
```sh
|
||||
pip install mudp
|
||||
```
|
||||
509
modules/games/battleship.py
Normal file
509
modules/games/battleship.py
Normal file
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# Battleship game module Meshing Around
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import random
|
||||
import copy
|
||||
import uuid
|
||||
import time
|
||||
|
||||
OCEAN = "~"
|
||||
FIRE = "x"
|
||||
HIT = "*"
|
||||
SIZE = 10
|
||||
SHIPS = [5, 4, 3, 3, 2]
|
||||
SHIP_NAMES = ["✈️Carrier", "Battleship", "Cruiser", "Submarine", "Destroyer"]
|
||||
|
||||
class Session:
|
||||
def __init__(self, player1_id, player2_id=None, vs_ai=True):
|
||||
self.session_id = str(uuid.uuid4())
|
||||
self.vs_ai = vs_ai
|
||||
self.player1_id = player1_id
|
||||
self.player2_id = player2_id
|
||||
self.game = Battleship(vs_ai=vs_ai)
|
||||
self.next_turn = player1_id
|
||||
self.last_move = None
|
||||
self.shots_fired = 0
|
||||
self.start_time = time.time()
|
||||
|
||||
class Battleship:
|
||||
sessions = {}
|
||||
short_codes = {}
|
||||
|
||||
@classmethod
|
||||
def _generate_short_code(cls):
|
||||
while True:
|
||||
code = str(random.randint(1000, 9999))
|
||||
if code not in cls.short_codes:
|
||||
return code
|
||||
|
||||
@classmethod
|
||||
def new_game(cls, player_id, vs_ai=True, p2p_id=None):
|
||||
session = Session(player1_id=player_id, player2_id=p2p_id, vs_ai=vs_ai)
|
||||
cls.sessions[session.session_id] = session
|
||||
if not vs_ai:
|
||||
code = cls._generate_short_code()
|
||||
cls.short_codes[code] = session.session_id
|
||||
msg = (
|
||||
"New 🚢Battleship🚢 game started!\n"
|
||||
"Joining player goes first, waiting for them to join...\n"
|
||||
f"Share\n'battleship join {code}'"
|
||||
)
|
||||
return msg, code
|
||||
else:
|
||||
msg = (
|
||||
"New 🚢Battleship🤖 game started!\n"
|
||||
"Enter your move using coordinates: row-letter, column-number.\n"
|
||||
"Example: B5 or C,7\n"
|
||||
"Type 'exit' or 'end' to quit the game."
|
||||
)
|
||||
return msg, session.session_id
|
||||
|
||||
@classmethod
|
||||
def end_game(cls, session_id):
|
||||
if session_id in cls.sessions:
|
||||
del cls.sessions[session_id]
|
||||
return "Thanks for playing 🚢Battleship🚢"
|
||||
|
||||
@classmethod
|
||||
def get_session(cls, code_or_session_id):
|
||||
session_id = cls.short_codes.get(code_or_session_id, code_or_session_id)
|
||||
return cls.sessions.get(session_id)
|
||||
|
||||
def __init__(self, vs_ai=True):
|
||||
if vs_ai:
|
||||
self.player_board = self._blank_board()
|
||||
self.ai_board = self._blank_board()
|
||||
self.player_radar = self._blank_board()
|
||||
self.ai_radar = self._blank_board()
|
||||
self.number_board = self._blank_board()
|
||||
self.player_alive = sum(SHIPS)
|
||||
self.ai_alive = sum(SHIPS)
|
||||
self._place_ships(self.player_board, self.number_board)
|
||||
self._place_ships(self.ai_board)
|
||||
self.ai_targets = []
|
||||
self.ai_last_hit = None
|
||||
self.ai_orientation = None
|
||||
else:
|
||||
# P2P: Each player has their own board and radar
|
||||
self.player1_board = self._blank_board()
|
||||
self.player2_board = self._blank_board()
|
||||
self.player1_radar = self._blank_board()
|
||||
self.player2_radar = self._blank_board()
|
||||
self.player1_alive = sum(SHIPS)
|
||||
self.player2_alive = sum(SHIPS)
|
||||
self._place_ships(self.player1_board)
|
||||
self._place_ships(self.player2_board)
|
||||
|
||||
def _blank_board(self):
|
||||
return [[OCEAN for _ in range(SIZE)] for _ in range(SIZE)]
|
||||
|
||||
def _place_ships(self, board, number_board=None):
|
||||
for idx, ship_len in enumerate(SHIPS):
|
||||
placed = False
|
||||
while not placed:
|
||||
vertical = random.choice([True, False])
|
||||
if vertical:
|
||||
row = random.randint(0, SIZE - ship_len)
|
||||
col = random.randint(0, SIZE - 1)
|
||||
if all(board[row + i][col] == OCEAN for i in range(ship_len)):
|
||||
for i in range(ship_len):
|
||||
board[row + i][col] = str(idx)
|
||||
if number_board is not None:
|
||||
number_board[row + i][col] = idx
|
||||
placed = True
|
||||
else:
|
||||
row = random.randint(0, SIZE - 1)
|
||||
col = random.randint(0, SIZE - ship_len)
|
||||
if all(board[row][col + i] == OCEAN for i in range(ship_len)):
|
||||
for i in range(ship_len):
|
||||
board[row][col + i] = str(idx)
|
||||
if number_board is not None:
|
||||
number_board[row][col + i] = idx
|
||||
placed = True
|
||||
|
||||
def player_move(self, row, col):
|
||||
"""Player fires at AI's board. Returns 'hit', 'miss', or 'sunk:<ship_idx>'."""
|
||||
if self.player_radar[row][col] != OCEAN:
|
||||
return "repeat"
|
||||
if self.ai_board[row][col] not in (OCEAN, FIRE, HIT):
|
||||
self.player_radar[row][col] = HIT
|
||||
ship_idx = int(self.ai_board[row][col])
|
||||
self.ai_board[row][col] = HIT
|
||||
if self._is_ship_sunk(self.ai_board, ship_idx):
|
||||
self.ai_alive -= SHIPS[ship_idx]
|
||||
return f"sunk:{ship_idx}"
|
||||
return "hit"
|
||||
else:
|
||||
self.player_radar[row][col] = FIRE
|
||||
self.ai_board[row][col] = FIRE
|
||||
return "miss"
|
||||
|
||||
def ai_move(self):
|
||||
"""AI fires at player's board. Returns (row, col, result or 'sunk:<ship_idx>')."""
|
||||
while True:
|
||||
row = random.randint(0, SIZE - 1)
|
||||
col = random.randint(0, SIZE - 1)
|
||||
if self.ai_radar[row][col] == OCEAN:
|
||||
break
|
||||
if self.player_board[row][col] not in (OCEAN, FIRE, HIT):
|
||||
self.ai_radar[row][col] = HIT
|
||||
ship_idx = int(self.player_board[row][col])
|
||||
self.player_board[row][col] = HIT
|
||||
if self._is_ship_sunk(self.player_board, ship_idx):
|
||||
self.player_alive -= SHIPS[ship_idx]
|
||||
return row, col, f"sunk:{ship_idx}"
|
||||
return row, col, "hit"
|
||||
else:
|
||||
self.ai_radar[row][col] = FIRE
|
||||
self.player_board[row][col] = FIRE
|
||||
return row, col, "miss"
|
||||
|
||||
def p2p_player_move(self, row, col, attacker, defender, radar, defender_alive_attr):
|
||||
"""P2P: attacker fires at defender's board, updates radar and defender's board."""
|
||||
if radar[row][col] != OCEAN:
|
||||
return "repeat"
|
||||
if defender[row][col] not in (OCEAN, FIRE, HIT):
|
||||
radar[row][col] = HIT
|
||||
ship_idx = int(defender[row][col])
|
||||
defender[row][col] = HIT
|
||||
if self._is_ship_sunk(defender, ship_idx):
|
||||
setattr(self, defender_alive_attr, getattr(self, defender_alive_attr) - SHIPS[ship_idx])
|
||||
return f"sunk:{ship_idx}"
|
||||
return "hit"
|
||||
else:
|
||||
radar[row][col] = FIRE
|
||||
defender[row][col] = FIRE
|
||||
return "miss"
|
||||
|
||||
def _is_ship_sunk(self, board, ship_idx):
|
||||
for row in board:
|
||||
for cell in row:
|
||||
if cell == str(ship_idx):
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_game_over(self, vs_ai=True):
|
||||
if vs_ai:
|
||||
return self.player_alive == 0 or self.ai_alive == 0
|
||||
else:
|
||||
return self.player1_alive == 0 or self.player2_alive == 0
|
||||
|
||||
def get_player_board(self):
|
||||
return copy.deepcopy(self.player_board)
|
||||
|
||||
def get_player_radar(self):
|
||||
return copy.deepcopy(self.player_radar)
|
||||
|
||||
def get_ai_board(self):
|
||||
return copy.deepcopy(self.ai_board)
|
||||
|
||||
def get_ai_radar(self):
|
||||
return copy.deepcopy(self.ai_radar)
|
||||
|
||||
def get_ship_status(self, board):
|
||||
status = {}
|
||||
for idx in range(len(SHIPS)):
|
||||
afloat = any(str(idx) in row for row in board)
|
||||
status[idx] = "Afloat" if afloat else "Sunk"
|
||||
return status
|
||||
|
||||
def display_draw_board(self, board, label="Board"):
|
||||
print(f"{label}")
|
||||
print(" " + " ".join(str(i+1).rjust(2) for i in range(SIZE)))
|
||||
for idx, row in enumerate(board):
|
||||
print(chr(ord('A') + idx) + " " + " ".join(cell.rjust(2) for cell in row))
|
||||
|
||||
def get_short_name(node_id):
|
||||
from mesh_bot import battleshipTracker
|
||||
entry = next((e for e in battleshipTracker if e['nodeID'] == node_id), None)
|
||||
return entry['short_name'] if entry and 'short_name' in entry else str(node_id)
|
||||
|
||||
def playBattleship(message, nodeID, deviceID, session_id=None):
|
||||
if not session_id or session_id not in Battleship.sessions:
|
||||
return Battleship.new_game(nodeID, vs_ai=True)
|
||||
|
||||
session = Battleship.get_session(session_id)
|
||||
game = session.game
|
||||
|
||||
# Check for game over
|
||||
if not session.vs_ai and game.is_game_over(vs_ai=False):
|
||||
winner = None
|
||||
if game.player1_alive == 0:
|
||||
winner = get_short_name(session.player2_id)
|
||||
elif game.player2_alive == 0:
|
||||
winner = get_short_name(session.player1_id)
|
||||
else:
|
||||
winner = "Nobody"
|
||||
elapsed = int(time.time() - session.start_time)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
||||
shots = session.shots_fired
|
||||
return (
|
||||
f"Game over! {winner} wins! 🚢🏆\n"
|
||||
f"Game finished in {shots} shots and {time_str}.\n"
|
||||
)
|
||||
|
||||
if not session.vs_ai and session.player2_id is None:
|
||||
code = next((k for k, v in Battleship.short_codes.items() if v == session.session_id), None)
|
||||
return (
|
||||
f"Waiting for another player to join.\n"
|
||||
f"Share this code: {code}\n"
|
||||
"Type 'end' to cancel this P2P game."
|
||||
)
|
||||
|
||||
if nodeID != session.next_turn:
|
||||
return "It's not your turn!"
|
||||
|
||||
msg = message.strip().lower()
|
||||
if msg.startswith("battleship"):
|
||||
msg = msg[len("battleship"):].strip()
|
||||
if msg.startswith("b:"):
|
||||
msg = msg[2:].strip()
|
||||
msg = msg.replace(" ", "")
|
||||
|
||||
# --- Ping Command ---
|
||||
if msg == "p":
|
||||
import random
|
||||
# 30% chance to fail
|
||||
if random.random() < 0.3:
|
||||
return "I can hear a couple of 🦞lobsters dukin' it out down there..."
|
||||
# Determine center of ping
|
||||
if session.vs_ai:
|
||||
# Use last move if available, else center of board
|
||||
if session.shots_fired > 0:
|
||||
# Find last move coordinates from radar (most recent HIT or FIRE)
|
||||
radar = game.get_player_radar()
|
||||
found = False
|
||||
for i in range(SIZE):
|
||||
for j in range(SIZE):
|
||||
if radar[i][j] in (HIT, FIRE):
|
||||
center_y, center_x = i, j
|
||||
found = True
|
||||
if not found:
|
||||
center_y, center_x = SIZE // 2, SIZE // 2
|
||||
else:
|
||||
center_y, center_x = SIZE // 2, SIZE // 2
|
||||
# Scan 3x3 area on AI board for unsunk ship cells
|
||||
board = game.ai_board
|
||||
else:
|
||||
# For P2P, use player's radar and opponent's board
|
||||
if session.last_move:
|
||||
coord = session.last_move[1]
|
||||
center_y = ord(coord[0]) - ord('A')
|
||||
center_x = int(coord[1:]) - 1
|
||||
else:
|
||||
center_y, center_x = SIZE // 2, SIZE // 2
|
||||
# Scan 3x3 area on opponent's board
|
||||
if nodeID == session.player1_id:
|
||||
board = game.player2_board
|
||||
else:
|
||||
board = game.player1_board
|
||||
|
||||
min_y = max(0, center_y - 1)
|
||||
max_y = min(SIZE, center_y + 2)
|
||||
min_x = max(0, center_x - 1)
|
||||
max_x = min(SIZE, center_x + 2)
|
||||
ship_cells = set()
|
||||
for i in range(min_y, max_y):
|
||||
for j in range(min_x, max_x):
|
||||
cell = board[i][j]
|
||||
if cell.isdigit():
|
||||
ship_cells.add(cell)
|
||||
pong_count = len(ship_cells)
|
||||
if pong_count == 0:
|
||||
return "silence in the deep..."
|
||||
elif pong_count == 1:
|
||||
return "something lurking nearby."
|
||||
else:
|
||||
return f"targets in the area!"
|
||||
|
||||
x = y = None
|
||||
if "," in msg:
|
||||
parts = msg.split(",")
|
||||
if len(parts) == 2 and len(parts[0]) == 1 and parts[0].isalpha() and parts[1].isdigit():
|
||||
y = ord(parts[0]) - ord('a')
|
||||
x = int(parts[1]) - 1
|
||||
else:
|
||||
return "Invalid coordinates. Use format A2 or A,2 (row letter, column number)."
|
||||
elif len(msg) >= 2 and msg[0].isalpha() and msg[1:].isdigit():
|
||||
y = ord(msg[0]) - ord('a')
|
||||
x = int(msg[1:]) - 1
|
||||
else:
|
||||
return "Invalid command. Use format A2 or A,2 (row letter, column number)."
|
||||
|
||||
if x is None or y is None or not (0 <= x < SIZE and 0 <= y < SIZE):
|
||||
return "Coordinates out of range."
|
||||
|
||||
ai_row = ai_col = ai_result = None
|
||||
over = False
|
||||
|
||||
if session.vs_ai:
|
||||
result = game.player_move(y, x)
|
||||
ai_row, ai_col, ai_result = game.ai_move()
|
||||
over = game.is_game_over(vs_ai=True)
|
||||
else:
|
||||
# P2P: determine which player is moving and fire at the other player's board
|
||||
if nodeID == session.player1_id:
|
||||
attacker = "player1"
|
||||
defender = "player2"
|
||||
result = game.p2p_player_move(
|
||||
y, x,
|
||||
game.player1_board, game.player2_board,
|
||||
game.player1_radar, "player2_alive"
|
||||
)
|
||||
else:
|
||||
attacker = "player2"
|
||||
defender = "player1"
|
||||
result = game.p2p_player_move(
|
||||
y, x,
|
||||
game.player2_board, game.player1_board,
|
||||
game.player2_radar, "player1_alive"
|
||||
)
|
||||
over = game.is_game_over(vs_ai=False)
|
||||
coord_str = f"{chr(y+65)}{x+1}"
|
||||
session.last_move = (nodeID, coord_str, result)
|
||||
|
||||
# --- DEBUG DISPLAY ---
|
||||
DEBUG = False
|
||||
if DEBUG:
|
||||
if session.vs_ai:
|
||||
game.display_draw_board(game.player_board, label=f"Player Board ({session.player1_id})")
|
||||
game.display_draw_board(game.player_radar, label="Player Radar")
|
||||
game.display_draw_board(game.ai_board, label="AI Board")
|
||||
game.display_draw_board(game.ai_radar, label="AI Radar")
|
||||
else:
|
||||
p1_id = session.player1_id
|
||||
p2_id = session.player2_id if session.player2_id else "Waiting"
|
||||
game.display_draw_board(game.player1_board, label=f"Player 1 Board ({p1_id})")
|
||||
game.display_draw_board(game.player1_radar, label="Player 1 Radar")
|
||||
game.display_draw_board(game.player2_board, label=f"Player 2 Board ({p2_id})")
|
||||
game.display_draw_board(game.player2_radar, label="Player 2 Radar")
|
||||
|
||||
# Format radar as a 4x4 grid centered on the player's move
|
||||
if session.vs_ai:
|
||||
radar = game.get_player_radar()
|
||||
else:
|
||||
radar = game.player1_radar if nodeID == session.player1_id else game.player2_radar
|
||||
|
||||
window_size = 4
|
||||
half_window = window_size // 2
|
||||
min_row = max(0, min(y - half_window, SIZE - window_size))
|
||||
max_row = min(SIZE, min_row + window_size)
|
||||
min_col = max(0, min(x - half_window, SIZE - window_size))
|
||||
max_col = min(SIZE, min_col + window_size)
|
||||
|
||||
radar_str = "🗺️" + " ".join(str(i+1) for i in range(min_col, max_col)) + "\n"
|
||||
for idx in range(min_row, max_row):
|
||||
radar_str += chr(ord('A') + idx) + " :" + " ".join(radar[idx][j] for j in range(min_col, max_col)) + "\n"
|
||||
|
||||
def format_ship_status(status_dict):
|
||||
afloat = 0
|
||||
for idx, state in status_dict.items():
|
||||
if state == "Afloat":
|
||||
afloat += 1
|
||||
return f"{afloat}/{len(SHIPS)} afloat"
|
||||
|
||||
if session.vs_ai:
|
||||
ai_status_str = format_ship_status(game.get_ship_status(game.ai_board))
|
||||
player_status_str = format_ship_status(game.get_ship_status(game.player_board))
|
||||
else:
|
||||
ai_status_str = format_ship_status(game.get_ship_status(game.player2_board))
|
||||
player_status_str = format_ship_status(game.get_ship_status(game.player1_board))
|
||||
|
||||
def move_result_text(res, is_player=True):
|
||||
if res.startswith("sunk:"):
|
||||
idx = int(res.split(":")[1])
|
||||
name = SHIP_NAMES[idx]
|
||||
return f"Sunk🎯 {name}!"
|
||||
elif res == "hit":
|
||||
return "💥Hit!"
|
||||
elif res == "miss":
|
||||
return "missed"
|
||||
elif res == "repeat":
|
||||
return "📋already targeted"
|
||||
else:
|
||||
return res
|
||||
|
||||
# After a valid move, switch turns
|
||||
if session.vs_ai:
|
||||
session.next_turn = nodeID
|
||||
else:
|
||||
session.next_turn = session.player2_id if nodeID == session.player1_id else session.player1_id
|
||||
|
||||
# Increment shots fired
|
||||
session.shots_fired += 1
|
||||
|
||||
# Waste of ammo comment
|
||||
funny_comment = ""
|
||||
if session.shots_fired % 50 == 0:
|
||||
funny_comment = f"\n🥵{session.shots_fired} rounds!"
|
||||
elif session.shots_fired % 25 == 0:
|
||||
funny_comment = f"\n🥔{session.shots_fired} fired!"
|
||||
|
||||
# Output message
|
||||
if session.vs_ai:
|
||||
msg_out = (
|
||||
f"Your move: {move_result_text(result)}\n"
|
||||
f"AI ships: {ai_status_str}\n"
|
||||
f"Radar:\n{radar_str}"
|
||||
f"AI move: {chr(ai_row+65)}{ai_col+1} ({move_result_text(ai_result, False)})\n"
|
||||
f"Your ships: {player_status_str}"
|
||||
f"{funny_comment}"
|
||||
)
|
||||
else:
|
||||
my_name = get_short_name(nodeID)
|
||||
opponent_id = session.player2_id if nodeID == session.player1_id else session.player1_id
|
||||
opponent_short_name = get_short_name(opponent_id) if opponent_id else "Waiting"
|
||||
opponent_label = f"{opponent_short_name}:"
|
||||
my_move_result_str = f"Your move: {move_result_text(result)}\n"
|
||||
last_move_str = ""
|
||||
if session.last_move and session.last_move[0] != nodeID:
|
||||
last_player_short_name = get_short_name(session.last_move[0])
|
||||
last_coord = session.last_move[1]
|
||||
last_result = move_result_text(session.last_move[2])
|
||||
last_move_str = f"Last move by {last_player_short_name}: {last_coord} ({last_result})\n"
|
||||
if session.next_turn == nodeID:
|
||||
turn_prompt = f"Your turn, {my_name}! Enter your move:"
|
||||
else:
|
||||
turn_prompt = f"Waiting for {opponent_short_name}..."
|
||||
msg_out = (
|
||||
f"{my_move_result_str}"
|
||||
f"{last_move_str}"
|
||||
f"{opponent_label} {ai_status_str}\n"
|
||||
f"Radar:\n{radar_str}"
|
||||
f"Your ships: {player_status_str}\n"
|
||||
f"{turn_prompt}"
|
||||
f"{funny_comment}"
|
||||
)
|
||||
|
||||
if over:
|
||||
elapsed = int(time.time() - session.start_time)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
||||
shots = session.shots_fired
|
||||
if session.vs_ai:
|
||||
if game.player_alive == 0:
|
||||
winner = "AI 🤖"
|
||||
msg_out += f"\nGame over! {winner} wins! Better luck next time.\n"
|
||||
else:
|
||||
winner = get_short_name(nodeID)
|
||||
msg_out += (
|
||||
f"\nGame over! {winner} wins! You sank all the AI's ships! 🎉\n"
|
||||
f"Took {shots} shots in {time_str}.\n"
|
||||
)
|
||||
else:
|
||||
# P2P: Announce winner by short name
|
||||
if game.player1_alive == 0:
|
||||
winner = get_short_name(session.player2_id)
|
||||
elif game.player2_alive == 0:
|
||||
winner = get_short_name(session.player1_id)
|
||||
else:
|
||||
winner = "Nobody"
|
||||
msg_out += (
|
||||
f"\nGame over! {winner} wins! 🚢🏆\n"
|
||||
f"Game finished in {shots} shots and {time_str}.\n"
|
||||
)
|
||||
msg_out += "Type 'battleship' to start a new game."
|
||||
|
||||
return msg_out
|
||||
98
modules/games/battleship_vid.py
Normal file
98
modules/games/battleship_vid.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# Battleship Display Module Meshing Around
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import pygame
|
||||
import sys
|
||||
import time
|
||||
|
||||
from modules.games.battleship import Battleship, SHIP_NAMES, SIZE, OCEAN, FIRE, HIT
|
||||
|
||||
CELL_SIZE = 40
|
||||
BOARD_MARGIN = 50
|
||||
STATUS_WIDTH = 320
|
||||
|
||||
def draw_board(screen, board, top_left, cell_size, show_ships=False):
|
||||
font = pygame.font.Font(None, 28)
|
||||
x0, y0 = top_left
|
||||
for y in range(SIZE):
|
||||
for x in range(SIZE):
|
||||
rect = pygame.Rect(x0 + x*cell_size, y0 + y*cell_size, cell_size, cell_size)
|
||||
pygame.draw.rect(screen, (100, 100, 200), rect, 1)
|
||||
val = board[y][x]
|
||||
# Show ships if requested, otherwise hide ship numbers
|
||||
if not show_ships and val.isdigit():
|
||||
val = OCEAN
|
||||
color = (200, 200, 255) if val == OCEAN else (255, 0, 0) if val == FIRE else (0, 255, 0) if val == HIT else (255,255,255)
|
||||
if val != OCEAN:
|
||||
pygame.draw.rect(screen, color, rect)
|
||||
text = font.render(val, True, (0,0,0))
|
||||
screen.blit(text, rect.move(10, 5))
|
||||
# Draw row/col labels
|
||||
for i in range(SIZE):
|
||||
# Col numbers
|
||||
num_surface = font.render(str(i+1), True, (255, 255, 0))
|
||||
screen.blit(num_surface, (x0 + i*cell_size + cell_size//2 - 8, y0 - 24))
|
||||
# Row letters
|
||||
letter_surface = font.render(chr(ord('A') + i), True, (255, 255, 0))
|
||||
screen.blit(letter_surface, (x0 - 28, y0 + i*cell_size + cell_size//2 - 10))
|
||||
|
||||
def draw_status_panel(screen, game, top_left, width, height, is_player=True):
|
||||
font = pygame.font.Font(None, 32)
|
||||
x0, y0 = top_left
|
||||
pygame.draw.rect(screen, (30, 30, 60), (x0, y0, width, height), border_radius=10)
|
||||
# Title
|
||||
title = font.render("Game Status", True, (255, 255, 0))
|
||||
screen.blit(title, (x0 + 10, y0 + 10))
|
||||
# Ships status
|
||||
ships_title = font.render("Ships Remaining:", True, (200, 200, 255))
|
||||
screen.blit(ships_title, (x0 + 10, y0 + 60))
|
||||
# Get ship status
|
||||
if is_player:
|
||||
status_dict = game.get_ship_status(game.player_board)
|
||||
else:
|
||||
status_dict = game.get_ship_status(game.ai_board)
|
||||
for i, ship in enumerate(SHIP_NAMES):
|
||||
status = status_dict.get(i, "Afloat")
|
||||
name_color = (200, 200, 255)
|
||||
if status.lower() == "sunk":
|
||||
status_color = (255, 0, 0)
|
||||
status_text = "Sunk"
|
||||
else:
|
||||
status_color = (0, 255, 0)
|
||||
status_text = "Afloat"
|
||||
ship_name_surface = font.render(f"{ship}:", True, name_color)
|
||||
screen.blit(ship_name_surface, (x0 + 20, y0 + 100 + i * 35))
|
||||
status_surface = font.render(f"{status_text}", True, status_color)
|
||||
screen.blit(status_surface, (x0 + 180, y0 + 100 + i * 35))
|
||||
|
||||
def battleship_visual_main(game):
|
||||
pygame.init()
|
||||
screen = pygame.display.set_mode((2*SIZE*CELL_SIZE + STATUS_WIDTH + 3*BOARD_MARGIN, SIZE*CELL_SIZE + 2*BOARD_MARGIN))
|
||||
pygame.display.set_caption("Battleship Visualizer")
|
||||
clock = pygame.time.Clock()
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
|
||||
running = False
|
||||
screen.fill((20, 20, 30))
|
||||
# Draw radar (left)
|
||||
draw_board(screen, game.get_player_radar(), (BOARD_MARGIN, BOARD_MARGIN+30), CELL_SIZE, show_ships=False)
|
||||
radar_label = pygame.font.Font(None, 36).render("Your Radar", True, (0,255,255))
|
||||
screen.blit(radar_label, (BOARD_MARGIN, BOARD_MARGIN))
|
||||
# Draw player board (right)
|
||||
draw_board(screen, game.get_player_board(), (SIZE*CELL_SIZE + 2*BOARD_MARGIN, BOARD_MARGIN+30), CELL_SIZE, show_ships=True)
|
||||
board_label = pygame.font.Font(None, 36).render("Your Board", True, (0,255,255))
|
||||
screen.blit(board_label, (SIZE*CELL_SIZE + 2*BOARD_MARGIN, BOARD_MARGIN))
|
||||
# Draw status panel (far right)
|
||||
draw_status_panel(screen, game, (2*SIZE*CELL_SIZE + 2*BOARD_MARGIN, BOARD_MARGIN), STATUS_WIDTH, SIZE*CELL_SIZE)
|
||||
pygame.display.flip()
|
||||
clock.tick(30)
|
||||
pygame.quit()
|
||||
sys.exit()
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# # Example: create a new game and show the boards
|
||||
# game = Battleship(vs_ai=True)
|
||||
# battleship_visual_main(game)
|
||||
@@ -5,272 +5,271 @@ import random
|
||||
import time
|
||||
import modules.settings as my_settings
|
||||
|
||||
useSynchCompression = True
|
||||
if useSynchCompression:
|
||||
import zlib
|
||||
|
||||
# to (max), molly and jake, I miss you both so much.
|
||||
|
||||
if my_settings.disable_emojis_in_games:
|
||||
X = "X"
|
||||
O = "O"
|
||||
else:
|
||||
X = "❌"
|
||||
O = "⭕️"
|
||||
|
||||
class TicTacToe:
|
||||
def __init__(self):
|
||||
def __init__(self, display_module):
|
||||
if getattr(my_settings, "disable_emojis_in_games", False):
|
||||
self.X = "X"
|
||||
self.O = "O"
|
||||
else:
|
||||
self.X = "❌"
|
||||
self.O = "⭕️"
|
||||
self.display_module = display_module
|
||||
self.game = {}
|
||||
self.win_lines_3d = self.generate_3d_win_lines()
|
||||
|
||||
def new_game(self, id):
|
||||
positiveThoughts = ["🚀I need to call NATO",
|
||||
"🏅Going for the gold!",
|
||||
"Mastering ❌TTT⭕️",]
|
||||
sorryNotGoinWell = ["😭Not your day, huh?",
|
||||
"📉Results here dont define you.",
|
||||
"🤖WOPR would be proud."]
|
||||
"""Start a new game"""
|
||||
games = won = 0
|
||||
ret = ""
|
||||
if id in self.game:
|
||||
games = self.game[id]["games"]
|
||||
won = self.game[id]["won"]
|
||||
if games > 3:
|
||||
if won / games >= 3.14159265358979323846: # win rate > pi
|
||||
ret += random.choice(positiveThoughts) + "\n"
|
||||
else:
|
||||
ret += random.choice(sorryNotGoinWell) + "\n"
|
||||
# Retain stats
|
||||
ret += f"Games:{games} 🥇❌:{won}\n"
|
||||
|
||||
self.game[id] = {
|
||||
"board": [" "] * 9, # 3x3 board as flat list
|
||||
"player": X, # Human is X, bot is O
|
||||
"games": games + 1,
|
||||
"won": won,
|
||||
"turn": "human" # whose turn it is
|
||||
def new_game(self, nodeID, mode="2D", channel=None, deviceID=None):
|
||||
board_size = 9 if mode == "2D" else 27
|
||||
self.game[nodeID] = {
|
||||
"board": [" "] * board_size,
|
||||
"mode": mode,
|
||||
"channel": channel,
|
||||
"nodeID": nodeID,
|
||||
"deviceID": deviceID,
|
||||
"player": self.X,
|
||||
"games": 1,
|
||||
"won": 0,
|
||||
"turn": "human"
|
||||
}
|
||||
ret += self.show_board(id)
|
||||
ret += "Pick 1-9:"
|
||||
return ret
|
||||
|
||||
def rndTeaPrice(self, tea=42):
|
||||
"""Return a random tea between 0 and tea."""
|
||||
return random.uniform(0, tea)
|
||||
self.update_display(nodeID, status="new")
|
||||
msg = f"{mode} game started!\n"
|
||||
if mode == "2D":
|
||||
msg += self.show_board(nodeID)
|
||||
msg += "Pick 1-9:"
|
||||
else:
|
||||
msg += "Play on the MeshBot Display!\n"
|
||||
msg += "Pick 1-27:"
|
||||
return msg
|
||||
|
||||
def show_board(self, id):
|
||||
"""Display compact board with move numbers"""
|
||||
g = self.game[id]
|
||||
b = g["board"]
|
||||
|
||||
# Show board with positions
|
||||
board_str = ""
|
||||
for i in range(3):
|
||||
row = ""
|
||||
for j in range(3):
|
||||
pos = i * 3 + j
|
||||
if my_settings.disable_emojis_in_games:
|
||||
cell = b[pos] if b[pos] != " " else str(pos + 1)
|
||||
else:
|
||||
cell = b[pos] if b[pos] != " " else f" {str(pos + 1)} "
|
||||
row += cell
|
||||
if j < 2:
|
||||
row += " | "
|
||||
board_str += row
|
||||
if i < 2:
|
||||
board_str += "\n"
|
||||
|
||||
return board_str + "\n"
|
||||
def update_display(self, nodeID, status=None):
|
||||
from modules.system import send_raw_bytes
|
||||
g = self.game[nodeID]
|
||||
mapping = {" ": "0", "X": "1", "O": "2", "❌": "1", "⭕️": "2"}
|
||||
board_str = "".join(mapping.get(cell, "0") for cell in g["board"])
|
||||
msg = f"MTTT:{board_str}|{g['nodeID']}|{g['channel']}|{g['deviceID']}"
|
||||
if status:
|
||||
msg += f"|status={status}"
|
||||
if useSynchCompression:
|
||||
payload = zlib.compress(msg.encode("utf-8"))
|
||||
else:
|
||||
payload = msg.encode("utf-8")
|
||||
send_raw_bytes(nodeID, payload, portnum=256)
|
||||
if self.display_module:
|
||||
self.display_module.update_board(
|
||||
g["board"], g["channel"], g["nodeID"], g["deviceID"]
|
||||
)
|
||||
|
||||
def make_move(self, id, position):
|
||||
"""Make a move for the current player"""
|
||||
g = self.game[id]
|
||||
|
||||
# Validate position
|
||||
if position < 1 or position > 9:
|
||||
return False
|
||||
|
||||
pos = position - 1
|
||||
if g["board"][pos] != " ":
|
||||
return False
|
||||
|
||||
# Make human move
|
||||
g["board"][pos] = X
|
||||
return True
|
||||
def show_board(self, nodeID):
|
||||
g = self.game[nodeID]
|
||||
if g["mode"] == "2D":
|
||||
b = g["board"]
|
||||
s = ""
|
||||
for i in range(3):
|
||||
row = []
|
||||
for j in range(3):
|
||||
cell = b[i*3+j]
|
||||
row.append(cell if cell != " " else str(i*3+j+1))
|
||||
s += " | ".join(row) + "\n"
|
||||
return s
|
||||
return ""
|
||||
|
||||
def bot_move(self, id):
|
||||
"""AI makes a move: tries to win, block, or pick random"""
|
||||
g = self.game[id]
|
||||
def make_move(self, nodeID, position):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
|
||||
# Try to win
|
||||
move = self.find_winning_move(id, O)
|
||||
if move != -1:
|
||||
board[move] = O
|
||||
return move
|
||||
|
||||
# Try to block player
|
||||
move = self.find_winning_move(id, X)
|
||||
if move != -1:
|
||||
board[move] = O
|
||||
return move
|
||||
|
||||
# Pick random move
|
||||
move = self.find_random_move(id)
|
||||
if move != -1:
|
||||
board[move] = O
|
||||
return move
|
||||
|
||||
# No moves possible
|
||||
return -1
|
||||
max_pos = 9 if g["mode"] == "2D" else 27
|
||||
if 1 <= position <= max_pos and board[position-1] == " ":
|
||||
board[position-1] = g["player"]
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_winning_move(self, id, player):
|
||||
"""Find a winning move for the given player"""
|
||||
g = self.game[id]
|
||||
board = g["board"][:]
|
||||
|
||||
# Check all empty positions
|
||||
for i in range(9):
|
||||
if board[i] == " ":
|
||||
board[i] = player
|
||||
if self.check_winner_on_board(board) == player:
|
||||
return i
|
||||
board[i] = " "
|
||||
return -1
|
||||
|
||||
def find_random_move(self, id: str, tea_price: float = 42.0) -> int:
|
||||
"""Find a random empty position, using time and tea_price for extra randomness."""
|
||||
board = self.game[id]["board"]
|
||||
def bot_move(self, nodeID):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
max_pos = 9 if g["mode"] == "2D" else 27
|
||||
# Try to win or block
|
||||
for player in (self.O, self.X):
|
||||
move = self.find_winning_move(nodeID, player)
|
||||
if move != -1:
|
||||
board[move] = self.O
|
||||
return move+1
|
||||
# Otherwise random move
|
||||
empty = [i for i, cell in enumerate(board) if cell == " "]
|
||||
current_time = time.time()
|
||||
from_china = self.rndTeaPrice(time.time() % 7) # Correct usage
|
||||
tea_price = from_china
|
||||
tea_price = (42 * 7) - (13 / 2) + (tea_price % 5)
|
||||
if not empty:
|
||||
return -1
|
||||
# Combine time and tea_price for a seed
|
||||
seed = int(current_time * 1000) ^ int(tea_price * 1000)
|
||||
local_random = random.Random(seed)
|
||||
local_random.shuffle(empty)
|
||||
return empty[0]
|
||||
if empty:
|
||||
move = random.choice(empty)
|
||||
board[move] = self.O
|
||||
return move+1
|
||||
return -1
|
||||
|
||||
def check_winner_on_board(self, board):
|
||||
"""Check winner on given board state"""
|
||||
# Winning combinations
|
||||
wins = [
|
||||
[0,1,2], [3,4,5], [6,7,8], # Rows
|
||||
[0,3,6], [1,4,7], [2,5,8], # Columns
|
||||
[0,4,8], [2,4,6] # Diagonals
|
||||
]
|
||||
|
||||
for combo in wins:
|
||||
if board[combo[0]] == board[combo[1]] == board[combo[2]] != " ":
|
||||
return board[combo[0]]
|
||||
def find_winning_move(self, nodeID, player):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
lines = self.get_win_lines(g["mode"])
|
||||
for line in lines:
|
||||
cells = [board[i] for i in line]
|
||||
if cells.count(player) == 2 and cells.count(" ") == 1:
|
||||
return line[cells.index(" ")]
|
||||
return -1
|
||||
|
||||
def play(self, nodeID, input_msg):
|
||||
try:
|
||||
if nodeID not in self.game:
|
||||
return self.new_game(nodeID)
|
||||
g = self.game[nodeID]
|
||||
mode = g["mode"]
|
||||
max_pos = 9 if mode == "2D" else 27
|
||||
|
||||
input_str = input_msg.strip().lower()
|
||||
if input_str in ("end", "e", "quit", "q"):
|
||||
msg = "Game ended."
|
||||
self.update_display(nodeID)
|
||||
return msg
|
||||
|
||||
# Add refresh/draw command
|
||||
if input_str in ("refresh", "board", "b"):
|
||||
self.update_display(nodeID, status="refresh")
|
||||
if mode == "2D":
|
||||
return self.show_board(nodeID) + f"Pick 1-{max_pos}:"
|
||||
else:
|
||||
return "Display refreshed."
|
||||
|
||||
# Allow 'new', 'new 2d', 'new 3d'
|
||||
if input_str.startswith("new"):
|
||||
parts = input_str.split()
|
||||
if len(parts) > 1 and parts[1] in ("2d", "3d"):
|
||||
new_mode = "2D" if parts[1] == "2d" else "3D"
|
||||
else:
|
||||
new_mode = mode
|
||||
msg = self.new_game(nodeID, new_mode, g["channel"], g["deviceID"])
|
||||
return msg
|
||||
|
||||
try:
|
||||
pos = int(input_msg)
|
||||
except Exception:
|
||||
return f"Enter a number between 1 and {max_pos}."
|
||||
|
||||
if not self.make_move(nodeID, pos):
|
||||
return f"Invalid move! Pick 1-{max_pos}:"
|
||||
|
||||
winner = self.check_winner(nodeID)
|
||||
if winner:
|
||||
# Add positive/sorry messages and stats
|
||||
positiveThoughts = [
|
||||
"🚀I need to call NATO",
|
||||
"🏅Going for the gold!",
|
||||
"Mastering ❌TTT⭕️",
|
||||
]
|
||||
sorryNotGoinWell = [
|
||||
"😭Not your day, huh?",
|
||||
"📉Results here dont define you.",
|
||||
"🤖WOPR would be proud."
|
||||
]
|
||||
games = won = 0
|
||||
ret = ""
|
||||
if nodeID in self.game:
|
||||
self.game[nodeID]["won"] += 1
|
||||
games = self.game[nodeID]["games"]
|
||||
won = self.game[nodeID]["won"]
|
||||
if games > 3:
|
||||
if won / games >= 3.14159265358979323846: # win rate > pi
|
||||
ret += random.choice(positiveThoughts) + "\n"
|
||||
else:
|
||||
ret += random.choice(sorryNotGoinWell) + "\n"
|
||||
# Retain stats
|
||||
ret += f"Games:{games} 🥇❌:{won}\n"
|
||||
msg = f"You ({g['player']}) win!\n" + ret
|
||||
msg += "Type 'new' to play again or 'end' to quit."
|
||||
self.update_display(nodeID, status="win")
|
||||
return msg
|
||||
|
||||
if " " not in g["board"]:
|
||||
msg = "Tie game!"
|
||||
msg += "\nType 'new' to play again or 'end' to quit."
|
||||
self.update_display(nodeID, status="tie")
|
||||
return msg
|
||||
|
||||
# Bot's turn
|
||||
g["player"] = self.O
|
||||
bot_pos = self.bot_move(nodeID)
|
||||
winner = self.check_winner(nodeID)
|
||||
if winner:
|
||||
self.update_display(nodeID, status="loss")
|
||||
msg = f"Bot ({g['player']}) wins!\n"
|
||||
msg += "Type 'new' to play again or 'end' to quit."
|
||||
return msg
|
||||
|
||||
if " " not in g["board"]:
|
||||
msg = "Tie game!"
|
||||
msg += "\nType 'new' to play again or 'end' to quit."
|
||||
self.update_display(nodeID, status="tie")
|
||||
return msg
|
||||
|
||||
g["player"] = self.X
|
||||
prompt = f"Pick 1-{max_pos}:"
|
||||
if mode == "2D":
|
||||
prompt = self.show_board(nodeID) + prompt
|
||||
self.update_display(nodeID)
|
||||
return prompt
|
||||
|
||||
except Exception as e:
|
||||
return f"An unexpected error occurred: {e}"
|
||||
|
||||
def check_winner(self, nodeID):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
lines = self.get_win_lines(g["mode"])
|
||||
for line in lines:
|
||||
vals = [board[i] for i in line]
|
||||
if vals[0] != " " and all(v == vals[0] for v in vals):
|
||||
return vals[0]
|
||||
return None
|
||||
|
||||
def check_winner(self, id):
|
||||
"""Check if there's a winner"""
|
||||
g = self.game[id]
|
||||
return self.check_winner_on_board(g["board"])
|
||||
def get_win_lines(self, mode):
|
||||
if mode == "2D":
|
||||
return [
|
||||
[0,1,2],[3,4,5],[6,7,8], # rows
|
||||
[0,3,6],[1,4,7],[2,5,8], # columns
|
||||
[0,4,8],[2,4,6] # diagonals
|
||||
]
|
||||
return self.win_lines_3d
|
||||
|
||||
def is_board_full(self, id):
|
||||
"""Check if board is full"""
|
||||
g = self.game[id]
|
||||
return " " not in g["board"]
|
||||
def generate_3d_win_lines(self):
|
||||
lines = []
|
||||
# Rows in each layer
|
||||
for z in range(3):
|
||||
for y in range(3):
|
||||
lines.append([z*9 + y*3 + x for x in range(3)])
|
||||
# Columns in each layer
|
||||
for z in range(3):
|
||||
for x in range(3):
|
||||
lines.append([z*9 + y*3 + x for y in range(3)])
|
||||
# Pillars (vertical columns through layers)
|
||||
for y in range(3):
|
||||
for x in range(3):
|
||||
lines.append([z*9 + y*3 + x for z in range(3)])
|
||||
# Diagonals in each layer
|
||||
for z in range(3):
|
||||
lines.append([z*9 + i*3 + i for i in range(3)]) # TL to BR
|
||||
lines.append([z*9 + i*3 + (2-i) for i in range(3)]) # TR to BL
|
||||
# Vertical diagonals in columns
|
||||
for x in range(3):
|
||||
lines.append([z*9 + z*3 + x for z in range(3)]) # (0,0,x)-(1,1,x)-(2,2,x)
|
||||
lines.append([z*9 + (2-z)*3 + x for z in range(3)]) # (0,2,x)-(1,1,x)-(2,0,x)
|
||||
for y in range(3):
|
||||
lines.append([z*9 + y*3 + z for z in range(3)]) # (z,y,z)
|
||||
lines.append([z*9 + y*3 + (2-z) for z in range(3)]) # (z,y,2-z)
|
||||
# Main space diagonals
|
||||
lines.append([0, 13, 26])
|
||||
lines.append([2, 13, 24])
|
||||
lines.append([6, 13, 20])
|
||||
lines.append([8, 13, 18])
|
||||
return lines
|
||||
|
||||
def game_over_msg(self, id):
|
||||
"""Generate game over message"""
|
||||
g = self.game[id]
|
||||
winner = self.check_winner(id)
|
||||
|
||||
if winner == X:
|
||||
g["won"] += 1
|
||||
return "🎉You won! (n)ew (e)nd"
|
||||
elif winner == O:
|
||||
return "🤖Bot wins! (n)ew (e)nd"
|
||||
else:
|
||||
return "🤝Tie, The only winning move! (n)ew (e)nd"
|
||||
|
||||
def play(self, id, input_msg):
|
||||
"""Main game play function"""
|
||||
if id not in self.game:
|
||||
return self.new_game(id)
|
||||
|
||||
# If input is just "tictactoe", show current board
|
||||
if input_msg.lower().strip() == ("tictactoe" or "tic-tac-toe"):
|
||||
return self.show_board(id) + "Your turn! Pick 1-9:"
|
||||
|
||||
g = self.game[id]
|
||||
|
||||
# Parse player move
|
||||
try:
|
||||
# Extract just the number from the input
|
||||
numbers = [char for char in input_msg if char.isdigit()]
|
||||
if not numbers:
|
||||
if input_msg.lower().startswith('q'):
|
||||
self.end_game(id)
|
||||
return "Game ended. To start a new game, type 'tictactoe'."
|
||||
elif input_msg.lower().startswith('n'):
|
||||
return self.new_game(id)
|
||||
elif input_msg.lower().startswith('b'):
|
||||
return self.show_board(id) + "Your turn! Pick 1-9:"
|
||||
position = int(numbers[0])
|
||||
except (ValueError, IndexError):
|
||||
return "Enter 1-9, or (e)nd (n)ew game, send (b)oard to see board🧩"
|
||||
|
||||
# Make player move
|
||||
if not self.make_move(id, position):
|
||||
return "Invalid move! Pick 1-9:"
|
||||
|
||||
# Check if player won
|
||||
if self.check_winner(id):
|
||||
result = self.game_over_msg(id) + "\n" + self.show_board(id)
|
||||
self.end_game(id)
|
||||
return result
|
||||
|
||||
# Check for tie
|
||||
if self.is_board_full(id):
|
||||
result = self.game_over_msg(id) + "\n" + self.show_board(id)
|
||||
self.end_game(id)
|
||||
return result
|
||||
|
||||
# Bot's turn
|
||||
bot_pos = self.bot_move(id)
|
||||
|
||||
# Check if bot won
|
||||
if self.check_winner(id):
|
||||
result = self.game_over_msg(id) + "\n" + self.show_board(id)
|
||||
self.end_game(id)
|
||||
return result
|
||||
|
||||
# Check for tie after bot move
|
||||
if self.is_board_full(id):
|
||||
result = self.game_over_msg(id) + "\n" + self.show_board(id)
|
||||
self.end_game(id)
|
||||
return result
|
||||
|
||||
# Continue game
|
||||
return self.show_board(id) + "Your turn! Pick 1-9:"
|
||||
|
||||
def end_game(self, id):
|
||||
"""Clean up finished game but keep stats"""
|
||||
if id in self.game:
|
||||
games = self.game[id]["games"]
|
||||
won = self.game[id]["won"]
|
||||
# Remove game but we'll create new one on next play
|
||||
del self.game[id]
|
||||
# Preserve stats for next game
|
||||
self.game[id] = {
|
||||
"board": [" "] * 9,
|
||||
"player": X,
|
||||
"games": games,
|
||||
"won": won,
|
||||
"turn": "human"
|
||||
}
|
||||
|
||||
|
||||
def end(self, id):
|
||||
"""End game completely (called by 'end' command)"""
|
||||
if id in self.game:
|
||||
del self.game[id]
|
||||
|
||||
|
||||
# Global instances for the bot system
|
||||
tictactoeTracker = []
|
||||
tictactoe = TicTacToe()
|
||||
def end(self, nodeID):
|
||||
"""End and remove the game for the given nodeID."""
|
||||
if nodeID in self.game:
|
||||
del self.game[nodeID]
|
||||
199
modules/games/tictactoe_vid.py
Normal file
199
modules/games/tictactoe_vid.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# Tic-Tac-Toe Video Display Module for Meshtastic mesh-bot
|
||||
# Uses Pygame to render the game board visually
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
print("Pygame is not installed. Please install it with 'pip install pygame-ce' to use the Tic-Tac-Toe display module.")
|
||||
exit(1)
|
||||
|
||||
latest_board = [" "] * 9 # or 27 for 3D
|
||||
latest_meta = {} # To store metadata like status
|
||||
|
||||
def handle_tictactoe_payload(payload, from_id=None):
|
||||
global latest_board, latest_meta
|
||||
#print("Received payload:", payload)
|
||||
board, meta = parse_tictactoe_message(payload)
|
||||
#print("Parsed board:", board)
|
||||
if board:
|
||||
latest_board = board
|
||||
latest_meta = meta if meta else {}
|
||||
|
||||
def parse_tictactoe_message(msg):
|
||||
# msg is already stripped of 'MTTT:' prefix
|
||||
parts = msg.split("|")
|
||||
if not parts or len(parts[0]) < 9:
|
||||
return None, None # Not enough data for a board
|
||||
board_str = parts[0]
|
||||
meta = {}
|
||||
if len(parts) > 1:
|
||||
meta["nodeID"] = parts[1] if len(parts) > 1 else ""
|
||||
meta["channel"] = parts[2] if len(parts) > 2 else ""
|
||||
meta["deviceID"] = parts[3] if len(parts) > 3 else ""
|
||||
# Look for status in any remaining parts
|
||||
for part in parts[4:]:
|
||||
if part.startswith("status="):
|
||||
meta["status"] = part.split("=", 1)[1]
|
||||
symbol_map = {"0": " ", "1": "❌", "2": "⭕️"}
|
||||
board = [symbol_map.get(c, " ") for c in board_str]
|
||||
return board, meta
|
||||
|
||||
def draw_board(screen, board, meta=None):
|
||||
screen.fill((30, 30, 30))
|
||||
width, height = screen.get_size()
|
||||
margin = int(min(width, height) * 0.05)
|
||||
font_size = int(height * 0.12)
|
||||
font = pygame.font.Font(None, font_size)
|
||||
|
||||
# Draw the title at the top center, scaled
|
||||
title_font = pygame.font.Font(None, int(height * 0.08))
|
||||
title_text = title_font.render("MeshBot Tic-Tac-Toe", True, (220, 220, 255))
|
||||
title_rect = title_text.get_rect(center=(width // 2, margin // 2 + 10))
|
||||
screen.blit(title_text, title_rect)
|
||||
|
||||
# Add a buffer below the title
|
||||
title_buffer = int(height * 0.06)
|
||||
|
||||
# --- Show win/draw message if present ---
|
||||
if meta and "status" in meta:
|
||||
status = meta["status"]
|
||||
msg_font = pygame.font.Font(None, int(height * 0.06)) # Smaller font
|
||||
msg_y = title_rect.bottom + int(height * 0.04) # Just under the title
|
||||
if status == "win":
|
||||
msg = "Game Won!"
|
||||
text = msg_font.render(msg, True, (100, 255, 100))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
elif status == "tie":
|
||||
msg = "Tie Game!"
|
||||
text = msg_font.render(msg, True, (255, 220, 120))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
elif status == "loss":
|
||||
msg = "You Lost!"
|
||||
text = msg_font.render(msg, True, (255, 100, 100))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
elif status == "new":
|
||||
msg = "Welcome! New Game"
|
||||
text = msg_font.render(msg, True, (200, 255, 200))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
# Do NOT return here—let the board draw as normal
|
||||
elif status != "refresh":
|
||||
msg = status.capitalize()
|
||||
text = msg_font.render(msg, True, (255, 220, 120))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
# Don't return here—let the board draw as normal
|
||||
|
||||
# Show waiting message if board is empty, unless status is "new"
|
||||
if all(cell.strip() == "" or cell.strip() == " " for cell in board):
|
||||
if not (meta and meta.get("status") == "new"):
|
||||
msg_font = pygame.font.Font(None, int(height * 0.09))
|
||||
msg = "Waiting for player..."
|
||||
text = msg_font.render(msg, True, (200, 200, 200))
|
||||
text_rect = text.get_rect(center=(width // 2, height // 2))
|
||||
screen.blit(text, text_rect)
|
||||
pygame.display.flip()
|
||||
return
|
||||
|
||||
def draw_x(rect):
|
||||
thickness = max(4, rect.width // 12)
|
||||
pygame.draw.line(screen, (255, 80, 80), rect.topleft, rect.bottomright, thickness)
|
||||
pygame.draw.line(screen, (255, 80, 80), rect.topright, rect.bottomleft, thickness)
|
||||
|
||||
def draw_o(rect):
|
||||
center = rect.center
|
||||
radius = rect.width // 2 - max(6, rect.width // 16)
|
||||
thickness = max(4, rect.width // 12)
|
||||
pygame.draw.circle(screen, (80, 180, 255), center, radius, thickness)
|
||||
|
||||
if len(board) == 9:
|
||||
# 2D: Center a single 3x3 grid, scale to fit
|
||||
size = min((width - 2*margin)//3, (height - 2*margin - title_buffer)//3)
|
||||
offset_x = (width - size*3) // 2
|
||||
offset_y = (height - size*3) // 2 + title_buffer // 2
|
||||
offset_y = max(offset_y, title_rect.bottom + title_buffer)
|
||||
# Index number font and buffer
|
||||
small_index_font = pygame.font.Font(None, int(size * 0.38))
|
||||
index_buffer_x = int(size * 0.16)
|
||||
index_buffer_y = int(size * 0.10)
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
rect = pygame.Rect(offset_x + j*size, offset_y + i*size, size, size)
|
||||
pygame.draw.rect(screen, (200, 200, 200), rect, 2)
|
||||
idx = i*3 + j
|
||||
# Draw index number in top-left, start at 1
|
||||
idx_text = small_index_font.render(str(idx + 1), True, (120, 120, 160))
|
||||
idx_rect = idx_text.get_rect(topleft=(rect.x + index_buffer_x, rect.y + index_buffer_y))
|
||||
screen.blit(idx_text, idx_rect)
|
||||
val = board[idx].strip()
|
||||
if val == "❌" or val == "X":
|
||||
draw_x(rect)
|
||||
elif val == "⭕️" or val == "O":
|
||||
draw_o(rect)
|
||||
elif val:
|
||||
text = font.render(val, True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=rect.center)
|
||||
screen.blit(text, text_rect)
|
||||
elif len(board) == 27:
|
||||
# 3D: Stack three 3x3 grids vertically, with horizontal offsets for 3D effect, scale to fit
|
||||
size = min((width - 2*margin)//7, (height - 4*margin - title_buffer)//9)
|
||||
base_offset_x = (width - (size * 3)) // 2
|
||||
offset_y = (height - (size*9 + margin*2)) // 2 + title_buffer // 2
|
||||
offset_y = max(offset_y, title_rect.bottom + title_buffer)
|
||||
small_font = pygame.font.Font(None, int(height * 0.045))
|
||||
small_index_font = pygame.font.Font(None, int(size * 0.38))
|
||||
index_buffer_x = int(size * 0.16)
|
||||
index_buffer_y = int(size * 0.10)
|
||||
for display_idx, layer in enumerate(reversed(range(3))):
|
||||
layer_offset_x = base_offset_x + (layer - 1) * 2 * size
|
||||
layer_y = offset_y + display_idx * (size*3 + margin)
|
||||
label_text = f"Layer {layer+1}"
|
||||
label = small_font.render(label_text, True, (180, 180, 220))
|
||||
label_rect = label.get_rect(center=(layer_offset_x + size*3//2, layer_y + size*3 + int(size*0.2)))
|
||||
screen.blit(label, label_rect)
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
rect = pygame.Rect(layer_offset_x + j*size, layer_y + i*size, size, size)
|
||||
pygame.draw.rect(screen, (200, 200, 200), rect, 2)
|
||||
idx = layer*9 + i*3 + j
|
||||
idx_text = small_index_font.render(str(idx + 1), True, (120, 120, 160))
|
||||
idx_rect = idx_text.get_rect(topleft=(rect.x + index_buffer_x, rect.y + index_buffer_y))
|
||||
screen.blit(idx_text, idx_rect)
|
||||
val = board[idx].strip()
|
||||
if val == "❌" or val == "X":
|
||||
draw_x(rect)
|
||||
elif val == "⭕️" or val == "O":
|
||||
draw_o(rect)
|
||||
elif val:
|
||||
text = font.render(val, True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=rect.center)
|
||||
screen.blit(text, text_rect)
|
||||
pygame.display.flip()
|
||||
|
||||
def ttt_main(fullscreen=True):
|
||||
global latest_board, latest_meta
|
||||
pygame.init()
|
||||
if fullscreen:
|
||||
screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
|
||||
else:
|
||||
# Use a reasonable windowed size if not fullscreen
|
||||
screen = pygame.display.set_mode((900, 700))
|
||||
pygame.display.set_caption("Tic-Tac-Toe 3D Display")
|
||||
info = pygame.display.Info()
|
||||
mode = "fullscreen" if fullscreen else "windowed"
|
||||
print(f"[MeshBot TTT Display] Pygame version: {pygame.version.ver}")
|
||||
print(f"[MeshBot TTT Display] Resolution: {info.current_w}x{info.current_h} ({mode})")
|
||||
print(f"[MeshBot TTT Display] Display driver: {pygame.display.get_driver()}")
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
|
||||
running = False
|
||||
draw_board(screen, latest_board, latest_meta)
|
||||
pygame.display.flip()
|
||||
pygame.time.wait(75) # or 50-100 for lower CPU
|
||||
pygame.quit()
|
||||
@@ -10,8 +10,8 @@ import bs4 as bs # pip install beautifulsoup4
|
||||
from modules.log import logger
|
||||
from modules.settings import urlTimeoutSeconds, NO_ALERTS, myRegionalKeysDE
|
||||
|
||||
trap_list_location_eu = ("ukalert")
|
||||
trap_list_location_de = ("dealert")
|
||||
trap_list_location_eu = ("ukalert",)
|
||||
trap_list_location_de = ("dealert",)
|
||||
|
||||
def get_govUK_alerts(lat, lon):
|
||||
try:
|
||||
|
||||
423
modules/inventory.md
Normal file
423
modules/inventory.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Inventory & Point of Sale System
|
||||
|
||||
## Overview
|
||||
|
||||
The inventory module provides a simple point-of-sale (POS) system for mesh networks, enabling inventory management, sales tracking, and cart-based transactions. This system is ideal for:
|
||||
|
||||
- Emergency supply management
|
||||
- Event merchandise sales
|
||||
- Community supply tracking
|
||||
- Remote location inventory
|
||||
- Asset management
|
||||
- Field operations logistics
|
||||
- Tool lending in makerspaces or ham swaps
|
||||
- Tracking and lending shared items like Legos or kits
|
||||
|
||||
> **Tool Lending & Shared Item Tracking:**
|
||||
> The system supports lending out tools or kits (e.g., in a makerspace or ham swap) using the `itemloan` and `itemreturn` commands. You can also track bulk or set-based items like Legos, manage their locations, and log checkouts and returns for community sharing or events.
|
||||
|
||||
## Features
|
||||
|
||||
### 🏪 Simple POS System
|
||||
- **Item Management**: Add, remove, and update inventory items
|
||||
- **Cart System**: Build orders before completing transactions
|
||||
- **Transaction Logging**: Full audit trail of all sales and returns
|
||||
- **Price Tracking**: Track price changes over time
|
||||
- **Location Tracking**: Optional warehouse/location field for items
|
||||
|
||||
### 💰 Financial Features
|
||||
- **Penny Rounding**: USA cash sales support
|
||||
- Cash sales round down to nearest nickel
|
||||
- Taxed sales round up to nearest nickel
|
||||
- **Daily Statistics**: Track sales performance
|
||||
- **Hot Item Detection**: Identify best-selling products
|
||||
- **Revenue Tracking**: Daily sales totals
|
||||
|
||||
### 📊 Reporting
|
||||
- **Inventory Value**: Total inventory worth
|
||||
- **Sales Reports**: Daily transaction summaries
|
||||
- **Best Sellers**: Most popular items
|
||||
|
||||
**Cart System:**
|
||||
- `cartadd <name> <qty>` - Add to cart
|
||||
- `cartremove <name>` - Remove from cart
|
||||
- `cartlist` / `cart` - View cart
|
||||
- `cartbuy` / `cartsell [notes]` - Complete transaction
|
||||
- `cartclear` - Empty cart
|
||||
|
||||
**Item Management:**
|
||||
- `itemadd <name> <qty> [price] [loc]` - Add new item
|
||||
- `itemremove <name>` - Remove item
|
||||
- `itemreset name> <qty> [price] [loc]` - Update item
|
||||
- `itemsell <name> <qty> [notes]` - Quick sale
|
||||
- `itemloan <name> <note>` - Loan/checkout an item
|
||||
- `itemreturn <transaction_id>` - Reverse transaction
|
||||
- `itemlist` - View all inventory
|
||||
- `itemstats` - Daily statistics
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `config.ini`:
|
||||
|
||||
```ini
|
||||
[inventory]
|
||||
enabled = True
|
||||
inventory_db = data/inventory.db
|
||||
# Set to True to disable penny precision and round to nickels (USA cash sales)
|
||||
# When True: cash sales round down, taxed sales round up to nearest $0.05
|
||||
# When False (default): normal penny precision ($0.01)
|
||||
disable_penny = False
|
||||
```
|
||||
|
||||
## Commands Reference
|
||||
|
||||
### Item Management
|
||||
|
||||
#### Add Item
|
||||
```
|
||||
itemadd <name> <price> <quantity> [location]
|
||||
```
|
||||
|
||||
Adds a new item to inventory.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemadd Radio 149.99 5 Shelf-A
|
||||
itemadd Battery 12.50 20 Warehouse
|
||||
itemadd Water 1.00 100
|
||||
```
|
||||
|
||||
#### Remove Item
|
||||
```
|
||||
itemremove <name>
|
||||
```
|
||||
|
||||
Removes an item from inventory (also removes from all carts).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemremove Radio
|
||||
itemremove "First Aid Kit"
|
||||
```
|
||||
|
||||
#### Update Item
|
||||
```
|
||||
itemreset <name> [price=X] [qty=Y]
|
||||
```
|
||||
|
||||
Updates item price and/or quantity.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemreset Radio price=139.99
|
||||
itemreset Battery qty=50
|
||||
itemreset Water price=0.95 qty=200
|
||||
```
|
||||
|
||||
#### Quick Sale
|
||||
```
|
||||
itemsell <name> <quantity> [notes]
|
||||
```
|
||||
|
||||
Sell directly without using cart (for quick transactions).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemsell Battery 2
|
||||
itemsell Water 10 Emergency supply
|
||||
itemsell Radio 1 Field unit sale
|
||||
```
|
||||
|
||||
#### Return Transaction
|
||||
```
|
||||
itemreturn <transaction_id>
|
||||
```
|
||||
|
||||
Reverse a transaction and return items to inventory.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemreturn 123
|
||||
itemreturn 45
|
||||
```
|
||||
|
||||
#### List Inventory
|
||||
```
|
||||
itemlist
|
||||
```
|
||||
|
||||
Shows all items with prices, quantities, and total inventory value.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
📦 Inventory:
|
||||
Radio: $149.99 x 5 @ Shelf-A = $749.95
|
||||
Battery: $12.50 x 20 @ Warehouse = $250.00
|
||||
Water: $1.00 x 100 = $100.00
|
||||
|
||||
Total Value: $1,099.95
|
||||
```
|
||||
|
||||
#### Statistics
|
||||
```
|
||||
itemstats
|
||||
```
|
||||
|
||||
Shows today's sales performance.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
📊 Today's Stats:
|
||||
Sales: 15
|
||||
Revenue: $423.50
|
||||
Hot Item: Battery (8 sold)
|
||||
```
|
||||
|
||||
### Cart System
|
||||
|
||||
#### Add to Cart
|
||||
```
|
||||
cartadd <name> <quantity>
|
||||
```
|
||||
|
||||
Add items to your shopping cart.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
cartadd Radio 2
|
||||
cartadd Battery 4
|
||||
cartadd Water 12
|
||||
```
|
||||
|
||||
#### Remove from Cart
|
||||
```
|
||||
cartremove <name>
|
||||
```
|
||||
|
||||
Remove items from cart.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
cartremove Radio
|
||||
cartremove Battery
|
||||
```
|
||||
|
||||
#### View Cart
|
||||
```
|
||||
cart
|
||||
cartlist
|
||||
```
|
||||
|
||||
Display your current cart contents and total.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
🛒 Your Cart:
|
||||
Radio: $149.99 x 2 = $299.98
|
||||
Battery: $12.50 x 4 = $50.00
|
||||
|
||||
Total: $349.98
|
||||
```
|
||||
|
||||
#### Complete Transaction
|
||||
```
|
||||
cartbuy [notes]
|
||||
cartsell [notes]
|
||||
```
|
||||
|
||||
Process the cart as a transaction. Use `cartbuy` for purchases (adds to inventory) or `cartsell` for sales (removes from inventory).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
cartsell Customer purchase
|
||||
cartbuy Restocking supplies
|
||||
cartsell Event merchandise
|
||||
```
|
||||
|
||||
#### Clear Cart
|
||||
```
|
||||
cartclear
|
||||
```
|
||||
|
||||
Empty your shopping cart without completing a transaction.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Event Merchandise Sales
|
||||
|
||||
Perfect for festivals, hamfests, or community events:
|
||||
|
||||
```
|
||||
# Setup inventory
|
||||
itemadd Tshirt 20.00 50 Booth-A
|
||||
itemadd Hat 15.00 30 Booth-A
|
||||
itemadd Sticker 5.00 100 Booth-B
|
||||
|
||||
# Customer transaction
|
||||
cartadd Tshirt 2
|
||||
cartadd Hat 1
|
||||
cartsell Festival sale
|
||||
|
||||
# Check daily performance
|
||||
itemstats
|
||||
```
|
||||
|
||||
### 2. Emergency Supply Tracking
|
||||
|
||||
Track supplies during disaster response:
|
||||
|
||||
```
|
||||
# Add emergency supplies
|
||||
itemadd Water 0.00 500 Warehouse-1
|
||||
itemadd MRE 0.00 200 Warehouse-1
|
||||
itemadd Blanket 0.00 100 Warehouse-2
|
||||
|
||||
# Distribute supplies
|
||||
itemsell Water 50 Red Cross distribution
|
||||
itemsell MRE 20 Family shelter
|
||||
|
||||
# Check remaining inventory
|
||||
itemlist
|
||||
```
|
||||
|
||||
### 3. Field Equipment Management
|
||||
|
||||
Manage tools and equipment in remote locations:
|
||||
|
||||
```
|
||||
# Track equipment
|
||||
itemadd Generator 500.00 3 Base-Camp
|
||||
itemadd Radio 200.00 10 Equipment-Room
|
||||
itemadd Battery 15.00 50 Supply-Closet
|
||||
|
||||
# Equipment checkout
|
||||
itemsell Generator 1 Field deployment
|
||||
itemsell Radio 5 Survey team
|
||||
|
||||
# Monitor inventory
|
||||
itemlist
|
||||
itemstats
|
||||
```
|
||||
|
||||
### 4. Community Supply Exchange
|
||||
|
||||
Facilitate supply exchanges within a community:
|
||||
|
||||
```
|
||||
# Add community items
|
||||
itemadd Seeds 2.00 100 Community-Garden
|
||||
itemadd Firewood 10.00 20 Storage-Shed
|
||||
|
||||
# Member transactions
|
||||
cartadd Seeds 5
|
||||
cartadd Firewood 2
|
||||
cartsell Member-123 purchase
|
||||
```
|
||||
|
||||
## Penny Rounding (USA Mode)
|
||||
|
||||
When `disable_penny = True` is set in the configuration, the system implements penny rounding (disabling penny precision). This follows USA practice where pennies are not commonly used in cash transactions.
|
||||
|
||||
### Cash Sales (Round Down)
|
||||
- $10.47 → $10.45
|
||||
- $10.48 → $10.45
|
||||
- $10.49 → $10.45
|
||||
|
||||
### Taxed Sales (Round Up)
|
||||
- $10.47 → $10.50
|
||||
- $10.48 → $10.50
|
||||
- $10.49 → $10.50
|
||||
|
||||
This follows common USA practice where pennies are not used in cash transactions.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The system uses SQLite with four tables:
|
||||
|
||||
### items
|
||||
```sql
|
||||
CREATE TABLE items (
|
||||
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_name TEXT UNIQUE NOT NULL,
|
||||
item_price REAL NOT NULL,
|
||||
item_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
created_date TEXT,
|
||||
updated_date TEXT
|
||||
)
|
||||
```
|
||||
|
||||
### transactions
|
||||
```sql
|
||||
CREATE TABLE transactions (
|
||||
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_type TEXT NOT NULL,
|
||||
transaction_date TEXT NOT NULL,
|
||||
transaction_time TEXT NOT NULL,
|
||||
user_name TEXT,
|
||||
total_amount REAL NOT NULL,
|
||||
notes TEXT
|
||||
)
|
||||
```
|
||||
|
||||
### transaction_items
|
||||
```sql
|
||||
CREATE TABLE transaction_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
price_at_sale REAL NOT NULL,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(transaction_id),
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id)
|
||||
)
|
||||
```
|
||||
|
||||
### carts
|
||||
```sql
|
||||
CREATE TABLE carts (
|
||||
cart_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
added_date TEXT,
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id)
|
||||
)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Users on the `bbs_ban_list` cannot use inventory commands
|
||||
- Each user has their own cart (identified by node ID)
|
||||
- Transactions are logged with user information for accountability
|
||||
- All database operations use parameterized queries to prevent SQL injection
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Regular Inventory Checks**: Use `itemlist` regularly to monitor stock levels
|
||||
2. **Descriptive Notes**: Add notes to transactions for better tracking
|
||||
3. **Location Tags**: Use consistent location naming for better organization
|
||||
4. **Daily Reviews**: Check `itemstats` at the end of each day
|
||||
5. **Transaction IDs**: Keep track of transaction IDs for potential returns
|
||||
6. **Quantity Updates**: Use `itemreset` to adjust inventory after physical counts
|
||||
7. **Cart Cleanup**: Use `cartclear` if you change your mind before completing a sale
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Item Already Exists
|
||||
If you get "Item already exists" when using `itemadd`, use `itemreset` instead to update the existing item.
|
||||
|
||||
### Insufficient Quantity
|
||||
If you see "Insufficient quantity" error, check available stock with `itemlist` before attempting the sale.
|
||||
|
||||
### Transaction Not Found
|
||||
If `itemreturn` fails, verify the transaction ID exists. Use recent transaction logs to find valid IDs.
|
||||
|
||||
### Cart Not Showing Items
|
||||
Each user has their own cart. Make sure you're using your own node to view your cart.
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
For issues or feature requests, please file an issue on the GitHub repository.
|
||||
747
modules/inventory.py
Normal file
747
modules/inventory.py
Normal file
@@ -0,0 +1,747 @@
|
||||
# Inventory and Point of Sale module for the bot
|
||||
# K7MHI Kelly Keeton 2024
|
||||
# Enhanced POS system with cart management and inventory tracking
|
||||
|
||||
import sqlite3
|
||||
from modules.log import logger
|
||||
from modules.settings import inventory_db, disable_penny, bbs_ban_list
|
||||
import time
|
||||
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN
|
||||
|
||||
trap_list_inventory = ("item", "itemlist", "itemloan", "itemsell", "itemreturn", "itemadd", "itemremove",
|
||||
"itemreset", "itemstats", "cart", "cartadd", "cartremove", "cartlist",
|
||||
"cartbuy", "cartsell", "cartclear")
|
||||
|
||||
def initialize_inventory_database():
|
||||
"""Initialize the inventory database with all necessary tables"""
|
||||
try:
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
# Items table - stores inventory items
|
||||
logger.debug("System: Inventory: Initializing database...")
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS items
|
||||
(item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_name TEXT UNIQUE NOT NULL,
|
||||
item_price REAL NOT NULL,
|
||||
item_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
created_date TEXT,
|
||||
updated_date TEXT)''')
|
||||
|
||||
# Transactions table - stores sales/purchases
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS transactions
|
||||
(transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_type TEXT NOT NULL,
|
||||
transaction_date TEXT NOT NULL,
|
||||
transaction_time TEXT NOT NULL,
|
||||
user_name TEXT,
|
||||
total_amount REAL NOT NULL,
|
||||
notes TEXT)''')
|
||||
|
||||
# Transaction items table - stores items in each transaction
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS transaction_items
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
price_at_sale REAL NOT NULL,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(transaction_id),
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id))''')
|
||||
|
||||
# Carts table - stores temporary shopping carts
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS carts
|
||||
(cart_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
added_date TEXT,
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id))''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("Inventory: Database initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Inventory: Failed to initialize database: {e}")
|
||||
return False
|
||||
|
||||
def round_price(amount, is_taxed_sale=False):
|
||||
"""Round price based on penny rounding settings"""
|
||||
if not disable_penny:
|
||||
return float(Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
|
||||
|
||||
# Penny rounding logic
|
||||
decimal_amount = Decimal(str(amount))
|
||||
if is_taxed_sale:
|
||||
# Round up for taxed sales
|
||||
return float(decimal_amount.quantize(Decimal('0.05'), rounding=ROUND_HALF_UP))
|
||||
else:
|
||||
# Round down for cash sales
|
||||
return float(decimal_amount.quantize(Decimal('0.05'), rounding=ROUND_DOWN))
|
||||
|
||||
def add_item(name, price, quantity=0, location=""):
|
||||
"""Add a new item to inventory"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Check if item already exists
|
||||
c.execute("SELECT item_id FROM items WHERE item_name = ?", (name,))
|
||||
existing = c.fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
return f"Item '{name}' already exists. Use itemreset to update."
|
||||
|
||||
c.execute("""INSERT INTO items (item_name, item_price, item_quantity, location, created_date, updated_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(name, price, quantity, location, current_date, current_date))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"✅ Item added: {name} - ${price:.2f} - Qty: {quantity}"
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
initialize_inventory_database()
|
||||
return add_item(name, price, quantity, location)
|
||||
else:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error adding item: {e}")
|
||||
return "Error adding item."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error adding item: {e}")
|
||||
return "Error adding item."
|
||||
|
||||
def remove_item(name):
|
||||
"""Remove an item from inventory"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("DELETE FROM items WHERE item_name = ?", (name,))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🗑️ Item removed: {name}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error removing item: {e}")
|
||||
return "Error removing item."
|
||||
|
||||
def reset_item(name, price=None, quantity=None):
|
||||
"""Update item price or quantity"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Check if item exists
|
||||
c.execute("SELECT item_price, item_quantity FROM items WHERE item_name = ?", (name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if price is not None:
|
||||
updates.append("item_price = ?")
|
||||
params.append(price)
|
||||
|
||||
if quantity is not None:
|
||||
updates.append("item_quantity = ?")
|
||||
params.append(quantity)
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
return "No updates specified."
|
||||
|
||||
updates.append("updated_date = ?")
|
||||
params.append(current_date)
|
||||
params.append(name)
|
||||
|
||||
query = f"UPDATE items SET {', '.join(updates)} WHERE item_name = ?"
|
||||
c.execute(query, params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
update_msg = []
|
||||
if price is not None:
|
||||
update_msg.append(f"Price: ${price:.2f}")
|
||||
if quantity is not None:
|
||||
update_msg.append(f"Qty: {quantity}")
|
||||
|
||||
return f"🔄 Item updated: {name} - {' - '.join(update_msg)}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error resetting item: {e}")
|
||||
return "Error updating item."
|
||||
|
||||
def sell_item(name, quantity, user_name="", notes=""):
|
||||
"""Sell an item (remove from inventory and record transaction)"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Get item details
|
||||
c.execute("SELECT item_id, item_price, item_quantity FROM items WHERE item_name = ?", (name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
|
||||
item_id, price, current_qty = item
|
||||
|
||||
if current_qty < quantity:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {current_qty}"
|
||||
|
||||
# Calculate total with rounding
|
||||
total = round_price(price * quantity, is_taxed_sale=True)
|
||||
|
||||
# Create transaction
|
||||
c.execute("""INSERT INTO transactions (transaction_type, transaction_date, transaction_time,
|
||||
user_name, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
("SALE", current_date, current_time, user_name, total, notes))
|
||||
transaction_id = c.lastrowid
|
||||
|
||||
# Add transaction item
|
||||
c.execute("""INSERT INTO transaction_items (transaction_id, item_id, quantity, price_at_sale)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(transaction_id, item_id, quantity, price))
|
||||
|
||||
# Update inventory
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity - ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"💰 Sale: {quantity}x {name} - Total: ${total:.2f}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error selling item: {e}")
|
||||
return "Error processing sale."
|
||||
|
||||
def return_item(transaction_id):
|
||||
"""Return items from a transaction (reverse the sale or loan)"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Get transaction details
|
||||
c.execute("SELECT transaction_type FROM transactions WHERE transaction_id = ?", (transaction_id,))
|
||||
transaction = c.fetchone()
|
||||
if not transaction:
|
||||
conn.close()
|
||||
return f"Transaction {transaction_id} not found."
|
||||
transaction_type = transaction[0]
|
||||
|
||||
# Get items in transaction
|
||||
c.execute("""SELECT ti.item_id, ti.quantity, i.item_name
|
||||
FROM transaction_items ti
|
||||
JOIN items i ON ti.item_id = i.item_id
|
||||
WHERE ti.transaction_id = ?""", (transaction_id,))
|
||||
items = c.fetchall()
|
||||
|
||||
if not items:
|
||||
conn.close()
|
||||
return f"No items found for transaction {transaction_id}."
|
||||
|
||||
# Return items to inventory
|
||||
for item_id, quantity, item_name in items:
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity + ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
|
||||
# Remove transaction and transaction_items
|
||||
c.execute("DELETE FROM transactions WHERE transaction_id = ?", (transaction_id,))
|
||||
c.execute("DELETE FROM transaction_items WHERE transaction_id = ?", (transaction_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if transaction_type == "LOAN":
|
||||
return f"↩️ Loan {transaction_id} returned. Item(s) back in inventory."
|
||||
else:
|
||||
return f"↩️ Transaction {transaction_id} reversed. Items returned to inventory."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error returning item: {e}")
|
||||
return "Error processing return."
|
||||
|
||||
def loan_item(name, user_name="", note=""):
|
||||
"""Loan an item (checkout/loan to someone, record transaction)"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Get item details
|
||||
c.execute("SELECT item_id, item_price, item_quantity FROM items WHERE item_name = ?", (name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
item_id, price, current_qty = item
|
||||
|
||||
if current_qty < 1:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {current_qty}"
|
||||
|
||||
# Create loan transaction (quantity always 1 for now)
|
||||
c.execute("""INSERT INTO transactions (transaction_type, transaction_date, transaction_time,
|
||||
user_name, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
("LOAN", current_date, current_time, user_name, 0.0, note))
|
||||
transaction_id = c.lastrowid
|
||||
|
||||
# Add transaction item
|
||||
c.execute("""INSERT INTO transaction_items (transaction_id, item_id, quantity, price_at_sale)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(transaction_id, item_id, 1, price))
|
||||
|
||||
# Update inventory
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity - 1, updated_date = ? WHERE item_id = ?",
|
||||
(current_date, item_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🔖 Loaned: {name} (note: {note}) [Transaction #{transaction_id}]"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error loaning item: {e}")
|
||||
return "Error processing loan."
|
||||
|
||||
def get_loans_for_items():
|
||||
"""Return a dict of item_name -> list of loan notes for currently loaned items"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
# Find all active loans (not returned)
|
||||
c.execute("""
|
||||
SELECT i.item_name, t.notes
|
||||
FROM transactions t
|
||||
JOIN transaction_items ti ON t.transaction_id = ti.transaction_id
|
||||
JOIN items i ON ti.item_id = i.item_id
|
||||
WHERE t.transaction_type = 'LOAN'
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
loans = {}
|
||||
for item_name, note in rows:
|
||||
loans.setdefault(item_name, []).append(note)
|
||||
return loans
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error fetching loans: {e}")
|
||||
return {}
|
||||
|
||||
def list_items():
|
||||
"""List all items in inventory, with loan info if any"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT item_name, item_price, item_quantity, location FROM items ORDER BY item_name")
|
||||
items = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not items:
|
||||
return "No items in inventory."
|
||||
|
||||
# Get loan info
|
||||
loans = get_loans_for_items()
|
||||
|
||||
result = "📦 Inventory:\n"
|
||||
total_value = 0
|
||||
for name, price, qty, location in items:
|
||||
value = price * qty
|
||||
total_value += value
|
||||
loc_str = f" @ {location}" if location else ""
|
||||
loan_str = ""
|
||||
if name in loans:
|
||||
for note in loans[name]:
|
||||
loan_str += f" [loan: {note}]"
|
||||
result += f"{name}: ${price:.2f} x {qty}{loc_str} = ${value:.2f}{loan_str}\n"
|
||||
|
||||
result += f"\nTotal Value: ${total_value:.2f}"
|
||||
return result.rstrip()
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error listing items: {e}")
|
||||
return "Error listing items."
|
||||
|
||||
def get_stats():
|
||||
"""Get sales statistics"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
# Get today's sales
|
||||
c.execute("""SELECT COUNT(*), SUM(total_amount)
|
||||
FROM transactions
|
||||
WHERE transaction_type = 'SALE' AND transaction_date = ?""",
|
||||
(current_date,))
|
||||
today_stats = c.fetchone()
|
||||
today_count = today_stats[0] or 0
|
||||
today_total = today_stats[1] or 0
|
||||
|
||||
# Get hot item (most sold today)
|
||||
c.execute("""SELECT i.item_name, SUM(ti.quantity) as total_qty
|
||||
FROM transaction_items ti
|
||||
JOIN transactions t ON ti.transaction_id = t.transaction_id
|
||||
JOIN items i ON ti.item_id = i.item_id
|
||||
WHERE t.transaction_date = ? AND t.transaction_type = 'SALE'
|
||||
GROUP BY i.item_name
|
||||
ORDER BY total_qty DESC
|
||||
LIMIT 1""", (current_date,))
|
||||
hot_item = c.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
result = f"📊 Today's Stats:\n"
|
||||
result += f"Sales: {today_count}\n"
|
||||
result += f"Revenue: ${today_total:.2f}\n"
|
||||
if hot_item:
|
||||
result += f"Hot Item: {hot_item[0]} ({hot_item[1]} sold)"
|
||||
else:
|
||||
result += "Hot Item: None"
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error getting stats: {e}")
|
||||
return "Error getting stats."
|
||||
|
||||
def add_to_cart(user_id, item_name, quantity):
|
||||
"""Add item to user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Get item details
|
||||
c.execute("SELECT item_id, item_quantity FROM items WHERE item_name = ?", (item_name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{item_name}' not found."
|
||||
|
||||
item_id, available_qty = item
|
||||
|
||||
# Check if item already in cart
|
||||
c.execute("SELECT quantity FROM carts WHERE user_id = ? AND item_id = ?", (user_id, item_id))
|
||||
existing = c.fetchone()
|
||||
|
||||
if existing:
|
||||
new_qty = existing[0] + quantity
|
||||
if new_qty > available_qty:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {available_qty}"
|
||||
c.execute("UPDATE carts SET quantity = ? WHERE user_id = ? AND item_id = ?",
|
||||
(new_qty, user_id, item_id))
|
||||
else:
|
||||
if quantity > available_qty:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {available_qty}"
|
||||
c.execute("INSERT INTO carts (user_id, item_id, quantity, added_date) VALUES (?, ?, ?, ?)",
|
||||
(user_id, item_id, quantity, current_date))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🛒 Added to cart: {quantity}x {item_name}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error adding to cart: {e}")
|
||||
return "Error adding to cart."
|
||||
|
||||
def remove_from_cart(user_id, item_name):
|
||||
"""Remove item from user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("""DELETE FROM carts
|
||||
WHERE user_id = ? AND item_id = (SELECT item_id FROM items WHERE item_name = ?)""",
|
||||
(user_id, item_name))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Item '{item_name}' not in cart."
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🗑️ Removed from cart: {item_name}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error removing from cart: {e}")
|
||||
return "Error removing from cart."
|
||||
|
||||
def list_cart(user_id):
|
||||
"""List items in user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("""SELECT i.item_name, i.item_price, c.quantity
|
||||
FROM carts c
|
||||
JOIN items i ON c.item_id = i.item_id
|
||||
WHERE c.user_id = ?""", (user_id,))
|
||||
items = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not items:
|
||||
return "🛒 Cart is empty."
|
||||
|
||||
result = "🛒 Your Cart:\n"
|
||||
total = 0
|
||||
for name, price, qty in items:
|
||||
subtotal = price * qty
|
||||
total += subtotal
|
||||
result += f"{name}: ${price:.2f} x {qty} = ${subtotal:.2f}\n"
|
||||
|
||||
total = round_price(total, is_taxed_sale=True)
|
||||
result += f"\nTotal: ${total:.2f}"
|
||||
return result
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error listing cart: {e}")
|
||||
return "Error listing cart."
|
||||
|
||||
def checkout_cart(user_id, user_name="", transaction_type="SALE", notes=""):
|
||||
"""Process cart as a transaction"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Get cart items
|
||||
c.execute("""SELECT i.item_id, i.item_name, i.item_price, c.quantity, i.item_quantity
|
||||
FROM carts c
|
||||
JOIN items i ON c.item_id = i.item_id
|
||||
WHERE c.user_id = ?""", (user_id,))
|
||||
cart_items = c.fetchall()
|
||||
|
||||
if not cart_items:
|
||||
conn.close()
|
||||
return "Cart is empty."
|
||||
|
||||
# Verify all items have sufficient quantity
|
||||
for item_id, name, price, cart_qty, stock_qty in cart_items:
|
||||
if stock_qty < cart_qty:
|
||||
conn.close()
|
||||
return f"Insufficient quantity for '{name}'. Available: {stock_qty}"
|
||||
|
||||
# Calculate total
|
||||
total = sum(price * qty for _, _, price, qty, _ in cart_items)
|
||||
total = round_price(total, is_taxed_sale=(transaction_type == "SALE"))
|
||||
|
||||
# Create transaction
|
||||
c.execute("""INSERT INTO transactions (transaction_type, transaction_date, transaction_time,
|
||||
user_name, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(transaction_type, current_date, current_time, user_name, total, notes))
|
||||
transaction_id = c.lastrowid
|
||||
|
||||
# Process each item
|
||||
for item_id, name, price, quantity, _ in cart_items:
|
||||
# Add to transaction items
|
||||
c.execute("""INSERT INTO transaction_items (transaction_id, item_id, quantity, price_at_sale)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(transaction_id, item_id, quantity, price))
|
||||
|
||||
# Update inventory (subtract for SALE, add for BUY)
|
||||
if transaction_type == "SALE":
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity - ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
else: # BUY
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity + ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
|
||||
# Clear cart
|
||||
c.execute("DELETE FROM carts WHERE user_id = ?", (user_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
emoji = "💰" if transaction_type == "SALE" else "📦"
|
||||
return f"{emoji} Transaction #{transaction_id} completed: ${total:.2f}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error processing cart: {e}")
|
||||
return "Error processing cart."
|
||||
|
||||
def clear_cart(user_id):
|
||||
"""Clear user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("DELETE FROM carts WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return "🗑️ Cart cleared."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error clearing cart: {e}")
|
||||
return "Error clearing cart."
|
||||
|
||||
def process_inventory_command(nodeID, message, name="none"):
|
||||
"""Process inventory and POS commands"""
|
||||
# Check ban list
|
||||
if str(nodeID) in bbs_ban_list:
|
||||
logger.warning("System: Inventory attempt from the ban list")
|
||||
return "Unable to process command"
|
||||
|
||||
message_lower = message.lower()
|
||||
parts = message.split()
|
||||
|
||||
try:
|
||||
# Help command
|
||||
if "?" in message_lower:
|
||||
return get_inventory_help()
|
||||
|
||||
# Item management commands
|
||||
if message_lower.startswith("itemadd "):
|
||||
# itemadd <name> <qty> [price] [location]
|
||||
if len(parts) < 3:
|
||||
return "Usage: itemadd <name> <qty> [price] [location]"
|
||||
item_name = parts[1]
|
||||
try:
|
||||
quantity = int(parts[2])
|
||||
except ValueError:
|
||||
return "Invalid quantity."
|
||||
price = 0.0
|
||||
location = ""
|
||||
if len(parts) > 3:
|
||||
try:
|
||||
price = float(parts[3])
|
||||
location = " ".join(parts[4:]) if len(parts) > 4 else ""
|
||||
except ValueError:
|
||||
# If price is omitted, treat parts[3] as location
|
||||
price = 0.0
|
||||
location = " ".join(parts[3:])
|
||||
return add_item(item_name, price, quantity, location)
|
||||
|
||||
elif message_lower.startswith("itemremove "):
|
||||
item_name = " ".join(parts[1:])
|
||||
return remove_item(item_name)
|
||||
|
||||
elif message_lower.startswith("itemreset "):
|
||||
# itemreset name [price=X] [quantity=Y]
|
||||
if len(parts) < 2:
|
||||
return "Usage: itemreset <name> [price=X] [quantity=Y]"
|
||||
item_name = parts[1]
|
||||
price = None
|
||||
quantity = None
|
||||
for part in parts[2:]:
|
||||
if part.startswith("price="):
|
||||
try:
|
||||
price = float(part.split("=")[1])
|
||||
except ValueError:
|
||||
return "Invalid price value."
|
||||
elif part.startswith("quantity=") or part.startswith("qty="):
|
||||
try:
|
||||
quantity = int(part.split("=")[1])
|
||||
except ValueError:
|
||||
return "Invalid quantity value."
|
||||
return reset_item(item_name, price, quantity)
|
||||
|
||||
elif message_lower.startswith("itemsell "):
|
||||
# itemsell name quantity [notes]
|
||||
if len(parts) < 3:
|
||||
return "Usage: itemsell <name> <quantity> [notes]"
|
||||
item_name = parts[1]
|
||||
try:
|
||||
quantity = int(parts[2])
|
||||
notes = " ".join(parts[3:]) if len(parts) > 3 else ""
|
||||
return sell_item(item_name, quantity, name, notes)
|
||||
except ValueError:
|
||||
return "Invalid quantity."
|
||||
|
||||
elif message_lower.startswith("itemreturn "):
|
||||
# itemreturn transaction_id
|
||||
if len(parts) < 2:
|
||||
return "Usage: itemreturn <transaction_id>"
|
||||
try:
|
||||
transaction_id = int(parts[1])
|
||||
return return_item(transaction_id)
|
||||
except ValueError:
|
||||
return "Invalid transaction ID."
|
||||
|
||||
elif message_lower.startswith("itemloan "):
|
||||
# itemloan <name> <note>
|
||||
if len(parts) < 3:
|
||||
return "Usage: itemloan <name> <note>"
|
||||
item_name = parts[1]
|
||||
note = " ".join(parts[2:])
|
||||
return loan_item(item_name, name, note)
|
||||
|
||||
elif message_lower == "itemlist":
|
||||
return list_items()
|
||||
|
||||
elif message_lower == "itemstats":
|
||||
return get_stats()
|
||||
|
||||
# Cart commands
|
||||
elif message_lower.startswith("cartadd "):
|
||||
# cartadd name quantity
|
||||
if len(parts) < 3:
|
||||
return "Usage: cartadd <name> <quantity>"
|
||||
item_name = parts[1]
|
||||
try:
|
||||
quantity = int(parts[2])
|
||||
return add_to_cart(str(nodeID), item_name, quantity)
|
||||
except ValueError:
|
||||
return "Invalid quantity."
|
||||
|
||||
elif message_lower.startswith("cartremove "):
|
||||
item_name = " ".join(parts[1:])
|
||||
return remove_from_cart(str(nodeID), item_name)
|
||||
|
||||
elif message_lower == "cartlist" or message_lower == "cart":
|
||||
return list_cart(str(nodeID))
|
||||
|
||||
elif message_lower.startswith("cartbuy") or message_lower.startswith("cartsell"):
|
||||
transaction_type = "BUY" if "buy" in message_lower else "SALE"
|
||||
notes = " ".join(parts[1:]) if len(parts) > 1 else ""
|
||||
return checkout_cart(str(nodeID), name, transaction_type, notes)
|
||||
|
||||
elif message_lower == "cartclear":
|
||||
return clear_cart(str(nodeID))
|
||||
|
||||
else:
|
||||
return "Invalid command. Send 'item?' for help."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Inventory: Error processing command: {e}")
|
||||
return "Error processing command."
|
||||
|
||||
def get_inventory_help():
|
||||
"""Return help text for inventory commands"""
|
||||
return (
|
||||
"📦 Inventory Commands:\n"
|
||||
" itemadd <name> <qty> [price] [loc]\n"
|
||||
" itemremove <name>\n"
|
||||
" itemreset name> <qty> [price] [loc]\n"
|
||||
" itemsell <name> <qty> [notes]\n"
|
||||
" itemloan <name> <note>\n"
|
||||
" itemreturn <transaction_id>\n"
|
||||
" itemlist\n"
|
||||
" itemstats\n"
|
||||
"\n"
|
||||
"🛒 Cart Commands:\n"
|
||||
" cartadd <name> <qty>\n"
|
||||
" cartremove <name>\n"
|
||||
" cartlist\n"
|
||||
" cartbuy/cartsell [notes]\n"
|
||||
" cartclear\n"
|
||||
)
|
||||
88
modules/llm.md
Normal file
88
modules/llm.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# How do I use this thing?
|
||||
This is not a full turnkey setup yet?
|
||||
|
||||
|
||||
For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma3:270m`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time.
|
||||
|
||||
|
||||
# Ollama local
|
||||
```bash
|
||||
# bash
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
# docker
|
||||
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -e OLLAMA_API_BASE_URL=http://host.docker.internal:11434 open-webui/open-webui
|
||||
```
|
||||
|
||||
## Update /etc/systemd/system/ollama.service
|
||||
https://github.com/ollama/ollama/issues/703
|
||||
```ini
|
||||
#service file addition not config.ini
|
||||
# [Service]
|
||||
Environment="OLLAMA_HOST=0.0.0.0:11434"
|
||||
```
|
||||
## validation
|
||||
http://IP::11434
|
||||
`Ollama is running`
|
||||
|
||||
## Docs
|
||||
Note for LLM in docker with [NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/docker-specialized.html). Needed for the container with ollama running?
|
||||
|
||||
---
|
||||
|
||||
# OpenWebUI (docker)
|
||||
```bash
|
||||
## ollama in docker
|
||||
docker run -d -p 3000:8080 --gpus all -v open-webui:/app/backend/data --name open-webui ghcr.io/open-webui/open-webui:cuda
|
||||
|
||||
## external ollama
|
||||
docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://IP:11434 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
|
||||
```
|
||||
wait for engine to build, update the config.ini for the bot
|
||||
|
||||
```ini
|
||||
# Use OpenWebUI instead of direct Ollama API (enables advanced RAG features)
|
||||
useOpenWebUI = False
|
||||
# OpenWebUI server URL (e.g., http://localhost:3000)
|
||||
openWebUIURL = http://localhost:3000
|
||||
# OpenWebUI API key/token (required when useOpenWebUI is True)
|
||||
openWebUIAPIKey = sk-xxxx (see below for help)
|
||||
```
|
||||
|
||||
## Validation
|
||||
http://IP:3000
|
||||
make a new admin user.
|
||||
validate you have models imported or that the system is working for query.
|
||||
make a new user for the bot
|
||||
|
||||
## API Key
|
||||
- upper right settings for the user
|
||||
- settings -> account
|
||||
- get/create the API key for the user
|
||||
|
||||
## Troubleshooting
|
||||
- make sure the OpenWebUI works from the bot node and loads (try lynx etc)
|
||||
- make sure the model in config.ini is also loaded in OpenWebUI and you can use it
|
||||
- make sure **OpenWebUI** can reach **Ollama IP** it should auto import the models
|
||||
- I find using IP and not common use names like localhost which may not work well with docker etc..
|
||||
|
||||
- Check OpenWebUI and Ollama are working
|
||||
- Go to Admin Settings within Open WebUI.
|
||||
- Connections tab
|
||||
- Ollama connection and click on the Manage (wrench icon)
|
||||
- download models directly from the Ollama library
|
||||
- **Once the model is downloaded or imported, it will become available for use within Open WebUI, allowing you to interact with it through the chat interface**
|
||||
|
||||
## Docs
|
||||
[OpenWebUI Quick Start](https://docs.openwebui.com/getting-started/quick-start/)
|
||||
[OpenWebUI API](https://docs.openwebui.com/getting-started/api-endpoints)
|
||||
[OpenWebUI Ollama](https://docs.openwebui.com/getting-started/quick-start/starting-with-ollama/)
|
||||
[Blog OpenWebUI on Pi](https://pimylifeup.com/raspberry-pi-open-webui/)
|
||||
|
||||
https://docs.openwebui.com/tutorials/tips/rag-tutorial#tutorial-configuring-rag-with-open-webui-documentation
|
||||
https://docs.openwebui.com/features/plugin/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
349
modules/llm.py
349
modules/llm.py
@@ -3,30 +3,28 @@
|
||||
# This module is used to interact with LLM API to generate responses to user input
|
||||
# K7MHI Kelly Keeton 2024
|
||||
from modules.log import logger
|
||||
from modules.settings import llmModel, ollamaHostName, rawLLMQuery
|
||||
from modules.settings import (llmModel, ollamaHostName, rawLLMQuery,
|
||||
llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey, cmdBang, urlTimeoutSeconds, use_kiwix_server)
|
||||
|
||||
# Ollama Client
|
||||
# https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
if not rawLLMQuery:
|
||||
# this may be removed in the future
|
||||
from googlesearch import search # pip install googlesearch-python
|
||||
if llmUseWikiContext or use_kiwix_server:
|
||||
from modules.wiki import get_wikipedia_summary, get_kiwix_summary
|
||||
|
||||
# LLM System Variables
|
||||
ollamaAPI = ollamaHostName + "/api/generate"
|
||||
openWebUIChatAPI = openWebUIURL + "/api/chat/completions"
|
||||
openWebUIOllamaProxy = openWebUIURL + "/ollama/api/generate"
|
||||
tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts
|
||||
requestTruncation = True # if True, the LLM "will" truncate the response
|
||||
|
||||
openaiAPI = "https://api.openai.com/v1/completions" # not used, if you do push a enhancement!
|
||||
requestTruncation = True # if True, the LLM "will" truncate the response
|
||||
DEBUG_LLM = False # enable debug logging for LLM queries
|
||||
|
||||
# Used in the meshBotAI template
|
||||
llmEnableHistory = True # enable last message history for the LLM model
|
||||
llmContext_fromGoogle = True # enable context from google search results adds to compute time but really helps with responses accuracy
|
||||
|
||||
googleSearchResults = 3 # number of google search results to include in the context more results = more compute time
|
||||
antiFloodLLM = []
|
||||
llmChat_history = {}
|
||||
trap_list_llm = ("ask:", "askai")
|
||||
@@ -52,24 +50,6 @@ meshBotAI = """
|
||||
|
||||
"""
|
||||
|
||||
if llmContext_fromGoogle:
|
||||
meshBotAI = meshBotAI + """
|
||||
CONTEXT
|
||||
The following is the location of the user
|
||||
{location_name}
|
||||
|
||||
The following is for context around the prompt to help guide your response.
|
||||
{context}
|
||||
|
||||
"""
|
||||
else:
|
||||
meshBotAI = meshBotAI + """
|
||||
CONTEXT
|
||||
The following is the location of the user
|
||||
{location_name}
|
||||
|
||||
"""
|
||||
|
||||
if llmEnableHistory:
|
||||
meshBotAI = meshBotAI + """
|
||||
HISTORY
|
||||
@@ -101,22 +81,6 @@ def llmTool_math_calculator(expression):
|
||||
except Exception as e:
|
||||
return f"Error in calculation: {e}"
|
||||
|
||||
def llmTool_get_google(query, num_results=3):
|
||||
"""
|
||||
Example tool function to perform a Google search and return results.
|
||||
:param query: The search query string.
|
||||
:param num_results: Number of search results to return.
|
||||
:return: A list of search result titles and descriptions.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
googleSearch = search(query, advanced=True, num_results=num_results)
|
||||
for result in googleSearch:
|
||||
results.append(f"{result.title}: {result.description}")
|
||||
return results
|
||||
except Exception as e:
|
||||
return [f"Error in Google search: {e}"]
|
||||
|
||||
llmFunctions = [
|
||||
|
||||
{
|
||||
@@ -141,46 +105,163 @@ llmFunctions = [
|
||||
"required": ["expression"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "llmTool_get_google",
|
||||
"description": "Perform a Google search and return results.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query string."
|
||||
},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "Number of search results to return.",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
def get_google_context(input, num_results):
|
||||
# Get context from Google search results
|
||||
googleResults = []
|
||||
def get_wiki_context(input):
|
||||
"""
|
||||
Get context from Wikipedia/Kiwix for RAG enhancement
|
||||
:param input: The user query
|
||||
:return: Wikipedia summary or empty string if not available
|
||||
"""
|
||||
try:
|
||||
googleSearch = search(input, advanced=True, num_results=num_results)
|
||||
if googleSearch:
|
||||
for result in googleSearch:
|
||||
googleResults.append(f"{result.title} {result.description}")
|
||||
else:
|
||||
googleResults = ['no other context provided']
|
||||
# Extract potential search terms from the input
|
||||
# Try to identify key topics/entities for Wikipedia search
|
||||
search_terms = extract_search_terms(input)
|
||||
|
||||
wiki_context = []
|
||||
for term in search_terms[:2]: # Limit to 2 searches to avoid excessive API calls
|
||||
if use_kiwix_server:
|
||||
summary = get_kiwix_summary(term, truncate=False)
|
||||
else:
|
||||
summary = get_wikipedia_summary(term, truncate=False)
|
||||
|
||||
if summary and "error" not in summary.lower() or "html://" not in summary or "ambiguous" not in summary.lower():
|
||||
wiki_context.append(f"Wikipedia context for '{term}': {summary}")
|
||||
|
||||
return '\n'.join(wiki_context) if wiki_context else ''
|
||||
except Exception as e:
|
||||
logger.debug(f"System: LLM Query: context gathering failed, likely due to network issues")
|
||||
googleResults = ['no other context provided']
|
||||
return googleResults
|
||||
logger.debug(f"System: LLM Query: Wiki context gathering failed: {e}")
|
||||
return ''
|
||||
|
||||
def llm_extract_topic(input):
|
||||
"""
|
||||
Use LLM to extract the main topic as a single word or short phrase.
|
||||
Always uses raw mode and supports both Ollama and OpenWebUI.
|
||||
:param input: The user query
|
||||
:return: List with one topic string, or empty list on failure
|
||||
"""
|
||||
prompt = (
|
||||
"Summarize the following query into a single word or short phrase that best represents the main topic, "
|
||||
"for use as a Wikipedia search term. Only return the word or phrase, nothing else:\n"
|
||||
f"{input}"
|
||||
)
|
||||
try:
|
||||
if useOpenWebUI and openWebUIAPIKey:
|
||||
result = send_openwebui_query(prompt, max_tokens=10)
|
||||
else:
|
||||
llmQuery = {"model": llmModel, "prompt": prompt, "stream": False, "max_tokens": 10}
|
||||
result = send_ollama_query(llmQuery)
|
||||
topic = result.strip().split('\n')[0]
|
||||
topic = topic.strip(' "\'.,!?;:')
|
||||
if topic:
|
||||
return [topic]
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM topic extraction failed: {e}")
|
||||
return []
|
||||
|
||||
def extract_search_terms(input):
|
||||
"""
|
||||
Extract potential search terms from user input.
|
||||
Enhanced: Try LLM-based topic extraction first, fallback to heuristic.
|
||||
:param input: The user query
|
||||
:return: List of potential search terms
|
||||
"""
|
||||
# Remove common command prefixes
|
||||
for trap in trap_list_llm:
|
||||
if input.lower().startswith(trap):
|
||||
input = input[len(trap):].strip()
|
||||
break
|
||||
|
||||
# Try LLM-based extraction first
|
||||
terms = llm_extract_topic(input)
|
||||
if terms:
|
||||
return terms
|
||||
|
||||
# Fallback: Simple heuristic (existing code)
|
||||
words = input.split()
|
||||
search_terms = []
|
||||
temp_phrase = []
|
||||
for word in words:
|
||||
clean_word = word.strip('.,!?;:')
|
||||
if clean_word and clean_word[0].isupper() and len(clean_word) > 2:
|
||||
temp_phrase.append(clean_word)
|
||||
elif temp_phrase:
|
||||
search_terms.append(' '.join(temp_phrase))
|
||||
temp_phrase = []
|
||||
if temp_phrase:
|
||||
search_terms.append(' '.join(temp_phrase))
|
||||
if not search_terms:
|
||||
search_terms = [input.strip()]
|
||||
if DEBUG_LLM:
|
||||
logger.debug(f"Extracted search terms: {search_terms}")
|
||||
return search_terms[:3] # Limit to 3 terms
|
||||
|
||||
def send_openwebui_query(prompt, model=None, max_tokens=450, context=''):
|
||||
"""
|
||||
Send query to OpenWebUI API for chat completion
|
||||
:param prompt: The user prompt
|
||||
:param model: Model name (optional, defaults to llmModel)
|
||||
:param max_tokens: Max tokens for response
|
||||
:param context: Additional context to include
|
||||
:return: Response text or error message
|
||||
"""
|
||||
if model is None:
|
||||
model = llmModel
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {openWebUIAPIKey}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
messages = []
|
||||
if context:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": f"Use the following context to help answer questions:\n{context}"
|
||||
})
|
||||
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
})
|
||||
|
||||
data = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# Debug logging
|
||||
if DEBUG_LLM:
|
||||
logger.debug(f"OpenWebUI payload: {json.dumps(data)}")
|
||||
logger.debug(f"OpenWebUI endpoint: {openWebUIChatAPI}")
|
||||
|
||||
try:
|
||||
result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=urlTimeoutSeconds * 5)
|
||||
if DEBUG_LLM:
|
||||
logger.debug(f"OpenWebUI response status: {result.status_code}")
|
||||
logger.debug(f"OpenWebUI response text: {result.text}")
|
||||
if result.status_code == 200:
|
||||
result_json = result.json()
|
||||
# OpenWebUI returns OpenAI-compatible format
|
||||
if 'choices' in result_json and len(result_json['choices']) > 0:
|
||||
response = result_json['choices'][0]['message']['content']
|
||||
return response.strip()
|
||||
else:
|
||||
logger.warning(f"System: OpenWebUI API returned unexpected format")
|
||||
return "⛔️ Response Error"
|
||||
else:
|
||||
logger.warning(f"System: OpenWebUI API returned status code {result.status_code}")
|
||||
return f"⛔️ Request Error"
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"System: OpenWebUI API request failed: {e}")
|
||||
return f"⛔️ Request Error"
|
||||
|
||||
def send_ollama_query(llmQuery):
|
||||
# Send the query to the Ollama API and return the response
|
||||
try:
|
||||
result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout=5)
|
||||
result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout= urlTimeoutSeconds * 5)
|
||||
if result.status_code == 200:
|
||||
result_json = result.json()
|
||||
result = result_json.get("response", "")
|
||||
@@ -219,24 +300,28 @@ def send_ollama_tooling_query(prompt, functions, model=None, max_tokens=450):
|
||||
else:
|
||||
raise Exception(f"HTTP Error: {result.status_code} - {result.text}")
|
||||
|
||||
def llm_query(input, nodeID=0, location_name=None):
|
||||
def llm_query(input, nodeID=0, location_name=None, init=False):
|
||||
global antiFloodLLM, llmChat_history
|
||||
googleResults = []
|
||||
wikiContext = ''
|
||||
|
||||
# if this is the first initialization of the LLM the query of " " should bring meshbotAIinit OTA shouldnt reach this?
|
||||
# This is for LLM like gemma and others now?
|
||||
if input == " " and rawLLMQuery:
|
||||
if init and rawLLMQuery:
|
||||
logger.warning("System: These LLM models lack a traditional system prompt, they can be verbose and not very helpful be advised.")
|
||||
input = meshbotAIinit
|
||||
else:
|
||||
elif init:
|
||||
input = input.strip()
|
||||
# classic model for gemma2, deepseek-r1, etc
|
||||
logger.debug(f"System: Using classic LLM model framework, ideally for gemma2, deepseek-r1, etc")
|
||||
logger.debug(f"System: Using SYSTEM model framework, ideally for gemma2, deepseek-r1, etc")
|
||||
|
||||
if not location_name:
|
||||
location_name = "no location provided "
|
||||
|
||||
# Remove command bang if present
|
||||
if cmdBang and input.startswith('!'):
|
||||
input = input.strip('!').strip()
|
||||
|
||||
# remove askai: and ask: from the input
|
||||
# Remove any trap words from the start of the input
|
||||
for trap in trap_list_llm:
|
||||
if input.lower().startswith(trap):
|
||||
input = input[len(trap):].strip()
|
||||
@@ -251,34 +336,84 @@ def llm_query(input, nodeID=0, location_name=None):
|
||||
else:
|
||||
antiFloodLLM.append(nodeID)
|
||||
|
||||
if llmContext_fromGoogle and not rawLLMQuery:
|
||||
googleResults = get_google_context(input, googleSearchResults)
|
||||
# Get Wikipedia/Kiwix context if enabled (RAG)
|
||||
if llmUseWikiContext and input != meshbotAIinit:
|
||||
# get_wiki_context returns a string, but we want to count the items before joining
|
||||
search_terms = extract_search_terms(input)
|
||||
wiki_context_list = []
|
||||
for term in search_terms[:2]:
|
||||
if not use_kiwix_server:
|
||||
summary = get_wiki_context(term)
|
||||
else:
|
||||
summary = get_wiki_context(term)
|
||||
if summary and "error" not in summary.lower():
|
||||
wiki_context_list.append(f"Wikipedia context for '{term}': {summary}")
|
||||
wikiContext = '\n'.join(wiki_context_list) if wiki_context_list else ''
|
||||
if wikiContext:
|
||||
logger.debug(f"System: using Wikipedia/Kiwix context for LLM query got {len(wiki_context_list)} results")
|
||||
|
||||
history = llmChat_history.get(nodeID, ["", ""])
|
||||
|
||||
if googleResults:
|
||||
logger.debug(f"System: Google-Enhanced LLM Query: {input} From:{nodeID}")
|
||||
else:
|
||||
logger.debug(f"System: LLM Query: {input} From:{nodeID}")
|
||||
|
||||
response = ""
|
||||
result = ""
|
||||
location_name += f" at the current time of {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}"
|
||||
|
||||
try:
|
||||
if rawLLMQuery:
|
||||
# sanitize the input to remove tool call syntax
|
||||
if '```' in input:
|
||||
logger.warning("System: LLM Query: Code markdown detected, removing for raw query")
|
||||
input = input.replace('```bash', '').replace('```python', '').replace('```', '')
|
||||
modelPrompt = input
|
||||
else:
|
||||
# Build the query from the template
|
||||
modelPrompt = meshBotAI.format(input=input, context='\n'.join(googleResults), location_name=location_name, llmModel=llmModel, history=history)
|
||||
# Use OpenWebUI if enabled
|
||||
if useOpenWebUI and openWebUIAPIKey:
|
||||
logger.debug(f"System: LLM Query: Using OpenWebUI API for LLM query {input} From:{nodeID}")
|
||||
|
||||
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens}
|
||||
# Query the model via Ollama web API
|
||||
result = send_ollama_query(llmQuery)
|
||||
# Combine all context sources
|
||||
combined_context = []
|
||||
if wikiContext:
|
||||
combined_context.append(wikiContext)
|
||||
|
||||
context_str = '\n\n'.join(combined_context)
|
||||
|
||||
# For OpenWebUI, we send a cleaner prompt
|
||||
if rawLLMQuery:
|
||||
result = send_openwebui_query(input, context=context_str, max_tokens=tokens)
|
||||
else:
|
||||
# Use the template for non-raw queries
|
||||
modelPrompt = meshBotAI.format(
|
||||
input=input,
|
||||
context=context_str if combined_context else 'no other context provided',
|
||||
location_name=location_name,
|
||||
llmModel=llmModel,
|
||||
history=history
|
||||
)
|
||||
result = send_openwebui_query(modelPrompt, max_tokens=tokens)
|
||||
else:
|
||||
logger.debug(f"System: LLM Query: Using Ollama API for LLM query {input} From:{nodeID}")
|
||||
# Use standard Ollama API
|
||||
if rawLLMQuery:
|
||||
# sanitize the input to remove tool call syntax
|
||||
if '```' in input:
|
||||
logger.warning("System: LLM Query: Code markdown detected, removing for raw query")
|
||||
input = input.replace('```bash', '').replace('```python', '').replace('```', '')
|
||||
modelPrompt = input
|
||||
|
||||
# Add wiki context to raw queries if available
|
||||
if wikiContext:
|
||||
modelPrompt = f"Context:\n{wikiContext}\n\nQuestion: {input}"
|
||||
else:
|
||||
# Build the query from the template
|
||||
all_context = []
|
||||
if wikiContext:
|
||||
all_context.append(wikiContext)
|
||||
|
||||
context_text = '\n'.join(all_context) if all_context else 'no other context provided'
|
||||
modelPrompt = meshBotAI.format(
|
||||
input=input,
|
||||
context=context_text,
|
||||
location_name=location_name,
|
||||
llmModel=llmModel,
|
||||
history=history
|
||||
)
|
||||
|
||||
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens}
|
||||
# Query the model via Ollama web API
|
||||
result = send_ollama_query(llmQuery)
|
||||
|
||||
#logger.debug(f"System: LLM Response: " + result.strip().replace('\n', ' '))
|
||||
except Exception as e:
|
||||
@@ -290,13 +425,17 @@ def llm_query(input, nodeID=0, location_name=None):
|
||||
response = result.strip().replace('\n', ' ')
|
||||
|
||||
if rawLLMQuery and requestTruncation and len(response) > 450:
|
||||
#retryy loop to truncate the response
|
||||
# retry loop to truncate the response
|
||||
logger.warning(f"System: LLM Query: Response exceeded {tokens} characters, requesting truncation")
|
||||
truncateQuery = {"model": llmModel, "prompt": truncatePrompt + response, "stream": False, "max_tokens": tokens}
|
||||
truncateResult = send_ollama_query(truncateQuery)
|
||||
truncate_prompt_full = truncatePrompt + response
|
||||
if useOpenWebUI and openWebUIAPIKey:
|
||||
truncateResult = send_openwebui_query(truncate_prompt_full, max_tokens=tokens)
|
||||
else:
|
||||
truncateQuery = {"model": llmModel, "prompt": truncate_prompt_full, "stream": False, "max_tokens": tokens}
|
||||
truncateResult = send_ollama_query(truncateQuery)
|
||||
|
||||
# cleanup for message output
|
||||
response = result.strip().replace('\n', ' ')
|
||||
response = truncateResult.strip().replace('\n', ' ')
|
||||
|
||||
# done with the query, remove the user from the anti flood list
|
||||
antiFloodLLM.remove(nodeID)
|
||||
|
||||
@@ -420,10 +420,37 @@ def getWeatherAlertsNOAA(lat=0, lon=0, useDefaultLatLon=False):
|
||||
for i in alertxml.getElementsByTagName("entry"):
|
||||
title = i.getElementsByTagName("title")[0].childNodes[0].nodeValue
|
||||
area_desc = i.getElementsByTagName("cap:areaDesc")[0].childNodes[0].nodeValue
|
||||
if my_settings.enableExtraLocationWx:
|
||||
alerts += f"{title}. {area_desc.replace(' ', '')}\n"
|
||||
|
||||
# Extract NWSheadline from cap:parameter if present
|
||||
nws_headline = ""
|
||||
for param in i.getElementsByTagName("cap:parameter"):
|
||||
try:
|
||||
value_name = param.getElementsByTagName("valueName")[0].childNodes[0].nodeValue
|
||||
if value_name == "NWSheadline":
|
||||
nws_headline = param.getElementsByTagName("value")[0].childNodes[0].nodeValue
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# If title is "Special Weather Statement" and headline exists, use headline only
|
||||
if "special" in title.lower() and nws_headline:
|
||||
main_alert = nws_headline
|
||||
else:
|
||||
alerts += f"{title}\n"
|
||||
main_alert = title
|
||||
|
||||
if my_settings.enableExtraLocationWx:
|
||||
# adds location data which is too much data?
|
||||
alerts += f"{main_alert}. {area_desc.replace(' ', '')}"
|
||||
# Only add headline if not already used as main_alert
|
||||
# if nws_headline and main_alert != nws_headline:
|
||||
# alerts += f" ALERT: {nws_headline}"
|
||||
alerts += "\n"
|
||||
else:
|
||||
alerts += f"{main_alert}"
|
||||
# Only add headline if not already used as main_alert
|
||||
# if nws_headline and main_alert != nws_headline:
|
||||
# alerts += f" ALERT: {nws_headline}"
|
||||
alerts += "\n"
|
||||
|
||||
if alerts == "" or alerts == None:
|
||||
return my_settings.NO_ALERTS
|
||||
@@ -462,7 +489,6 @@ def alertBrodcastNOAA():
|
||||
# broadcast the alerts send to wxBrodcastCh
|
||||
elif currentAlert[0] not in wxAlertCacheNOAA:
|
||||
# Check if the current alert is not in the weather alert cache
|
||||
logger.debug("Location:Broadcasting weather alerts")
|
||||
wxAlertCacheNOAA = currentAlert[0]
|
||||
return currentAlert
|
||||
|
||||
@@ -1005,38 +1031,42 @@ def distance(lat=0,lon=0,nodeID=0, reset=False):
|
||||
|
||||
return msg
|
||||
|
||||
def get_openskynetwork(lat=0, lon=0):
|
||||
# get the latest aircraft data from OpenSky Network in the area
|
||||
def get_openskynetwork(lat=0, lon=0, altitude=0, node_altitude=0, altitude_window=1000):
|
||||
"""
|
||||
Returns the aircraft dict from OpenSky Network closest in altitude (within altitude_window meters)
|
||||
to the given node_altitude. If no aircraft found, returns my_settings.NO_ALERTS.
|
||||
"""
|
||||
if lat == 0 and lon == 0:
|
||||
return my_settings.NO_ALERTS
|
||||
# setup a bounding box of 50km around the lat/lon
|
||||
box_size = 0.45 # approx 50km
|
||||
# return limits for aircraft search
|
||||
search_limit = 3
|
||||
return False
|
||||
|
||||
box_size = 0.45 # approx 50km
|
||||
lamin = lat - box_size
|
||||
lamax = lat + box_size
|
||||
lomin = lon - box_size
|
||||
lomax = lon + box_size
|
||||
|
||||
# fetch the aircraft data from OpenSky Network
|
||||
opensky_url = f"https://opensky-network.org/api/states/all?lamin={lamin}&lomin={lomin}&lamax={lamax}&lomax={lomax}"
|
||||
opensky_url = (
|
||||
f"https://opensky-network.org/api/states/all?lamin={lamin}&lomin={lomin}"
|
||||
f"&lamax={lamax}&lomax={lomax}"
|
||||
)
|
||||
try:
|
||||
aircraft_data = requests.get(opensky_url, timeout=my_settings.urlTimeoutSeconds)
|
||||
if not aircraft_data.ok:
|
||||
logger.warning("Location:Error fetching aircraft data from OpenSky Network")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
return False
|
||||
except (requests.exceptions.RequestException):
|
||||
logger.warning("Location:Error fetching aircraft data from OpenSky Network")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
return False
|
||||
|
||||
aircraft_json = aircraft_data.json()
|
||||
if 'states' not in aircraft_json or not aircraft_json['states']:
|
||||
return my_settings.NO_ALERTS
|
||||
return False
|
||||
|
||||
aircraft_list = aircraft_json['states']
|
||||
aircraft_report = ""
|
||||
logger.debug(f"Location: OpenSky Network: Found {len(aircraft_list)} possible aircraft in area")
|
||||
closest = None
|
||||
min_diff = float('inf')
|
||||
for aircraft in aircraft_list:
|
||||
if len(aircraft_report.split("\n")) >= search_limit:
|
||||
break
|
||||
# extract values from JSON
|
||||
try:
|
||||
callsign = aircraft[1].strip() if aircraft[1] else "N/A"
|
||||
origin_country = aircraft[2]
|
||||
@@ -1044,20 +1074,37 @@ def get_openskynetwork(lat=0, lon=0):
|
||||
true_track = aircraft[10]
|
||||
vertical_rate = aircraft[11]
|
||||
sensors = aircraft[12]
|
||||
baro_altitude = aircraft[7]
|
||||
geo_altitude = aircraft[13]
|
||||
squawk = aircraft[14] if len(aircraft) > 14 else "N/A"
|
||||
except Exception as e:
|
||||
logger.debug("Location:Error extracting aircraft data from OpenSky Network")
|
||||
continue
|
||||
|
||||
# format the aircraft data
|
||||
aircraft_report += f"{callsign} Alt:{int(geo_altitude) if geo_altitude else 'N/A'}m Vel:{int(velocity) if velocity else 'N/A'}m/s Heading:{int(true_track) if true_track else 'N/A'}°\n"
|
||||
|
||||
# remove last newline
|
||||
if aircraft_report.endswith("\n"):
|
||||
aircraft_report = aircraft_report[:-1]
|
||||
aircraft_report = abbreviate_noaa(aircraft_report)
|
||||
return aircraft_report if aircraft_report else my_settings.NO_ALERTS
|
||||
|
||||
# Prefer geo_altitude, fallback to baro_altitude
|
||||
plane_alt = geo_altitude if geo_altitude is not None else baro_altitude
|
||||
if plane_alt is None or node_altitude == 0:
|
||||
continue
|
||||
|
||||
diff = abs(plane_alt - node_altitude)
|
||||
if diff <= altitude_window and diff < min_diff:
|
||||
min_diff = diff
|
||||
closest = {
|
||||
"callsign": callsign,
|
||||
"origin_country": origin_country,
|
||||
"velocity": velocity,
|
||||
"true_track": true_track,
|
||||
"vertical_rate": vertical_rate,
|
||||
"sensors": sensors,
|
||||
"altitude": baro_altitude,
|
||||
"geo_altitude": geo_altitude,
|
||||
"squawk": squawk,
|
||||
}
|
||||
|
||||
if closest:
|
||||
return closest
|
||||
else:
|
||||
return False
|
||||
|
||||
def log_locationData_toMap(userID, location, message):
|
||||
"""
|
||||
|
||||
55
modules/radio.md
Normal file
55
modules/radio.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Radio Module: Meshages TTS (Text-to-Speech) Setup
|
||||
|
||||
The radio module supports audible mesh messages using the [KittenTTS](https://github.com/KittenML/KittenTTS) engine. This allows the bot to generate and play speech from text, making mesh alerts and messages audible on your device.
|
||||
|
||||
## Features
|
||||
|
||||
- Converts mesh messages to speech using KittenTTS.
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Install Python dependencies:**
|
||||
|
||||
- `kittentts` is the TTS engine.
|
||||
|
||||
`pip install https://github.com/KittenML/KittenTTS/releases/download/0.1/kittentts-0.1.0-py3-none-any.whl`
|
||||
|
||||
2. **Install PortAudio (required for sounddevice):**
|
||||
|
||||
- **macOS:**
|
||||
```sh
|
||||
brew install portaudio
|
||||
```
|
||||
- **Linux (Debian/Ubuntu):**
|
||||
```sh
|
||||
sudo apt-get install portaudio19-dev
|
||||
```
|
||||
- **Windows:**
|
||||
No extra step needed; `sounddevice` will use the default audio driver.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Enable TTS in your `config.ini`:
|
||||
```ini
|
||||
[radioMon]
|
||||
meshagesTTS = True
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
When enabled, the bot will generate and play speech for mesh messages using the selected voice.
|
||||
No additional user action is required.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If you see errors about missing `sounddevice` or `portaudio`, ensure you have installed the dependencies above.
|
||||
- On macOS, you may need to allow microphone/audio access for your terminal.
|
||||
- If you have audio issues, check your system’s default output device.
|
||||
|
||||
## References
|
||||
|
||||
- [KittenTTS GitHub](https://github.com/KittenML/KittenTTS)
|
||||
- [KittenTTS Model on HuggingFace](https://huggingface.co/KittenML/kitten-tts-nano-0.2)
|
||||
- [sounddevice documentation](https://python-sounddevice.readthedocs.io/)
|
||||
|
||||
---
|
||||
488
modules/radio.py
488
modules/radio.py
@@ -3,10 +3,22 @@
|
||||
# depends on rigctld running externally as a network service
|
||||
# also can use VOX detection with a microphone and vosk speech to text to send voice messages to mesh network
|
||||
# requires vosk and sounddevice python modules. will auto download needed. more from https://alphacephei.com/vosk/models and unpack
|
||||
# 2024 Kelly Keeton K7MHI
|
||||
# 2025 Kelly Keeton K7MHI
|
||||
|
||||
# WSJT-X and JS8Call UDP Monitoring
|
||||
# Based on WSJT-X UDP protocol specification
|
||||
# Reference: https://github.com/ckuhtz/ham/blob/main/mcast/recv_decode.py
|
||||
|
||||
|
||||
from modules.log import logger
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
import json
|
||||
from modules.log import logger
|
||||
|
||||
# verbose debug logging for trap words function
|
||||
debugVoxTmsg = False
|
||||
|
||||
from modules.settings import (
|
||||
radio_detection_enabled,
|
||||
rigControlServerAddress,
|
||||
@@ -22,50 +34,13 @@ from modules.settings import (
|
||||
voxTrapList,
|
||||
voxOnTrapList,
|
||||
voxEnableCmd,
|
||||
ERROR_FETCHING_DATA
|
||||
ERROR_FETCHING_DATA,
|
||||
meshagesTTS,
|
||||
)
|
||||
|
||||
# verbose debug logging for trap words function
|
||||
debugVoxTmsg = False
|
||||
|
||||
|
||||
if radio_detection_enabled:
|
||||
# used by hamlib detection
|
||||
import socket
|
||||
|
||||
if voxDetectionEnabled:
|
||||
# methods available for trap word processing, these can be called by VOX detection when trap words are detected
|
||||
from mesh_bot import tell_joke, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass
|
||||
botMethods = {
|
||||
"joke": tell_joke,
|
||||
"weather": handle_wxc,
|
||||
"moon": handle_moon,
|
||||
"daylight": handle_sun,
|
||||
"river": handle_riverFlow,
|
||||
"tide": handle_tide,
|
||||
"satellite": handle_satpass}
|
||||
# module global variables
|
||||
previousVoxState = False
|
||||
voxHoldTime = signalHoldTime
|
||||
|
||||
try:
|
||||
import sounddevice as sd # pip install sounddevice sudo apt install portaudio19-dev
|
||||
from vosk import Model, KaldiRecognizer # pip install vosk
|
||||
import json
|
||||
q = asyncio.Queue(maxsize=32) # queue for audio data
|
||||
|
||||
if useLocalVoxModel:
|
||||
voxModel = Model(lang=localVoxModelPath) # use built in model for specified language
|
||||
else:
|
||||
voxModel = Model(lang=voxLanguage) # use built in model for specified language
|
||||
|
||||
except Exception as e:
|
||||
print(f"RadioMon: Error importing VOX dependencies: {e}")
|
||||
print(f"To use VOX detection please install the vosk and sounddevice python modules")
|
||||
print(f"pip install vosk sounddevice")
|
||||
print(f"sounddevice needs pulseaudio, apt-get install portaudio19-dev")
|
||||
voxDetectionEnabled = False
|
||||
logger.error(f"RadioMon: VOX detection disabled due to import error")
|
||||
# module global variables
|
||||
previousStrength = -40
|
||||
signalCycle = 0
|
||||
|
||||
FREQ_NAME_MAP = {
|
||||
462562500: "GRMS CH1",
|
||||
@@ -106,6 +81,140 @@ FREQ_NAME_MAP = {
|
||||
# Add more as needed
|
||||
}
|
||||
|
||||
# --- WSJT-X and JS8Call Settings Initialization ---
|
||||
wsjtxMsgQueue = [] # Queue for WSJT-X detected messages
|
||||
js8callMsgQueue = [] # Queue for JS8Call detected messages
|
||||
wsjtx_enabled = False
|
||||
js8call_enabled = False
|
||||
wsjtx_udp_port = 2237
|
||||
js8call_udp_port = 2442
|
||||
watched_callsigns = []
|
||||
wsjtx_udp_address = '127.0.0.1'
|
||||
js8call_tcp_address = '127.0.0.1'
|
||||
js8call_tcp_port = 2442
|
||||
# WSJT-X UDP Protocol Message Types
|
||||
WSJTX_HEARTBEAT = 0
|
||||
WSJTX_STATUS = 1
|
||||
WSJTX_DECODE = 2
|
||||
WSJTX_CLEAR = 3
|
||||
WSJTX_REPLY = 4
|
||||
WSJTX_QSO_LOGGED = 5
|
||||
WSJTX_CLOSE = 6
|
||||
WSJTX_REPLAY = 7
|
||||
WSJTX_HALT_TX = 8
|
||||
WSJTX_FREE_TEXT = 9
|
||||
WSJTX_WSPR_DECODE = 10
|
||||
WSJTX_LOCATION = 11
|
||||
WSJTX_LOGGED_ADIF = 12
|
||||
|
||||
|
||||
try:
|
||||
from modules.settings import (
|
||||
wsjtx_detection_enabled,
|
||||
wsjtx_udp_server_address,
|
||||
wsjtx_watched_callsigns,
|
||||
js8call_detection_enabled,
|
||||
js8call_server_address,
|
||||
js8call_watched_callsigns
|
||||
)
|
||||
wsjtx_enabled = wsjtx_detection_enabled
|
||||
js8call_enabled = js8call_detection_enabled
|
||||
|
||||
# Use a local list to collect callsigns before assigning to watched_callsigns
|
||||
callsigns = []
|
||||
|
||||
if wsjtx_enabled:
|
||||
if ':' in wsjtx_udp_server_address:
|
||||
wsjtx_udp_address, port_str = wsjtx_udp_server_address.split(':')
|
||||
wsjtx_udp_port = int(port_str)
|
||||
if wsjtx_watched_callsigns:
|
||||
callsigns.extend([cs.strip() for cs in wsjtx_watched_callsigns.split(',') if cs.strip()])
|
||||
|
||||
if js8call_enabled:
|
||||
if ':' in js8call_server_address:
|
||||
js8call_tcp_address, port_str = js8call_server_address.split(':')
|
||||
js8call_tcp_port = int(port_str)
|
||||
if js8call_watched_callsigns:
|
||||
callsigns.extend([cs.strip() for cs in js8call_watched_callsigns.split(',') if cs.strip()])
|
||||
|
||||
# Clean up and deduplicate callsigns, uppercase for matching
|
||||
watched_callsigns = list({cs.upper() for cs in callsigns})
|
||||
|
||||
except ImportError:
|
||||
logger.debug("System: RadioMon: WSJT-X/JS8Call settings not configured")
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error loading WSJT-X/JS8Call settings: {e}")
|
||||
|
||||
|
||||
if radio_detection_enabled:
|
||||
# used by hamlib detection
|
||||
import socket
|
||||
|
||||
if voxDetectionEnabled:
|
||||
# methods available for trap word processing, these can be called by VOX detection when trap words are detected
|
||||
from mesh_bot import tell_joke, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass
|
||||
botMethods = {
|
||||
"joke": tell_joke,
|
||||
"weather": handle_wxc,
|
||||
"moon": handle_moon,
|
||||
"daylight": handle_sun,
|
||||
"river": handle_riverFlow,
|
||||
"tide": handle_tide,
|
||||
"satellite": handle_satpass}
|
||||
# module global variables
|
||||
previousVoxState = False
|
||||
voxHoldTime = signalHoldTime
|
||||
|
||||
try:
|
||||
import sounddevice as sd # pip install sounddevice sudo apt install portaudio19-dev
|
||||
from vosk import Model, KaldiRecognizer # pip install vosk
|
||||
import json
|
||||
q = asyncio.Queue(maxsize=32) # queue for audio data
|
||||
|
||||
if useLocalVoxModel:
|
||||
voxModel = Model(lang=localVoxModelPath) # use built in model for specified language
|
||||
else:
|
||||
voxModel = Model(lang=voxLanguage) # use built in model for specified language
|
||||
|
||||
except Exception as e:
|
||||
print(f"System: RadioMon: Error importing VOX dependencies: {e}")
|
||||
print(f"To use VOX detection please install the vosk and sounddevice python modules")
|
||||
print(f"pip install vosk sounddevice")
|
||||
print(f"sounddevice needs pulseaudio, apt-get install portaudio19-dev")
|
||||
voxDetectionEnabled = False
|
||||
logger.error(f"System: RadioMon: VOX detection disabled due to import error")
|
||||
|
||||
if meshagesTTS:
|
||||
try:
|
||||
# TTS for meshages imports
|
||||
logger.debug("System: RadioMon: Initializing TTS model for audible meshages")
|
||||
import sounddevice as sd
|
||||
from kittentts import KittenTTS
|
||||
ttsModel = KittenTTS("KittenML/kitten-tts-nano-0.2")
|
||||
available_voices = [
|
||||
'expr-voice-2-m', 'expr-voice-2-f', 'expr-voice-3-m', 'expr-voice-3-f',
|
||||
'expr-voice-4-m', 'expr-voice-4-f', 'expr-voice-5-m', 'expr-voice-5-f'
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"To use Meshages TTS please review the radio.md documentation for setup instructions.")
|
||||
meshagesTTS = False
|
||||
|
||||
async def generate_and_play_tts(text, voice, samplerate=24000):
|
||||
"""Async: Generate speech and play audio."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
logger.debug(f"System: RadioMon: Generating TTS for text: {text} with voice: {voice}")
|
||||
audio = await asyncio.to_thread(ttsModel.generate, text, voice=voice)
|
||||
if audio is None or len(audio) == 0:
|
||||
return
|
||||
await asyncio.to_thread(sd.play, audio, samplerate)
|
||||
await asyncio.to_thread(sd.wait)
|
||||
del audio
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error in generate_and_play_tts: {e}")
|
||||
|
||||
def get_freq_common_name(freq):
|
||||
freq = int(freq)
|
||||
name = FREQ_NAME_MAP.get(freq)
|
||||
@@ -118,14 +227,14 @@ def get_freq_common_name(freq):
|
||||
def get_hamlib(msg="f"):
|
||||
# get data from rigctld server
|
||||
if "socket" not in globals():
|
||||
logger.warning("RadioMon: 'socket' module not imported. Hamlib disabled.")
|
||||
logger.warning("System: RadioMon: 'socket' module not imported. Hamlib disabled.")
|
||||
return ERROR_FETCHING_DATA
|
||||
try:
|
||||
rigControlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
rigControlSocket.settimeout(2)
|
||||
rigControlSocket.connect((rigControlServerAddress.split(":")[0],int(rigControlServerAddress.split(":")[1])))
|
||||
except Exception as e:
|
||||
logger.error(f"RadioMon: Error connecting to rigctld: {e}")
|
||||
logger.error(f"System: RadioMon: Error connecting to rigctld: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
try:
|
||||
@@ -139,7 +248,7 @@ def get_hamlib(msg="f"):
|
||||
data = data.replace(b'\n',b'')
|
||||
return data.decode("utf-8").rstrip()
|
||||
except Exception as e:
|
||||
logger.error(f"RadioMon: Error fetching data from rigctld: {e}")
|
||||
logger.error(f"System: RadioMon: Error fetching data from rigctld: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_sig_strength():
|
||||
@@ -149,7 +258,7 @@ def get_sig_strength():
|
||||
def checkVoxTrapWords(text):
|
||||
try:
|
||||
if not voxOnTrapList:
|
||||
logger.debug(f"RadioMon: VOX detected: {text}")
|
||||
logger.debug(f"System: RadioMon: VOX detected: {text}")
|
||||
return text
|
||||
if text:
|
||||
traps = [voxTrapList] if isinstance(voxTrapList, str) else voxTrapList
|
||||
@@ -159,27 +268,27 @@ def checkVoxTrapWords(text):
|
||||
trap_lower = trap_clean.lower()
|
||||
idx = text_lower.find(trap_lower)
|
||||
if debugVoxTmsg:
|
||||
logger.debug(f"RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})")
|
||||
logger.debug(f"System: RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})")
|
||||
if idx != -1:
|
||||
new_text = text[idx + len(trap_clean):].strip()
|
||||
if debugVoxTmsg:
|
||||
logger.debug(f"RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')")
|
||||
logger.debug(f"System: RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')")
|
||||
new_words = new_text.split()
|
||||
if voxEnableCmd:
|
||||
for word in new_words:
|
||||
if word in botMethods:
|
||||
logger.info(f"RadioMon: VOX action '{word}' with '{new_text}'")
|
||||
logger.info(f"System: RadioMon: VOX action '{word}' with '{new_text}'")
|
||||
if word == "joke":
|
||||
return botMethods[word](vox=True)
|
||||
else:
|
||||
return botMethods[word](None, None, None, vox=True)
|
||||
logger.debug(f"RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'")
|
||||
logger.debug(f"System: RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'")
|
||||
return new_text
|
||||
if debugVoxTmsg:
|
||||
logger.debug(f"RadioMon: VOX no trap word found in: '{text}'")
|
||||
logger.debug(f"System: RadioMon: VOX no trap word found in: '{text}'")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"RadioMon: Error in checkVoxTrapWords: {e}")
|
||||
logger.debug(f"System: RadioMon: Error in checkVoxTrapWords: {e}")
|
||||
return None
|
||||
|
||||
async def signalWatcher():
|
||||
@@ -189,7 +298,7 @@ async def signalWatcher():
|
||||
signalStrength = int(get_sig_strength())
|
||||
if signalStrength >= previousStrength and signalStrength > signalDetectionThreshold:
|
||||
message = f"Detected {get_freq_common_name(get_hamlib('f'))} active. S-Meter:{signalStrength}dBm"
|
||||
logger.debug(f"RadioMon: {message}. Waiting for {signalHoldTime} seconds")
|
||||
logger.debug(f"System: RadioMon: {message}. Waiting for {signalHoldTime} seconds")
|
||||
previousStrength = signalStrength
|
||||
signalCycle = 0
|
||||
await asyncio.sleep(signalHoldTime)
|
||||
@@ -209,7 +318,7 @@ async def signalWatcher():
|
||||
async def make_vox_callback(loop, q):
|
||||
def vox_callback(indata, frames, time, status):
|
||||
if status:
|
||||
logger.warning(f"RadioMon: VOX input status: {status}")
|
||||
logger.warning(f"System: RadioMon: VOX input status: {status}")
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
|
||||
except asyncio.QueueFull:
|
||||
@@ -222,7 +331,7 @@ async def make_vox_callback(loop, q):
|
||||
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
|
||||
except asyncio.QueueFull:
|
||||
# If still full, just drop this frame
|
||||
logger.debug("RadioMon: VOX queue full, dropping audio frame")
|
||||
logger.debug("System: RadioMon: VOX queue full, dropping audio frame")
|
||||
except RuntimeError:
|
||||
# Loop may be closed
|
||||
pass
|
||||
@@ -234,7 +343,7 @@ async def voxMonitor():
|
||||
model = voxModel
|
||||
device_info = sd.query_devices(voxInputDevice, 'input')
|
||||
samplerate = 16000
|
||||
logger.debug(f"RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}")
|
||||
logger.debug(f"System: RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}")
|
||||
rec = KaldiRecognizer(model, samplerate)
|
||||
loop = asyncio.get_running_loop()
|
||||
callback = await make_vox_callback(loop, q)
|
||||
@@ -261,6 +370,267 @@ async def voxMonitor():
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"RadioMon: Error in VOX monitor: {e}")
|
||||
logger.warning(f"System: RadioMon: Error in VOX monitor: {e}")
|
||||
|
||||
def decode_wsjtx_packet(data):
|
||||
"""Decode WSJT-X UDP packet according to the protocol specification"""
|
||||
try:
|
||||
# WSJT-X uses Qt's QDataStream format (big-endian)
|
||||
magic = struct.unpack('>I', data[0:4])[0]
|
||||
if magic != 0xADBCCBDA:
|
||||
return None
|
||||
|
||||
schema_version = struct.unpack('>I', data[4:8])[0]
|
||||
msg_type = struct.unpack('>I', data[8:12])[0]
|
||||
|
||||
offset = 12
|
||||
|
||||
# Helper to read Qt QString (4-byte length + UTF-8 data)
|
||||
def read_qstring(data, offset):
|
||||
if offset + 4 > len(data):
|
||||
return "", offset
|
||||
length = struct.unpack('>I', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
if length == 0xFFFFFFFF: # Null string
|
||||
return "", offset
|
||||
if offset + length > len(data):
|
||||
return "", offset
|
||||
text = data[offset:offset+length].decode('utf-8', errors='ignore')
|
||||
return text, offset + length
|
||||
|
||||
# Decode DECODE message (type 2)
|
||||
if msg_type == WSJTX_DECODE:
|
||||
# Read fields according to WSJT-X protocol
|
||||
wsjtx_id, offset = read_qstring(data, offset)
|
||||
|
||||
# Read other decode fields: new, time, snr, delta_time, delta_frequency, mode, message
|
||||
if offset + 1 > len(data):
|
||||
return None
|
||||
new = struct.unpack('>?', data[offset:offset+1])[0]
|
||||
offset += 1
|
||||
|
||||
if offset + 4 > len(data):
|
||||
return None
|
||||
time_val = struct.unpack('>I', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
if offset + 4 > len(data):
|
||||
return None
|
||||
snr = struct.unpack('>i', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
if offset + 8 > len(data):
|
||||
return None
|
||||
delta_time = struct.unpack('>d', data[offset:offset+8])[0]
|
||||
offset += 8
|
||||
|
||||
if offset + 4 > len(data):
|
||||
return None
|
||||
delta_frequency = struct.unpack('>I', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
mode, offset = read_qstring(data, offset)
|
||||
message, offset = read_qstring(data, offset)
|
||||
|
||||
return {
|
||||
'type': 'decode',
|
||||
'id': wsjtx_id,
|
||||
'new': new,
|
||||
'time': time_val,
|
||||
'snr': snr,
|
||||
'delta_time': delta_time,
|
||||
'delta_frequency': delta_frequency,
|
||||
'mode': mode,
|
||||
'message': message
|
||||
}
|
||||
|
||||
# Decode QSO_LOGGED message (type 5)
|
||||
elif msg_type == WSJTX_QSO_LOGGED:
|
||||
wsjtx_id, offset = read_qstring(data, offset)
|
||||
|
||||
# Read QSO logged fields
|
||||
if offset + 8 > len(data):
|
||||
return None
|
||||
date_off = struct.unpack('>Q', data[offset:offset+8])[0]
|
||||
offset += 8
|
||||
|
||||
if offset + 8 > len(data):
|
||||
return None
|
||||
time_off = struct.unpack('>Q', data[offset:offset+8])[0]
|
||||
offset += 8
|
||||
|
||||
dx_call, offset = read_qstring(data, offset)
|
||||
dx_grid, offset = read_qstring(data, offset)
|
||||
|
||||
return {
|
||||
'type': 'qso_logged',
|
||||
'id': wsjtx_id,
|
||||
'dx_call': dx_call,
|
||||
'dx_grid': dx_grid
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error decoding WSJT-X packet: {e}")
|
||||
return None
|
||||
|
||||
def check_callsign_match(message, callsigns):
|
||||
"""Check if any watched callsign appears in the message
|
||||
|
||||
Uses word boundary matching to avoid false positives like matching
|
||||
'K7' when looking for 'K7MHI'. Callsigns are expected to be
|
||||
separated by spaces or be at the start/end of the message.
|
||||
"""
|
||||
if not callsigns:
|
||||
return True # If no filter, accept all
|
||||
|
||||
message_upper = message.upper()
|
||||
# Split message into words for exact matching
|
||||
words = message_upper.split()
|
||||
|
||||
for callsign in callsigns:
|
||||
callsign_upper = callsign.upper()
|
||||
# Pre-compute patterns for portable/mobile suffixes
|
||||
callsign_with_slash = callsign_upper + '/'
|
||||
callsign_with_dash = callsign_upper + '-'
|
||||
slash_callsign = '/' + callsign_upper
|
||||
dash_callsign = '-' + callsign_upper
|
||||
|
||||
# Check if callsign appears as a complete word
|
||||
if callsign_upper in words:
|
||||
return True
|
||||
|
||||
# Check for callsigns in compound forms like "K7MHI/P" or "K7MHI-7"
|
||||
for word in words:
|
||||
if (word.startswith(callsign_with_slash) or
|
||||
word.startswith(callsign_with_dash) or
|
||||
word.endswith(slash_callsign) or
|
||||
word.endswith(dash_callsign)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def wsjtxMonitor():
|
||||
"""Monitor WSJT-X UDP broadcasts for decode messages"""
|
||||
if not wsjtx_enabled:
|
||||
logger.warning("System: RadioMon: WSJT-X monitoring called but not enabled")
|
||||
return
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((wsjtx_udp_address, wsjtx_udp_port))
|
||||
sock.setblocking(False)
|
||||
|
||||
logger.info(f"System: RadioMon: WSJT-X UDP listener started on {wsjtx_udp_address}:{wsjtx_udp_port}")
|
||||
if watched_callsigns:
|
||||
logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
data, addr = sock.recvfrom(4096)
|
||||
decoded = decode_wsjtx_packet(data)
|
||||
|
||||
if decoded and decoded['type'] == 'decode':
|
||||
message = decoded['message']
|
||||
mode = decoded['mode']
|
||||
snr = decoded['snr']
|
||||
|
||||
# Check if message contains watched callsigns
|
||||
if check_callsign_match(message, watched_callsigns):
|
||||
msg_text = f"WSJT-X {mode}: {message} (SNR: {snr:+d}dB)"
|
||||
logger.info(f"System: RadioMon: {msg_text}")
|
||||
wsjtxMsgQueue.append(msg_text)
|
||||
|
||||
except BlockingIOError:
|
||||
# No data available
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error in WSJT-X monitor loop: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error starting WSJT-X monitor: {e}")
|
||||
|
||||
async def js8callMonitor():
|
||||
"""Monitor JS8Call TCP API for messages"""
|
||||
if not js8call_enabled:
|
||||
logger.warning("System: RadioMon: JS8Call monitoring called but not enabled")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(f"System: RadioMon: JS8Call TCP listener connecting to {js8call_tcp_address}:{js8call_tcp_port}")
|
||||
if watched_callsigns:
|
||||
logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Connect to JS8Call TCP API
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((js8call_tcp_address, js8call_tcp_port))
|
||||
sock.setblocking(False)
|
||||
|
||||
logger.info("System: RadioMon: Connected to JS8Call API")
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
logger.warning("System: RadioMon: JS8Call connection closed")
|
||||
break
|
||||
|
||||
buffer += data.decode('utf-8', errors='ignore')
|
||||
|
||||
# Process complete JSON messages (newline delimited)
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
msg_type = msg.get('type', '')
|
||||
|
||||
# Handle RX.DIRECTED and RX.ACTIVITY messages
|
||||
if msg_type in ['RX.DIRECTED', 'RX.ACTIVITY']:
|
||||
params = msg.get('params', {})
|
||||
text = params.get('TEXT', '')
|
||||
from_call = params.get('FROM', '')
|
||||
snr = params.get('SNR', 0)
|
||||
|
||||
if text and check_callsign_match(text, watched_callsigns):
|
||||
msg_text = f"JS8Call from {from_call}: {text} (SNR: {snr:+d}dB)"
|
||||
logger.info(f"System: RadioMon: {msg_text}")
|
||||
js8callMsgQueue.append(msg_text)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"System: RadioMon: Invalid JSON from JS8Call: {line[:100]}")
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error processing JS8Call message: {e}")
|
||||
|
||||
except BlockingIOError:
|
||||
await asyncio.sleep(0.1)
|
||||
except socket.timeout:
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error in JS8Call receive loop: {e}")
|
||||
break
|
||||
|
||||
sock.close()
|
||||
logger.warning("System: RadioMon: JS8Call connection lost, reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except socket.timeout:
|
||||
logger.warning("System: RadioMon: JS8Call connection timeout, retrying in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error connecting to JS8Call: {e}")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error starting JS8Call monitor: {e}")
|
||||
|
||||
# end of file
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# rss feed module for meshing-around 2025
|
||||
from modules.log import logger
|
||||
from modules.settings import rssFeedURL, rssFeedNames, rssMaxItems, rssTruncate, urlTimeoutSeconds, ERROR_FETCHING_DATA
|
||||
from modules.settings import rssFeedURL, rssFeedNames, rssMaxItems, rssTruncate, urlTimeoutSeconds, ERROR_FETCHING_DATA, newsAPI_KEY, newsAPIsort
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import bs4 as bs
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Common User-Agent for all RSS requests
|
||||
COMMON_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
|
||||
@@ -77,6 +79,7 @@ def get_rss_feed(msg):
|
||||
return "No RSS or Atom feed entries found."
|
||||
|
||||
formatted_entries = []
|
||||
seen_first3 = set() # Track first 3 words (lowercased) to avoid duplicates
|
||||
for item in items:
|
||||
# Helper to try multiple tag names
|
||||
def find_any(item, tags):
|
||||
@@ -122,9 +125,60 @@ def get_rss_feed(msg):
|
||||
if len(description) > RSS_TRIM_LENGTH:
|
||||
description = description[:RSS_TRIM_LENGTH - 3] + "..."
|
||||
|
||||
# Duplicate check: use first 3 words of description (or title if description is empty)
|
||||
text_for_dupe = description if description else (title or "")
|
||||
first3 = " ".join(text_for_dupe.lower().split()[:3])
|
||||
if first3 in seen_first3:
|
||||
continue
|
||||
seen_first3.add(first3)
|
||||
|
||||
formatted_entries.append(f"{title}\n{description}\n")
|
||||
return "\n".join(formatted_entries)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching RSS feed from {feed_url}: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
|
||||
def get_newsAPI(user_search="meshtastic", message_from_id=None, deviceID=None, isDM=False):
|
||||
# Fetch news from NewsAPI.org
|
||||
user_search = user_search.strip()
|
||||
# check api_throttle
|
||||
from modules.system import api_throttle
|
||||
check_throttle = api_throttle(message_from_id, deviceID, apiName="NewsAPI")
|
||||
if check_throttle:
|
||||
return check_throttle # Return throttle message if applicable
|
||||
|
||||
if user_search.lower().startswith("latest"):
|
||||
user_search = user_search[6:].strip()
|
||||
if not user_search:
|
||||
user_search = "meshtastic"
|
||||
try:
|
||||
last_week = datetime.now() - timedelta(days=7)
|
||||
newsAPIurl = (
|
||||
f"https://newsapi.org/v2/everything?"
|
||||
f"q={user_search}&language=en&from={last_week.strftime('%Y-%m-%d')}&sortBy={newsAPIsort}shedAt&pageSize=5&apiKey={newsAPI_KEY}"
|
||||
)
|
||||
|
||||
response = requests.get(newsAPIurl, headers={"User-Agent": COMMON_USER_AGENT}, timeout=urlTimeoutSeconds)
|
||||
news_data = response.json()
|
||||
|
||||
if news_data.get("status") != "ok":
|
||||
error_message = news_data.get("message", "Unknown error")
|
||||
logger.error(f"NewsAPI error: {error_message}")
|
||||
return ERROR_FETCHING_DATA
|
||||
logger.debug(f"System: NewsAPI Searching for '{user_search}' got {news_data.get('totalResults', 0)} results")
|
||||
articles = news_data.get("articles", [])[:3]
|
||||
news_list = []
|
||||
for article in articles:
|
||||
title = article.get("title", "No Title")
|
||||
url = article.get("url", "")
|
||||
description = article.get("description", '')
|
||||
news_list.append(f"📰{title}\n{description}")
|
||||
|
||||
# Make a nice newspaper style output
|
||||
msg = f"🗞️:"
|
||||
for item in news_list:
|
||||
msg += item + "\n\n"
|
||||
return msg.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"System: NewsAPI fetching news: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
@@ -1,11 +1,9 @@
|
||||
# modules/scheduler.py 2025 meshing-around
|
||||
# Scheduler setup for Mesh Bot
|
||||
# Scheduler module for mesh_bot
|
||||
import asyncio
|
||||
import schedule
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from modules.log import logger
|
||||
from modules.settings import MOTD
|
||||
from modules.system import send_message
|
||||
|
||||
async def run_scheduler_loop(interval=1):
|
||||
@@ -80,7 +78,11 @@ def setup_scheduler(
|
||||
handle_riverFlow,
|
||||
handle_tide,
|
||||
handle_satpass,
|
||||
handleNews,
|
||||
handle_mwx,
|
||||
sysinfo,
|
||||
)
|
||||
from modules.rss import get_rss_feed
|
||||
except ImportError as e:
|
||||
logger.warning(f"Some mesh_bot schedule features are unavailable by option disable in config.ini: {e} comment out the use of these methods in your custom_scheduler.py")
|
||||
|
||||
@@ -103,8 +105,10 @@ def setup_scheduler(
|
||||
if any(option in schedulerValue for option in basicOptions):
|
||||
if schedulerValue == 'day':
|
||||
if schedulerTime:
|
||||
# Specific time each day
|
||||
schedule.every().day.at(schedulerTime).do(send_sched_msg)
|
||||
else:
|
||||
# Every N days
|
||||
schedule.every(schedulerIntervalInt).days.do(send_sched_msg)
|
||||
elif 'mon' in schedulerValue and schedulerTime:
|
||||
schedule.every().monday.at(schedulerTime).do(send_sched_msg)
|
||||
@@ -127,19 +131,55 @@ def setup_scheduler(
|
||||
logger.debug(f"System: Starting the basic scheduler to send '{scheduler_message}' on schedule '{schedulerValue}' every {schedulerIntervalInt} interval at time '{schedulerTime}' on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'joke' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).minutes.do(
|
||||
partial(send_message, tell_joke(), schedulerChannel, 0, schedulerInterface)
|
||||
lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the joke scheduler to send a joke every {schedulerIntervalInt} minutes on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'link' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface)
|
||||
lambda: send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the link scheduler to send link messages every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'weather' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
partial(send_message, handle_wxc(0, schedulerInterface, 'wx', days=1), schedulerChannel, 0, schedulerInterface)
|
||||
lambda: send_message(handle_wxc(0, schedulerInterface, 'wx', days=1), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the weather scheduler to send weather updates every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'news' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message(handleNews(0, schedulerInterface, 'readnews', False), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the news scheduler to send news updates every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'readrss' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message(get_rss_feed(''), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the RSS scheduler to send RSS feeds every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'mwx' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_mwx(0, schedulerInterface, 'mwx'), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the marine weather scheduler to send marine weather updates at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'sysinfo' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message(sysinfo('', 0, schedulerInterface, False), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the sysinfo scheduler to send system information every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'tide' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_tide(0, schedulerInterface, schedulerChannel), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the tide scheduler to send tide information at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'solar' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_sun(0, schedulerInterface, schedulerChannel), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the scheduler to send solar information at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'verse' in schedulerValue:
|
||||
from modules.filemon import read_verse
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(read_verse(), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the verse scheduler to send a verse at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'custom' in schedulerValue:
|
||||
try:
|
||||
from modules.custom_scheduler import setup_custom_schedules # type: ignore
|
||||
@@ -151,7 +191,7 @@ def setup_scheduler(
|
||||
lambda: logger.info("System: Scheduled Broadcast Enabled Reminder")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Custom scheduler file not found or failed to import. cp etc/custom_scheduler.py modules/custom_scheduler.py")
|
||||
logger.warning("Custom scheduler file not found or failed to import. cp etc/custom_scheduler.template modules/custom_scheduler.py")
|
||||
except Exception as e:
|
||||
logger.error(f"System: Scheduler Error {e}")
|
||||
return True
|
||||
@@ -32,6 +32,11 @@ cmdHistory = [] # list to hold the command history for lheard and history comman
|
||||
msg_history = [] # list to hold the message history for the messages command
|
||||
max_bytes = 200 # Meshtastic has ~237 byte limit, use conservative 200 bytes for message content
|
||||
voxMsgQueue = [] # queue for VOX detected messages
|
||||
tts_read_queue = [] # queue for TTS messages
|
||||
wsjtxMsgQueue = [] # queue for WSJT-X detected messages
|
||||
js8callMsgQueue = [] # queue for JS8Call detected messages
|
||||
autoBanlist = [] # list of nodes to autoban for repeated offenses
|
||||
apiThrottleList = [] # list of nodes to throttle API requests for repeated offenses
|
||||
# Game trackers
|
||||
surveyTracker = [] # Survey game tracker
|
||||
tictactoeTracker = [] # TicTacToe game tracker
|
||||
@@ -45,6 +50,7 @@ lemonadeTracker = [] # Lemonade Stand game tracker
|
||||
dwPlayerTracker = [] # DopeWars player tracker
|
||||
jackTracker = [] # Jack game tracker
|
||||
mindTracker = [] # Mastermind (mmind) game tracker
|
||||
battleshipTracker = [] # Battleship game tracker
|
||||
|
||||
# Memory Management Constants
|
||||
MAX_MSG_HISTORY = 250
|
||||
@@ -78,7 +84,7 @@ if 'sentry' not in config:
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'location' not in config:
|
||||
config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'UseMeteoWxAPI': 'False', 'useMetric': 'False', 'NOAAforecastDuration': '4', 'NOAAalertCount': '2', 'NOAAalertsEnabled': 'True', 'wxAlertBroadcastEnabled': 'False', 'wxAlertBroadcastChannel': '2', 'repeaterLookup': 'rbook'}
|
||||
config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'fuzzConfigLocation': 'True',}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'bbs' not in config:
|
||||
@@ -125,6 +131,10 @@ if 'qrz' not in config:
|
||||
config['qrz'] = {'enabled': 'False', 'qrz_db': 'data/qrz.db', 'qrz_hello_string': 'send CMD or DM me for more info.'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'inventory' not in config:
|
||||
config['inventory'] = {'enabled': 'False', 'inventory_db': 'data/inventory.db', 'disable_penny': 'False'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
# interface1 settings
|
||||
interface1_type = config['interface'].get('type', 'serial')
|
||||
port1 = config['interface'].get('port', '')
|
||||
@@ -246,6 +256,7 @@ try:
|
||||
dad_jokes_enabled = config['general'].getboolean('DadJokes', False)
|
||||
dad_jokes_emojiJokes = config['general'].getboolean('DadJokesEmoji', False)
|
||||
bee_enabled = config['general'].getboolean('bee', False) # 🐝 off by default undocumented
|
||||
bible_enabled = config['general'].getboolean('verse', False) # verse command
|
||||
solar_conditions_enabled = config['general'].getboolean('spaceWeather', True)
|
||||
wikipedia_enabled = config['general'].getboolean('wikipedia', False)
|
||||
use_kiwix_server = config['general'].getboolean('useKiwixServer', False)
|
||||
@@ -256,6 +267,10 @@ try:
|
||||
llmModel = config['general'].get('ollamaModel', 'gemma3:270m') # default gemma3:270m
|
||||
rawLLMQuery = config['general'].getboolean('rawLLMQuery', True) #default True
|
||||
llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True) # default True
|
||||
llmUseWikiContext = config['general'].getboolean('llmUseWikiContext', False) # default False
|
||||
useOpenWebUI = config['general'].getboolean('useOpenWebUI', False) # default False
|
||||
openWebUIURL = config['general'].get('openWebUIURL', 'http://localhost:3000') # default localhost:3000
|
||||
openWebUIAPIKey = config['general'].get('openWebUIAPIKey', '') # default empty
|
||||
dont_retry_disconnect = config['general'].getboolean('dont_retry_disconnect', False) # default False, retry on disconnect
|
||||
favoriteNodeList = config['general'].get('favoriteNodeList', '').split(',')
|
||||
enableEcho = config['general'].getboolean('enableEcho', False) # default False
|
||||
@@ -265,12 +280,10 @@ try:
|
||||
rssMaxItems = config['general'].getint('rssMaxItems', 3) # default 3 items
|
||||
rssTruncate = config['general'].getint('rssTruncate', 100) # default 100 characters
|
||||
rssFeedNames = config['general'].get('rssFeedNames', 'default,arrl').split(',')
|
||||
|
||||
# emergency response
|
||||
emergency_responder_enabled = config['emergencyHandler'].getboolean('enabled', False)
|
||||
emergency_responder_alert_channel = config['emergencyHandler'].getint('alert_channel', 2) # default 2
|
||||
emergency_responder_alert_interface = config['emergencyHandler'].getint('alert_interface', 1) # default 1
|
||||
emergency_responder_email = config['emergencyHandler'].get('email', '').split(',')
|
||||
newsAPI_KEY = config['general'].get('newsAPI_KEY', '') # default empty
|
||||
newsAPIregion = config['general'].get('newsAPIregion', 'us') # default us
|
||||
enable_headlines = config['general'].getboolean('enableNewsAPI', False) # default False
|
||||
newsAPIsort = config['general'].get('sort_by', 'relevancy') # default publishedAt
|
||||
|
||||
# sentry
|
||||
sentry_enabled = config['sentry'].getboolean('SentryEnabled', False) # default False
|
||||
@@ -310,28 +323,47 @@ try:
|
||||
coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days
|
||||
|
||||
# location alerts
|
||||
emergencyAlertBrodcastEnabled = config['location'].getboolean('eAlertBroadcastEnabled', False) # default False
|
||||
eAlertBroadcastEnabled = config['location'].getboolean('eAlertBroadcastEnabled', False) # old deprecated name
|
||||
ipawsAlertEnabled = config['location'].getboolean('ipawsAlertEnabled', False) # default False new ^
|
||||
# Keep both in sync for backward compatibility
|
||||
if eAlertBroadcastEnabled or ipawsAlertEnabled:
|
||||
eAlertBroadcastEnabled = True
|
||||
ipawsAlertEnabled = True
|
||||
wxAlertBroadcastEnabled = config['location'].getboolean('wxAlertBroadcastEnabled', False) # default False
|
||||
volcanoAlertBroadcastEnabled = config['location'].getboolean('volcanoAlertBroadcastEnabled', False) # default False
|
||||
enableGBalerts = config['location'].getboolean('enableGBalerts', False) # default False
|
||||
enableDEalerts = config['location'].getboolean('enableDEalerts', False) # default False
|
||||
wxAlertsEnabled = config['location'].getboolean('NOAAalertsEnabled', True) # default True
|
||||
|
||||
ignoreEASenable = config['location'].getboolean('ignoreEASenable', False) # default False
|
||||
ignoreEASwords = config['location'].get('ignoreEASwords', 'test,advisory').split(',') # default test,advisory
|
||||
myRegionalKeysDE = config['location'].get('myRegionalKeysDE', '110000000000').split(',') # default city Berlin
|
||||
ignoreFEMAenable = config['location'].getboolean('ignoreFEMAenable', True) # default True
|
||||
ignoreFEMAwords = config['location'].get('ignoreFEMAwords', 'test,exercise').split(',') # default test,exercise
|
||||
ignoreUSGSEnable = config['location'].getboolean('ignoreVolcanoEnable', False) # default False
|
||||
ignoreUSGSWords = config['location'].get('ignoreVolcanoWords', 'test,advisory').split(',') # default test,advisory
|
||||
|
||||
forecastDuration = config['location'].getint('NOAAforecastDuration', 4) # NOAA forcast days
|
||||
numWxAlerts = config['location'].getint('NOAAalertCount', 2) # default 2 alerts
|
||||
enableExtraLocationWx = config['location'].getboolean('enableExtraLocationWx', False) # default False
|
||||
myStateFIPSList = config['location'].get('myFIPSList', '').split(',') # default empty
|
||||
mySAMEList = config['location'].get('mySAMEList', '').split(',') # default empty
|
||||
ignoreFEMAenable = config['location'].getboolean('ignoreFEMAenable', True) # default True
|
||||
ignoreFEMAwords = config['location'].get('ignoreFEMAwords', 'test,exercise').split(',') # default test,exercise
|
||||
wxAlertBroadcastChannel = config['location'].get('wxAlertBroadcastCh', '2').split(',') # default Channel 2
|
||||
emergencyAlertBroadcastCh = config['location'].get('eAlertBroadcastCh', '2').split(',') # default Channel 2
|
||||
volcanoAlertBroadcastEnabled = config['location'].getboolean('volcanoAlertBroadcastEnabled', False) # default False
|
||||
volcanoAlertBroadcastChannel = config['location'].get('volcanoAlertBroadcastCh', '2').split(',') # default Channel 2
|
||||
ignoreUSGSEnable = config['location'].getboolean('ignoreVolcanoEnable', False) # default False
|
||||
ignoreUSGSWords = config['location'].get('ignoreVolcanoWords', 'test,advisory').split(',') # default test,advisory
|
||||
myRegionalKeysDE = config['location'].get('myRegionalKeysDE', '110000000000').split(',') # default city Berlin
|
||||
eAlertBroadcastChannel = config['location'].get('eAlertBroadcastCh', '').split(',') # default empty
|
||||
|
||||
# any US alerts enabled
|
||||
usAlerts = (
|
||||
ipawsAlertEnabled or
|
||||
wxAlertBroadcastEnabled or
|
||||
volcanoAlertBroadcastEnabled or
|
||||
eAlertBroadcastEnabled
|
||||
)
|
||||
|
||||
# emergency response
|
||||
emergency_responder_enabled = config['emergencyHandler'].getboolean('enabled', False)
|
||||
emergency_responder_alert_channel = config['emergencyHandler'].getint('alert_channel', 2) # default 2
|
||||
emergency_responder_alert_interface = config['emergencyHandler'].getint('alert_interface', 1) # default 1
|
||||
emergency_responder_email = config['emergencyHandler'].get('email', '').split(',')
|
||||
|
||||
|
||||
# bbs
|
||||
bbs_enabled = config['bbs'].getboolean('enabled', False)
|
||||
bbsdb = config['bbs'].get('bbsdb', 'data/bbsdb.pkl')
|
||||
@@ -345,6 +377,7 @@ try:
|
||||
checklist_enabled = config['checklist'].getboolean('enabled', False)
|
||||
checklist_db = config['checklist'].get('checklist_db', 'data/checklist.db')
|
||||
reverse_in_out = config['checklist'].getboolean('reverse_in_out', False)
|
||||
checklist_auto_approve = config['checklist'].getboolean('auto_approve', True) # default True
|
||||
|
||||
# qrz hello
|
||||
qrz_hello_enabled = config['qrz'].getboolean('enabled', False)
|
||||
@@ -352,6 +385,11 @@ try:
|
||||
qrz_hello_string = config['qrz'].get('qrz_hello_string', 'MeshBot says Hello! DM for more info.')
|
||||
train_qrz = config['qrz'].getboolean('training', True)
|
||||
|
||||
# inventory and POS
|
||||
inventory_enabled = config['inventory'].getboolean('enabled', False)
|
||||
inventory_db = config['inventory'].get('inventory_db', 'data/inventory.db')
|
||||
disable_penny = config['inventory'].getboolean('disable_penny', False)
|
||||
|
||||
# E-Mail Settings
|
||||
sysopEmails = config['smtp'].get('sysopEmails', '').split(',')
|
||||
enableSMTP = config['smtp'].getboolean('enableSMTP', False)
|
||||
@@ -402,6 +440,17 @@ try:
|
||||
voxOnTrapList = config['radioMon'].getboolean('voxOnTrapList', False) # default False
|
||||
voxTrapList = config['radioMon'].get('voxTrapList', 'chirpy').split(',') # default chirpy
|
||||
voxEnableCmd = config['radioMon'].getboolean('voxEnableCmd', True) # default True
|
||||
meshagesTTS = config['radioMon'].getboolean('meshagesTTS', False) # default False
|
||||
ttsChannels = config['radioMon'].get('ttsChannels', '2').split(',') # default Channel 2
|
||||
ttsnoWelcome = config['radioMon'].getboolean('ttsnoWelcome', False) # default False
|
||||
|
||||
# WSJT-X and JS8Call monitoring
|
||||
wsjtx_detection_enabled = config['radioMon'].getboolean('wsjtxDetectionEnabled', False) # default WSJT-X detection disabled
|
||||
wsjtx_udp_server_address = config['radioMon'].get('wsjtxUdpServerAddress', '127.0.0.1:2237') # default localhost:2237
|
||||
wsjtx_watched_callsigns = config['radioMon'].get('wsjtxWatchedCallsigns', '') # default empty (all callsigns)
|
||||
js8call_detection_enabled = config['radioMon'].getboolean('js8callDetectionEnabled', False) # default JS8Call detection disabled
|
||||
js8call_server_address = config['radioMon'].get('js8callServerAddress', '127.0.0.1:2442') # default localhost:2442
|
||||
js8call_watched_callsigns = config['radioMon'].get('js8callWatchedCallsigns', '') # default empty (all callsigns)
|
||||
|
||||
# file monitor
|
||||
file_monitor_enabled = config['fileMon'].getboolean('filemon_enabled', False)
|
||||
@@ -410,10 +459,13 @@ try:
|
||||
read_news_enabled = config['fileMon'].getboolean('enable_read_news', False) # default disabled
|
||||
news_file_path = config['fileMon'].get('news_file_path', '../data/news.txt') # default ../data/news.txt
|
||||
news_random_line_only = config['fileMon'].getboolean('news_random_line', False) # default False
|
||||
news_block_mode = config['fileMon'].getboolean('news_block_mode', False) # default False
|
||||
if news_random_line_only and news_block_mode:
|
||||
news_random_line_only = False
|
||||
enable_runShellCmd = config['fileMon'].getboolean('enable_runShellCmd', False) # default False
|
||||
allowXcmd = config['fileMon'].getboolean('allowXcmd', False) # default False
|
||||
xCmd2factorEnabled = config['fileMon'].getboolean('2factor_enabled', True) # default True
|
||||
xCmd2factor_timeout = config['fileMon'].getint('2factor_timeout', 100) # default 100 seconds
|
||||
xCmd2factorEnabled = config['fileMon'].getboolean('twoFactor_enabled', True) # default True
|
||||
xCmd2factor_timeout = config['fileMon'].getint('twoFactor_timeout', 100) # default 100 seconds
|
||||
|
||||
# games
|
||||
game_hop_limit = config['games'].getint('game_hop_limit', 5) # default 5 hops
|
||||
@@ -433,6 +485,7 @@ try:
|
||||
surveyRecordID = config['games'].getboolean('surveyRecordID', True)
|
||||
surveyRecordLocation = config['games'].getboolean('surveyRecordLocation', True)
|
||||
wordOfTheDay = config['games'].getboolean('wordOfTheDay', True)
|
||||
battleship_enabled = config['games'].getboolean('battleShip', True)
|
||||
|
||||
# messaging settings
|
||||
responseDelay = config['messagingSettings'].getfloat('responseDelay', 0.7) # default 0.7
|
||||
@@ -447,6 +500,10 @@ try:
|
||||
noisyNodeLogging = config['messagingSettings'].getboolean('noisyNodeLogging', False) # default False
|
||||
logMetaStats = config['messagingSettings'].getboolean('logMetaStats', True) # default True
|
||||
noisyTelemetryLimit = config['messagingSettings'].getint('noisyTelemetryLimit', 5) # default 5 packets
|
||||
autoBanEnabled = config['messagingSettings'].getboolean('autoBanEnabled', False) # default False
|
||||
autoBanThreshold = config['messagingSettings'].getint('autoBanThreshold', 5) # default 5 offenses
|
||||
autoBanTimeframe = config['messagingSettings'].getint('autoBanTimeframe', 3600) # default 1 hour in seconds
|
||||
apiThrottleValue = config['messagingSettings'].getint('apiThrottleValue', 20) # default 20 requests
|
||||
except Exception as e:
|
||||
print(f"System: Error reading config file: {e}")
|
||||
print("System: Check the config.ini against config.template file for missing sections or values.")
|
||||
|
||||
@@ -114,7 +114,7 @@ if location_enabled:
|
||||
help_message = help_message + ", howtall"
|
||||
|
||||
# NOAA alerts needs location module
|
||||
if wxAlertBroadcastEnabled or emergencyAlertBrodcastEnabled or volcanoAlertBroadcastEnabled:
|
||||
if wxAlertBroadcastEnabled or ipawsAlertEnabled or volcanoAlertBroadcastEnabled or eAlertBroadcastEnabled: #eAlertBroadcastEnabled depricated
|
||||
from modules.locationdata import * # from the spudgunman/meshing-around repo
|
||||
# limited subset, this should be done better but eh..
|
||||
trap_list = trap_list + ("wx", "wxa", "wxalert", "ea", "ealert", "valert")
|
||||
@@ -147,16 +147,21 @@ if dxspotter_enabled:
|
||||
help_message = help_message + ", dx"
|
||||
|
||||
# Wikipedia Search Configuration
|
||||
if wikipedia_enabled:
|
||||
from modules.wiki import * # from the spudgunman/meshing-around repo
|
||||
if wikipedia_enabled or use_kiwix_server:
|
||||
from modules.wiki import get_wikipedia_summary, get_kiwix_summary, get_wikipedia_summary
|
||||
trap_list = trap_list + ("wiki",)
|
||||
help_message = help_message + ", wiki"
|
||||
|
||||
# RSS Feed Configuration
|
||||
if rssEnable:
|
||||
from modules.rss import * # from the spudgunman/meshing-around repo
|
||||
trap_list = trap_list + ("readrss",)
|
||||
help_message = help_message + ", readrss"
|
||||
if rssEnable or enable_headlines:
|
||||
if rssEnable:
|
||||
from modules.rss import get_rss_feed
|
||||
trap_list = trap_list + ("readrss",)
|
||||
help_message = help_message + ", readrss"
|
||||
if enable_headlines:
|
||||
from modules.rss import get_newsAPI
|
||||
trap_list = trap_list + ("latest",)
|
||||
help_message = help_message + ", latest"
|
||||
|
||||
# LLM Configuration
|
||||
if llm_enabled:
|
||||
@@ -209,7 +214,8 @@ if hamtest_enabled:
|
||||
games_enabled = True
|
||||
|
||||
if tictactoe_enabled:
|
||||
from modules.games.tictactoe import * # from the spudgunman/meshing-around repo
|
||||
from modules.games.tictactoe import TicTacToe # from the spudgunman/meshing-around repo
|
||||
tictactoe = TicTacToe(display_module=None)
|
||||
trap_list = trap_list + ("tictactoe","tic-tac-toe",)
|
||||
|
||||
if quiz_enabled:
|
||||
@@ -229,6 +235,11 @@ if wordOfTheDay:
|
||||
theWordOfTheDay = WordOfTheDayGame()
|
||||
# this runs in background and wont enable other games
|
||||
|
||||
if battleship_enabled:
|
||||
from modules.games.battleship import playBattleship # from the spudgunman/meshing-around repo
|
||||
trap_list = trap_list + ("battleship",)
|
||||
games_enabled = True
|
||||
|
||||
# Games Configuration
|
||||
if games_enabled is True:
|
||||
help_message = help_message + ", games"
|
||||
@@ -256,6 +267,8 @@ if games_enabled is True:
|
||||
gamesCmdList += "hamTest, "
|
||||
if tictactoe_enabled:
|
||||
gamesCmdList += "ticTacToe, "
|
||||
if battleship_enabled:
|
||||
gamesCmdList += "battleship, "
|
||||
gamesCmdList = gamesCmdList[:-2] # remove the last comma
|
||||
else:
|
||||
gamesCmdList = ""
|
||||
@@ -282,12 +295,11 @@ if checklist_enabled:
|
||||
trap_list = trap_list + trap_list_checklist # items checkin, checkout, checklist, purgein, purgeout
|
||||
help_message = help_message + ", checkin, checkout"
|
||||
|
||||
# Radio Monitor Configuration
|
||||
if radio_detection_enabled:
|
||||
from modules.radio import * # from the spudgunman/meshing-around repo
|
||||
|
||||
if voxDetectionEnabled:
|
||||
from modules.radio import * # from the spudgunman/meshing-around repo
|
||||
# Inventory and POS Configuration
|
||||
if inventory_enabled:
|
||||
from modules.inventory import * # from the spudgunman/meshing-around repo
|
||||
trap_list = trap_list + trap_list_inventory # items item, itemlist, itemsell, etc.
|
||||
help_message = help_message + ", item, cart"
|
||||
|
||||
# File Monitor Configuration
|
||||
if file_monitor_enabled or read_news_enabled or bee_enabled or enable_runShellCmd or cmdShellSentryAlerts:
|
||||
@@ -298,6 +310,9 @@ if file_monitor_enabled or read_news_enabled or bee_enabled or enable_runShellCm
|
||||
# Bee Configuration uses file monitor module
|
||||
if bee_enabled:
|
||||
trap_list = trap_list + ("🐝",)
|
||||
if bible_enabled:
|
||||
trap_list = trap_list + ("verse",)
|
||||
help_message = help_message + ", verse"
|
||||
# x: command for shell access
|
||||
if enable_runShellCmd and allowXcmd:
|
||||
trap_list = trap_list + ("x:",)
|
||||
@@ -373,6 +388,9 @@ for i in range(1, 10):
|
||||
logger.critical(f"System: abort. Initializing Interface{i} {e}")
|
||||
exit()
|
||||
|
||||
# Get my node numbers for global use
|
||||
my_node_ids = [globals().get(f'myNodeNum{i}') for i in range(1, 10)]
|
||||
|
||||
# Get the node number of the devices, check if the devices are connected meshtastic devices
|
||||
for i in range(1, 10):
|
||||
if globals().get(f'interface{i}') and globals().get(f'interface{i}_enabled'):
|
||||
@@ -472,7 +490,7 @@ def cleanup_game_trackers(current_time):
|
||||
tracker_names = [
|
||||
'dwPlayerTracker', 'lemonadeTracker', 'jackTracker',
|
||||
'vpTracker', 'mindTracker', 'golfTracker',
|
||||
'hangmanTracker', 'hamtestTracker', 'tictactoeTracker', 'surveyTracker'
|
||||
'hangmanTracker', 'hamtestTracker', 'tictactoeTracker', 'surveyTracker', 'battleshipTracker'
|
||||
]
|
||||
|
||||
for tracker_name in tracker_names:
|
||||
@@ -656,7 +674,7 @@ async def get_closest_nodes(nodeInt=1,returnCount=3, channel=publicChannel):
|
||||
distance = round(geopy.distance.geodesic((latitudeValue, longitudeValue), (latitude, longitude)).m, 2)
|
||||
|
||||
if (distance < sentry_radius):
|
||||
if (nodeID not in [globals().get(f'myNodeNum{i}') for i in range(1, 10)]) and str(nodeID) not in sentryIgnoreList:
|
||||
if (nodeID not in my_node_ids) and str(nodeID) not in sentryIgnoreList:
|
||||
node_list.append({'id': nodeID, 'latitude': latitude, 'longitude': longitude, 'distance': distance})
|
||||
|
||||
except Exception as e:
|
||||
@@ -668,7 +686,7 @@ async def get_closest_nodes(nodeInt=1,returnCount=3, channel=publicChannel):
|
||||
try:
|
||||
logger.debug(f"System: Requesting location data for {node['id']}, lastHeard: {node.get('lastHeard', 'N/A')}")
|
||||
# if not a interface node
|
||||
if node['num'] in [globals().get(f'myNodeNum{i}') for i in range(1, 10)]:
|
||||
if node['num'] in my_node_ids:
|
||||
ignore = True
|
||||
else:
|
||||
# one idea is to send a ping to the node to request location data for if or when, ask again later
|
||||
@@ -945,21 +963,143 @@ def messageTrap(msg):
|
||||
return True
|
||||
return False
|
||||
|
||||
def stringSafeCheck(s):
|
||||
def stringSafeCheck(s, fromID=0):
|
||||
# Check if a string is safe to use, no control characters or non-printable characters
|
||||
soFarSoGood = True
|
||||
if not all(c.isprintable() or c.isspace() for c in s):
|
||||
return False
|
||||
ban_hammer(fromID, reason="Non-printable character in message")
|
||||
return False # non-printable characters found
|
||||
if any(ord(c) < 32 and c not in '\n\r\t' for c in s):
|
||||
return False
|
||||
ban_hammer(fromID, reason="Control character in message")
|
||||
return False # control characters found
|
||||
if any(c in s for c in ['\x0b', '\x0c', '\x1b']):
|
||||
return False
|
||||
return False # vertical tab, form feed, escape characters found
|
||||
if len(s) > 1000:
|
||||
return False
|
||||
injection_chars = [';', '|', '../']
|
||||
if any(char in s for char in injection_chars):
|
||||
# Check for single-character injections
|
||||
single_injection_chars = [';', '|', '}', '>', ')']
|
||||
if any(c in s for c in single_injection_chars):
|
||||
return False # injection character found
|
||||
# Check for multi-character patterns
|
||||
multi_injection_patterns = ['../', '||']
|
||||
if any(pattern in s for pattern in multi_injection_patterns):
|
||||
return False
|
||||
return soFarSoGood
|
||||
return True
|
||||
|
||||
def api_throttle(node_id, rxInterface=None, channel=None, apiName=""):
|
||||
"""
|
||||
Throttle API requests from nodes to prevent abuse.
|
||||
Returns False if not throttled, or a string message if throttled.
|
||||
"""
|
||||
global apiThrottleList
|
||||
|
||||
current_time = time.time()
|
||||
node_id_str = str(node_id)
|
||||
|
||||
if isNodeAdmin(node_id_str):
|
||||
return False # Do not throttle admin nodes
|
||||
|
||||
# Find or create the apiThrottleList entry
|
||||
node_entry = next((entry for entry in apiThrottleList if entry['node_id'] == node_id_str), None)
|
||||
if node_entry:
|
||||
# Update interface and channel if provided
|
||||
if rxInterface is not None:
|
||||
node_entry['rxInterface'] = rxInterface
|
||||
if channel is not None:
|
||||
node_entry['channel'] = channel
|
||||
# Check if the timeframe has expired
|
||||
if (current_time - node_entry['lastSeen']) > autoBanTimeframe:
|
||||
node_entry['api_throttle_count'] = 1
|
||||
node_entry['lastSeen'] = current_time
|
||||
else:
|
||||
node_entry['api_throttle_count'] += 1
|
||||
node_entry['lastSeen'] = current_time
|
||||
if node_entry['api_throttle_count'] > apiThrottleValue:
|
||||
logger.warning(f"System: Node {node_id_str} throttled on API {apiName} count: {node_entry['api_throttle_count']}")
|
||||
if autoBanEnabled:
|
||||
ban_hammer(node_id_str, reason="API Throttle Exceeded")
|
||||
return "🚦 System busy, try again later."
|
||||
else:
|
||||
# node not found, create a new entry
|
||||
entry = {
|
||||
'node_id': node_id_str,
|
||||
'first_seen': current_time,
|
||||
'lastSeen': current_time,
|
||||
'api_throttle_count': 1,
|
||||
'rxInterface': rxInterface,
|
||||
'channel': channel
|
||||
}
|
||||
apiThrottleList.append(entry)
|
||||
node_entry = entry
|
||||
|
||||
logger.debug(f"System: API Throttle check for Node {node_id} on API {apiName} count: {node_entry['api_throttle_count']}")
|
||||
return False # Not throttled
|
||||
|
||||
def ban_hammer(node_id, rxInterface=None, channel=None, reason=""):
|
||||
"""
|
||||
Auto-ban nodes that exceed the message threshold within the timeframe.
|
||||
Returns True if the node is (or becomes) banned, False otherwise.
|
||||
"""
|
||||
global autoBanlist, seenNodes, bbs_ban_list
|
||||
|
||||
current_time = time.time()
|
||||
node_id_str = str(node_id)
|
||||
|
||||
if isNodeAdmin(node_id_str):
|
||||
return False # Do not ban admin nodes
|
||||
|
||||
# Check if the node is already banned
|
||||
if node_id_str in bbs_ban_list or node_id_str in autoBanlist:
|
||||
return True # Node is already banned
|
||||
|
||||
# if no reason provided, dont ban just run that last check
|
||||
if reason == "":
|
||||
return False
|
||||
|
||||
# Find or create the seenNodes entry (patched for missing 'node_id')
|
||||
node_entry = next((entry for entry in seenNodes if entry.get('node_id') == node_id_str), None)
|
||||
if node_entry:
|
||||
# Update interface and channel if provided
|
||||
if rxInterface is not None:
|
||||
node_entry['rxInterface'] = rxInterface
|
||||
if channel is not None:
|
||||
node_entry['channel'] = channel
|
||||
# Check if the timeframe has expired
|
||||
if (current_time - node_entry['lastSeen']) > autoBanTimeframe:
|
||||
node_entry['auto_ban_count'] = 1
|
||||
node_entry['lastSeen'] = current_time
|
||||
else:
|
||||
node_entry['auto_ban_count'] += 1
|
||||
node_entry['lastSeen'] = current_time
|
||||
else:
|
||||
# node not found, create a new entry
|
||||
entry = {
|
||||
'node_id': node_id_str,
|
||||
'first_seen': current_time,
|
||||
'lastSeen': current_time,
|
||||
'auto_ban_count': 3, # start at 3 to trigger ban faster
|
||||
'rxInterface': rxInterface,
|
||||
'channel': channel,
|
||||
'welcome': False
|
||||
}
|
||||
seenNodes.append(entry)
|
||||
node_entry = entry
|
||||
|
||||
# Check if the node has exceeded the ban threshold
|
||||
if node_entry['auto_ban_count'] < autoBanThreshold:
|
||||
logger.debug(f"System: Node {node_id_str} auto-ban count: {node_entry['auto_ban_count']}")
|
||||
return False # No ban applied
|
||||
|
||||
# If the node has exceeded the ban threshold within the time window
|
||||
autoBanlist.append(node_id_str)
|
||||
logger.info(f"System: Node {node_id_str} exceeded auto-ban threshold with {node_entry['auto_ban_count']} messages")
|
||||
if autoBanEnabled:
|
||||
logger.warning(f"System: Auto-banned node {node_id_str} Reason: {reason}")
|
||||
if node_id_str not in bbs_ban_list:
|
||||
bbs_ban_list.append(node_id_str)
|
||||
save_bbsBanList()
|
||||
return True # Node is now banned
|
||||
|
||||
return False # No ban applied
|
||||
|
||||
def save_bbsBanList():
|
||||
# save the bbs_ban_list to file
|
||||
@@ -977,7 +1117,7 @@ def load_bbsBanList():
|
||||
try:
|
||||
with open('data/bbs_ban_list.txt', 'r') as f:
|
||||
loaded_list = [line.strip() for line in f if line.strip()]
|
||||
logger.debug("System: BBS ban list loaded from file")
|
||||
logger.debug(f"System: BBS ban list now has {len(loaded_list)} entries loaded from file")
|
||||
except FileNotFoundError:
|
||||
config_val = config['bbs'].get('bbs_ban_list', '')
|
||||
if config_val:
|
||||
@@ -997,8 +1137,6 @@ def isNodeAdmin(nodeID):
|
||||
for admin in bbs_admin_list:
|
||||
if str(nodeID) == admin:
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
def isNodeBanned(nodeID):
|
||||
@@ -1009,6 +1147,7 @@ def isNodeBanned(nodeID):
|
||||
return False
|
||||
|
||||
def handle_bbsban(message, message_from_id, isDM):
|
||||
global bbs_ban_list
|
||||
msg = ""
|
||||
if not isDM:
|
||||
return "🤖only available in a Direct Message📵"
|
||||
@@ -1105,109 +1244,79 @@ def handleMultiPing(nodeID=0, deviceID=1):
|
||||
multiPingList.pop(j)
|
||||
break
|
||||
|
||||
priorVolcanoAlert = ""
|
||||
priorEmergencyAlert = ""
|
||||
priorWxAlert = ""
|
||||
# Alert broadcasting initialization
|
||||
last_alerts = {
|
||||
"overdue": {"time": 0, "message": ""},
|
||||
"fema": {"time": 0, "message": ""},
|
||||
"uk": {"time": 0, "message": ""},
|
||||
"de": {"time": 0, "message": ""},
|
||||
"wx": {"time": 0, "message": ""},
|
||||
"volcano": {"time": 0, "message": ""},
|
||||
}
|
||||
def should_send_alert(alert_type, new_message, min_interval=1):
|
||||
now = time.time()
|
||||
last = last_alerts[alert_type]
|
||||
# Only send if enough time has passed AND the message is different
|
||||
if (now - last["time"]) > min_interval and new_message != last["message"]:
|
||||
last_alerts[alert_type]["time"] = now
|
||||
last_alerts[alert_type]["message"] = new_message
|
||||
return True
|
||||
return False
|
||||
|
||||
def handleAlertBroadcast(deviceID=1):
|
||||
global priorVolcanoAlert, priorEmergencyAlert, priorWxAlert
|
||||
alertUk = NO_ALERTS
|
||||
alertDe = NO_ALERTS
|
||||
alertFema = NO_ALERTS
|
||||
wxAlert = NO_ALERTS
|
||||
volcanoAlert = NO_ALERTS
|
||||
alertWx = False
|
||||
# only allow API call every 20 minutes
|
||||
# the watchdog will call this function 3 times, seeing possible throttling on the API
|
||||
clock = datetime.now()
|
||||
if clock.minute % 20 != 0:
|
||||
return False
|
||||
if clock.second > 17:
|
||||
return False
|
||||
|
||||
# check for alerts
|
||||
if wxAlertBroadcastEnabled:
|
||||
alertWx = alertBrodcastNOAA()
|
||||
try:
|
||||
alertUk = alertDe = alertFema = wxAlert = volcanoAlert = overdueAlerts = NO_ALERTS
|
||||
alertWx = False
|
||||
clock = datetime.now()
|
||||
|
||||
# Overdue check-in alert
|
||||
if checklist_enabled:
|
||||
overdueAlerts = format_overdue_alert()
|
||||
if overdueAlerts:
|
||||
if should_send_alert("overdue", overdueAlerts, min_interval=300): # 5 minutes interval for overdue alerts
|
||||
send_message(overdueAlerts, emergency_responder_alert_channel, 0, emergency_responder_alert_interface)
|
||||
|
||||
# Only allow API call every 20 minutes
|
||||
if not (clock.minute % 20 == 0 and clock.second <= 17):
|
||||
return False
|
||||
|
||||
# Collect alerts
|
||||
if wxAlertBroadcastEnabled:
|
||||
alertWx = alertBrodcastNOAA()
|
||||
if alertWx:
|
||||
wxAlert = f"🚨 {alertWx[1]} EAS-WX ALERT: {alertWx[0]}"
|
||||
if eAlertBroadcastEnabled or ipawsAlertEnabled:
|
||||
alertFema = getIpawsAlert(latitudeValue, longitudeValue, shortAlerts=True)
|
||||
if volcanoAlertBroadcastEnabled:
|
||||
volcanoAlert = get_volcano_usgs(latitudeValue, longitudeValue)
|
||||
|
||||
if emergencyAlertBrodcastEnabled:
|
||||
if enableDEalerts:
|
||||
alertDe = get_nina_alerts()
|
||||
if enableGBalerts:
|
||||
alertUk = get_govUK_alerts()
|
||||
else:
|
||||
# default USA alerts
|
||||
alertFema = getIpawsAlert(latitudeValue,longitudeValue, shortAlerts=True)
|
||||
deAlerts = get_nina_alerts()
|
||||
|
||||
# format alert
|
||||
if alertWx:
|
||||
wxAlert = f"🚨 {alertWx[1]} EAS-WX ALERT: {alertWx[0]}"
|
||||
else:
|
||||
wxAlert = False
|
||||
if usAlerts:
|
||||
alert_types = [
|
||||
("fema", alertFema, ipawsAlertEnabled),
|
||||
("wx", wxAlert, wxAlertBroadcastEnabled),
|
||||
("volcano", volcanoAlert, volcanoAlertBroadcastEnabled),]
|
||||
|
||||
femaAlert = alertFema
|
||||
ukAlert = alertUk
|
||||
deAlert = alertDe
|
||||
if enableDEalerts:
|
||||
alert_types = [("de", deAlerts, enableDEalerts)]
|
||||
|
||||
if emergencyAlertBrodcastEnabled:
|
||||
if NO_ALERTS not in femaAlert and ERROR_FETCHING_DATA not in femaAlert:
|
||||
if femaAlert != priorEmergencyAlert:
|
||||
priorEmergencyAlert = femaAlert
|
||||
else:
|
||||
return False
|
||||
if isinstance(emergencyAlertBroadcastCh, list):
|
||||
for channel in emergencyAlertBroadcastCh:
|
||||
send_message(femaAlert, int(channel), 0, deviceID)
|
||||
else:
|
||||
send_message(femaAlert, emergencyAlertBroadcastCh, 0, deviceID)
|
||||
return True
|
||||
if NO_ALERTS not in ukAlert:
|
||||
if ukAlert != priorEmergencyAlert:
|
||||
priorEmergencyAlert = ukAlert
|
||||
else:
|
||||
return False
|
||||
if isinstance(emergencyAlertBroadcastCh, list):
|
||||
for channel in emergencyAlertBroadcastCh:
|
||||
send_message(ukAlert, int(channel), 0, deviceID)
|
||||
else:
|
||||
send_message(ukAlert, emergencyAlertBroadcastCh, 0, deviceID)
|
||||
return True
|
||||
|
||||
if NO_ALERTS not in alertDe:
|
||||
if deAlert != priorEmergencyAlert:
|
||||
priorEmergencyAlert = deAlert
|
||||
else:
|
||||
return False
|
||||
if isinstance(emergencyAlertBroadcastCh, list):
|
||||
for channel in emergencyAlertBroadcastCh:
|
||||
send_message(deAlert, int(channel), 0, deviceID)
|
||||
else:
|
||||
send_message(deAlert, emergencyAlertBroadcastCh, 0, deviceID)
|
||||
return True
|
||||
|
||||
if wxAlertBroadcastEnabled:
|
||||
if wxAlert:
|
||||
if wxAlert != priorWxAlert:
|
||||
priorWxAlert = wxAlert
|
||||
else:
|
||||
return False
|
||||
if isinstance(wxAlertBroadcastChannel, list):
|
||||
for channel in wxAlertBroadcastChannel:
|
||||
send_message(wxAlert, int(channel), 0, deviceID)
|
||||
else:
|
||||
send_message(wxAlert, wxAlertBroadcastChannel, 0, deviceID)
|
||||
return True
|
||||
|
||||
if volcanoAlertBroadcastEnabled:
|
||||
volcanoAlert = get_volcano_usgs(latitudeValue, longitudeValue)
|
||||
if volcanoAlert and NO_ALERTS not in volcanoAlert and ERROR_FETCHING_DATA not in volcanoAlert:
|
||||
# check if the alert is different from the last one
|
||||
if volcanoAlert != priorVolcanoAlert:
|
||||
priorVolcanoAlert = volcanoAlert
|
||||
if isinstance(volcanoAlertBroadcastChannel, list):
|
||||
for channel in volcanoAlertBroadcastChannel:
|
||||
send_message(volcanoAlert, int(channel), 0, deviceID)
|
||||
else:
|
||||
send_message(volcanoAlert, volcanoAlertBroadcastChannel, 0, deviceID)
|
||||
return True
|
||||
for alert_type, alert_msg, enabled in alert_types:
|
||||
if enabled and alert_msg and NO_ALERTS not in alert_msg and ERROR_FETCHING_DATA not in alert_msg:
|
||||
if should_send_alert(alert_type, alert_msg):
|
||||
logger.debug(f"System: Sending {alert_type} alert to emergency responder channel {emergency_responder_alert_channel}")
|
||||
send_message(alert_msg, emergency_responder_alert_channel, 0, emergency_responder_alert_interface)
|
||||
if eAlertBroadcastChannel:
|
||||
for ch in eAlertBroadcastChannel:
|
||||
ch = ch.strip()
|
||||
if ch:
|
||||
logger.debug(f"System: Sending {alert_type} alert to aux channel {ch}")
|
||||
time.sleep(splitDelay)
|
||||
send_message(alert_msg, int(ch), 0, emergency_responder_alert_interface)
|
||||
except Exception as e:
|
||||
logger.error(f"System: Error in handleAlertBroadcast: {e}")
|
||||
return False
|
||||
|
||||
def onDisconnect(interface):
|
||||
# Handle disconnection of the interface
|
||||
@@ -1358,6 +1467,7 @@ def initializeMeshLeaderboard():
|
||||
'longestUptime': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 🕰️
|
||||
'fastestSpeed': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 🚓
|
||||
'highestAltitude': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 🚀
|
||||
'tallestNode': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 🪜
|
||||
'coldestTemp': {'nodeID': None, 'value': 999, 'timestamp': 0}, # 🥶
|
||||
'hottestTemp': {'nodeID': None, 'value': -999, 'timestamp': 0}, # 🥵
|
||||
'worstAirQuality': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 💨
|
||||
@@ -1403,11 +1513,13 @@ def consumeMetadata(packet, rxNode=0, channel=-1):
|
||||
|
||||
# Meta for most Messages leaderboard
|
||||
if packet_type == 'TEXT_MESSAGE':
|
||||
message_count = meshLeaderboard.get('nodeMessageCounts', {})
|
||||
message_count[nodeID] = message_count.get(nodeID, 0) + 1
|
||||
meshLeaderboard['nodeMessageCounts'] = message_count
|
||||
if message_count[nodeID] > meshLeaderboard['mostMessages']['value']:
|
||||
meshLeaderboard['mostMessages'] = {'nodeID': nodeID, 'value': message_count[nodeID], 'timestamp': time.time()}
|
||||
# if packet isnt TO a my_node_id count it
|
||||
if packet.get('to') not in my_node_ids:
|
||||
message_count = meshLeaderboard.get('nodeMessageCounts', {})
|
||||
message_count[nodeID] = message_count.get(nodeID, 0) + 1
|
||||
meshLeaderboard['nodeMessageCounts'] = message_count
|
||||
if message_count[nodeID] > meshLeaderboard['mostMessages']['value']:
|
||||
meshLeaderboard['mostMessages'] = {'nodeID': nodeID, 'value': message_count[nodeID], 'timestamp': time.time()}
|
||||
else:
|
||||
tmessage_count = meshLeaderboard.get('nodeTMessageCounts', {})
|
||||
tmessage_count[nodeID] = tmessage_count.get(nodeID, 0) + 1
|
||||
@@ -1423,10 +1535,11 @@ def consumeMetadata(packet, rxNode=0, channel=-1):
|
||||
if debugMetadata and 'TELEMETRY_APP' not in metadataFilter:
|
||||
print(f"DEBUG TELEMETRY_APP: {packet}\n\n")
|
||||
telemetry_packet = packet['decoded']['telemetry']
|
||||
# Track lowest battery 🪫
|
||||
# Track device metrics (battery, uptime)
|
||||
if telemetry_packet.get('deviceMetrics'):
|
||||
deviceMetrics = telemetry_packet['deviceMetrics']
|
||||
current_time = time.time()
|
||||
# Track lowest battery 🪫
|
||||
try:
|
||||
if deviceMetrics.get('batteryLevel') is not None:
|
||||
battery = float(deviceMetrics['batteryLevel'])
|
||||
@@ -1501,10 +1614,7 @@ def consumeMetadata(packet, rxNode=0, channel=-1):
|
||||
positionMetadata[nodeID][key] = position_data.get(key, 0)
|
||||
# Track fastest speed 🚓
|
||||
if position_data.get('groundSpeed') is not None:
|
||||
if use_metric:
|
||||
speed = position_data['groundSpeed']
|
||||
else:
|
||||
speed = round(position_data['groundSpeed'] * 1.60934, 1) # Convert mph to km/h
|
||||
speed = position_data['groundSpeed']
|
||||
if speed > meshLeaderboard['fastestSpeed']['value']:
|
||||
meshLeaderboard['fastestSpeed'] = {'nodeID': nodeID, 'value': speed, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
@@ -1512,10 +1622,20 @@ def consumeMetadata(packet, rxNode=0, channel=-1):
|
||||
# Track highest altitude 🚀 (also log if over highfly_altitude threshold)
|
||||
if position_data.get('altitude') is not None:
|
||||
altitude = position_data['altitude']
|
||||
if altitude > meshLeaderboard['highestAltitude']['value']:
|
||||
meshLeaderboard['highestAltitude'] = {'nodeID': nodeID, 'value': altitude, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
logger.info(f"System: 🚀 New altitude record: {altitude}m from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}")
|
||||
if altitude > highfly_altitude:
|
||||
if altitude > meshLeaderboard['highestAltitude']['value']:
|
||||
meshLeaderboard['highestAltitude'] = {'nodeID': nodeID, 'value': altitude, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
logger.info(f"System: 🚀 New altitude record: {altitude}m from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}")
|
||||
# Track tallest node 🪜 (under the highfly_altitude limit by 100m)
|
||||
if position_data.get('altitude') is not None:
|
||||
altitude = position_data['altitude']
|
||||
if altitude < (highfly_altitude - 100):
|
||||
if altitude > meshLeaderboard['tallestNode']['value']:
|
||||
meshLeaderboard['tallestNode'] = {'nodeID': nodeID, 'value': altitude, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
logger.info(f"System: 🪜 New tallest node record: {altitude}m from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}")
|
||||
|
||||
# if altitude is over highfly_altitude send a log and message for high-flying nodes and not in highfly_ignoreList
|
||||
if position_data.get('altitude', 0) > highfly_altitude and highfly_enabled and str(nodeID) not in highfly_ignoreList and not isNodeBanned(nodeID):
|
||||
logger.info(f"System: High Altitude {position_data['altitude']}m on Device: {rxNode} Channel: {channel} NodeID:{nodeID} Lat:{position_data.get('latitude', 0)} Lon:{position_data.get('longitude', 0)}")
|
||||
@@ -1528,25 +1648,26 @@ def consumeMetadata(packet, rxNode=0, channel=-1):
|
||||
if current_time - last_alert_time < 1800:
|
||||
return False # less than 30 minutes since last alert
|
||||
positionMetadata[nodeID]['lastHighFlyAlert'] = current_time
|
||||
|
||||
if highfly_check_openskynetwork:
|
||||
# check get_openskynetwork to see if the node is an aircraft
|
||||
if 'latitude' in position_data and 'longitude' in position_data:
|
||||
flight_info = get_openskynetwork(position_data.get('latitude', 0), position_data.get('longitude', 0))
|
||||
# Only show plane if within altitude
|
||||
if (
|
||||
flight_info
|
||||
and NO_ALERTS not in flight_info
|
||||
and ERROR_FETCHING_DATA not in flight_info
|
||||
and isinstance(flight_info, dict)
|
||||
and 'altitude' in flight_info
|
||||
):
|
||||
plane_alt = flight_info['altitude']
|
||||
node_alt = position_data.get('altitude', 0)
|
||||
if abs(node_alt - plane_alt) <= 900: # within 900m
|
||||
msg += f"\n✈️Detected near:\n{flight_info}"
|
||||
send_message(msg, highfly_channel, 0, highfly_interface)
|
||||
|
||||
try:
|
||||
if highfly_check_openskynetwork:
|
||||
if 'latitude' in position_data and 'longitude' in position_data and 'altitude' in position_data:
|
||||
flight_info = get_openskynetwork(
|
||||
position_data.get('latitude', 0),
|
||||
position_data.get('longitude', 0),
|
||||
node_altitude=position_data.get('altitude', 0)
|
||||
)
|
||||
if flight_info and isinstance(flight_info, dict):
|
||||
msg += (
|
||||
f"\n✈️Detected near:\n"
|
||||
f"{flight_info.get('callsign', 'N/A')} "
|
||||
f"Alt:{int(flight_info.get('geo_altitude', 0)) if flight_info.get('geo_altitude') else 'N/A'}m "
|
||||
f"Vel:{int(flight_info.get('velocity', 0)) if flight_info.get('velocity') else 'N/A'}m/s "
|
||||
f"Heading:{int(flight_info.get('true_track', 0)) if flight_info.get('true_track') else 'N/A'}°\n"
|
||||
f"From:{flight_info.get('origin_country', 'N/A')}"
|
||||
)
|
||||
send_message(msg, highfly_channel, 0, highfly_interface)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: Highfly: error: {e}")
|
||||
# Keep the positionMetadata dictionary at a maximum size
|
||||
if len(positionMetadata) > MAX_SEEN_NODES:
|
||||
# Remove the oldest entry
|
||||
@@ -1769,7 +1890,11 @@ def loadLeaderboard():
|
||||
global meshLeaderboard
|
||||
try:
|
||||
with open('data/leaderboard.pkl', 'rb') as f:
|
||||
meshLeaderboard = pickle.load(f)
|
||||
loaded = pickle.load(f)
|
||||
# Merge with current default structure to add any new keys
|
||||
initializeMeshLeaderboard() # sets meshLeaderboard to default structure
|
||||
for k, v in loaded.items():
|
||||
meshLeaderboard[k] = v
|
||||
if logMetaStats:
|
||||
logger.debug("System: Mesh Leaderboard loaded from leaderboard.pkl")
|
||||
except FileNotFoundError:
|
||||
@@ -1820,6 +1945,16 @@ def get_mesh_leaderboard(msg, fromID, deviceID):
|
||||
result += f"🚀 Altitude: {int(round(value_m, 0))}m {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
else:
|
||||
result += f"🚀 Altitude: {int(value_ft)}ft {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
|
||||
# Tallest node
|
||||
if meshLeaderboard['tallestNode']['nodeID']:
|
||||
nodeID = meshLeaderboard['tallestNode']['nodeID']
|
||||
value_m = meshLeaderboard['tallestNode']['value']
|
||||
value_ft = round(value_m * 3.28084, 0)
|
||||
if use_metric:
|
||||
result += f"🪜 Tallest: {int(round(value_m, 0))}m {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
else:
|
||||
result += f"🪜 Tallest: {int(value_ft)}ft {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
|
||||
# Coldest temperature
|
||||
if meshLeaderboard['coldestTemp']['nodeID']:
|
||||
@@ -1921,7 +2056,8 @@ def get_sysinfo(nodeID=0, deviceID=1):
|
||||
return sysinfo
|
||||
|
||||
async def handleSignalWatcher():
|
||||
global lastHamLibAlert
|
||||
from modules.radio import signalWatcher
|
||||
from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface, lastHamLibAlert
|
||||
# monitor rigctld for signal strength and frequency
|
||||
while True:
|
||||
msg = await signalWatcher()
|
||||
@@ -1982,6 +2118,62 @@ async def handleFileWatcher():
|
||||
await asyncio.sleep(1)
|
||||
pass
|
||||
|
||||
async def handleWsjtxWatcher():
|
||||
# monitor WSJT-X UDP broadcasts for decode messages
|
||||
from modules.radio import wsjtxMsgQueue, wsjtxMonitor
|
||||
from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface
|
||||
|
||||
# Start the WSJT-X monitor task
|
||||
monitor_task = asyncio.create_task(wsjtxMonitor())
|
||||
|
||||
while True:
|
||||
if wsjtxMsgQueue:
|
||||
msg = wsjtxMsgQueue.pop(0)
|
||||
logger.debug(f"System: Detected message from WSJT-X: {msg}")
|
||||
|
||||
# Broadcast to configured channels
|
||||
if type(sigWatchBroadcastCh) is list:
|
||||
for ch in sigWatchBroadcastCh:
|
||||
if antiSpam and int(ch) != publicChannel:
|
||||
send_message(msg, int(ch), 0, sigWatchBroadcastInterface)
|
||||
else:
|
||||
logger.warning(f"System: antiSpam prevented Alert from WSJT-X")
|
||||
else:
|
||||
if antiSpam and sigWatchBroadcastCh != publicChannel:
|
||||
send_message(msg, int(sigWatchBroadcastCh), 0, sigWatchBroadcastInterface)
|
||||
else:
|
||||
logger.warning(f"System: antiSpam prevented Alert from WSJT-X")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def handleJs8callWatcher():
|
||||
# monitor JS8Call TCP API for messages
|
||||
from modules.radio import js8callMsgQueue, js8callMonitor
|
||||
from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface
|
||||
|
||||
# Start the JS8Call monitor task
|
||||
monitor_task = asyncio.create_task(js8callMonitor())
|
||||
|
||||
while True:
|
||||
if js8callMsgQueue:
|
||||
msg = js8callMsgQueue.pop(0)
|
||||
logger.debug(f"System: Detected message from JS8Call: {msg}")
|
||||
|
||||
# Broadcast to configured channels
|
||||
if type(sigWatchBroadcastCh) is list:
|
||||
for ch in sigWatchBroadcastCh:
|
||||
if antiSpam and int(ch) != publicChannel:
|
||||
send_message(msg, int(ch), 0, sigWatchBroadcastInterface)
|
||||
else:
|
||||
logger.warning(f"System: antiSpam prevented Alert from JS8Call")
|
||||
else:
|
||||
if antiSpam and sigWatchBroadcastCh != publicChannel:
|
||||
send_message(msg, int(sigWatchBroadcastCh), 0, sigWatchBroadcastInterface)
|
||||
else:
|
||||
logger.warning(f"System: antiSpam prevented Alert from JS8Call")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def retry_interface(nodeID):
|
||||
global retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9
|
||||
global max_retry_count1, max_retry_count2, max_retry_count3, max_retry_count4, max_retry_count5, max_retry_count6, max_retry_count7, max_retry_count8, max_retry_count9
|
||||
@@ -2091,17 +2283,40 @@ async def handleSentinel(deviceID):
|
||||
handleSentinel_loop = 0 # Reset if nothing detected
|
||||
|
||||
async def process_vox_queue():
|
||||
# process the voxMsgQueue
|
||||
global voxMsgQueue
|
||||
items_to_process = voxMsgQueue[:]
|
||||
voxMsgQueue.clear()
|
||||
if len(items_to_process) > 0:
|
||||
logger.debug(f"System: Processing {len(items_to_process)} items in voxMsgQueue")
|
||||
for item in items_to_process:
|
||||
message = item
|
||||
for channel in sigWatchBroadcastCh:
|
||||
if antiSpam and int(channel) != publicChannel:
|
||||
send_message(message, int(channel), 0, sigWatchBroadcastInterface)
|
||||
# process the voxMsgQueue
|
||||
from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface, voxMsgQueue
|
||||
items_to_process = voxMsgQueue[:]
|
||||
voxMsgQueue.clear()
|
||||
if len(items_to_process) > 0:
|
||||
logger.debug(f"System: Processing {len(items_to_process)} items in voxMsgQueue")
|
||||
for item in items_to_process:
|
||||
message = item
|
||||
for channel in sigWatchBroadcastCh:
|
||||
if antiSpam and int(channel) != publicChannel:
|
||||
send_message(message, int(channel), 0, sigWatchBroadcastInterface)
|
||||
|
||||
async def handleTTS():
|
||||
from modules.radio import generate_and_play_tts, available_voices
|
||||
from modules.settings import ttsnoWelcome, tts_read_queue
|
||||
logger.debug("System: Handle TTS started")
|
||||
if not ttsnoWelcome:
|
||||
logger.debug("System: Playing TTS welcome message to disable set 'ttsnoWelcome = True' in settings.ini")
|
||||
await generate_and_play_tts("Hey its Cheerpy! Thanks for using Meshing-Around on Meshtasstic!", available_voices[0])
|
||||
try:
|
||||
while True:
|
||||
if tts_read_queue:
|
||||
tts_read = tts_read_queue.pop(0)
|
||||
voice = available_voices[0]
|
||||
# ensure the tts_read ends with a punctuation mark
|
||||
if not tts_read.endswith(('.', '!', '?')):
|
||||
tts_read += '.'
|
||||
try:
|
||||
await generate_and_play_tts(tts_read, voice)
|
||||
except Exception as e:
|
||||
logger.error(f"System: TTShandler error: {e}")
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logger.critical(f"System: handleTTS crashed: {e}")
|
||||
|
||||
async def watchdog():
|
||||
global localTelemetryData, retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9
|
||||
@@ -2135,7 +2350,7 @@ async def watchdog():
|
||||
|
||||
handleMultiPing(0, i)
|
||||
|
||||
if wxAlertBroadcastEnabled or emergencyAlertBrodcastEnabled or volcanoAlertBroadcastEnabled:
|
||||
if usAlerts or checklist_enabled or enableDEalerts:
|
||||
handleAlertBroadcast(i)
|
||||
|
||||
intData = displayNodeTelemetry(0, i)
|
||||
|
||||
@@ -77,6 +77,13 @@ class TestBot(unittest.TestCase):
|
||||
self.assertTrue(result)
|
||||
self.assertIsInstance(result1, str)
|
||||
|
||||
def test_initialize_inventory_database(self):
|
||||
from inventory import initialize_inventory_database, process_inventory_command
|
||||
result = initialize_inventory_database()
|
||||
result1 = process_inventory_command(0, 'inventory', name="none")
|
||||
self.assertTrue(result)
|
||||
self.assertIsInstance(result1, str)
|
||||
|
||||
def test_init_news_sources(self):
|
||||
from filemon import initNewsSources
|
||||
result = initNewsSources()
|
||||
@@ -87,16 +94,29 @@ class TestBot(unittest.TestCase):
|
||||
alerts = get_nina_alerts()
|
||||
self.assertIsInstance(alerts, str)
|
||||
|
||||
def test_llmTool_get_google(self):
|
||||
from llm import llmTool_get_google
|
||||
result = llmTool_get_google("What is 2+2?", 1)
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
def test_send_ollama_query(self):
|
||||
from llm import send_ollama_query
|
||||
response = send_ollama_query("Hello, Ollama!")
|
||||
self.assertIsInstance(response, str)
|
||||
|
||||
def test_extract_search_terms(self):
|
||||
from llm import extract_search_terms
|
||||
# Test with capitalized terms
|
||||
terms = extract_search_terms("What is Python programming?")
|
||||
self.assertIsInstance(terms, list)
|
||||
self.assertTrue(len(terms) > 0)
|
||||
# Test with multiple capitalized words
|
||||
terms2 = extract_search_terms("Tell me about Albert Einstein and Marie Curie")
|
||||
self.assertIsInstance(terms2, list)
|
||||
self.assertTrue(len(terms2) > 0)
|
||||
|
||||
def test_get_wiki_context(self):
|
||||
from llm import get_wiki_context
|
||||
# Test with a well-known topic
|
||||
context = get_wiki_context("Python programming language")
|
||||
self.assertIsInstance(context, str)
|
||||
# Context might be empty if wiki is disabled or fails, that's ok
|
||||
|
||||
def test_get_moon_phase(self):
|
||||
from space import get_moon
|
||||
phase = get_moon(lat, lon)
|
||||
@@ -132,10 +152,13 @@ class TestBot(unittest.TestCase):
|
||||
result = initalize_qrz_database()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_get_hamlib(self):
|
||||
from radio import get_hamlib
|
||||
frequency = get_hamlib('f')
|
||||
self.assertIsInstance(frequency, str)
|
||||
def test_import_radio_module(self):
|
||||
try:
|
||||
import radio
|
||||
#frequency = get_hamlib('f')
|
||||
#self.assertIsInstance(frequency, str)
|
||||
except Exception as e:
|
||||
self.fail(f"Importing radio module failed: {e}")
|
||||
|
||||
def test_get_rss_feed(self):
|
||||
from rss import get_rss_feed
|
||||
@@ -403,6 +426,35 @@ class TestBot(unittest.TestCase):
|
||||
flood_report = get_flood_openmeteo(lat, lon)
|
||||
self.assertIsInstance(flood_report, str)
|
||||
|
||||
def test_check_callsign_match(self):
|
||||
# Test the callsign filtering function for WSJT-X/JS8Call
|
||||
from radio import check_callsign_match
|
||||
|
||||
# Test with empty filter (should match all)
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI CN87", []))
|
||||
|
||||
# Test exact match
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI CN87", ["K7MHI"]))
|
||||
|
||||
# Test case insensitive match
|
||||
self.assertTrue(check_callsign_match("CQ k7mhi CN87", ["K7MHI"]))
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI CN87", ["k7mhi"]))
|
||||
|
||||
# Test no match
|
||||
self.assertFalse(check_callsign_match("CQ W1AW FN31", ["K7MHI"]))
|
||||
|
||||
# Test multiple callsigns
|
||||
self.assertTrue(check_callsign_match("CQ W1AW FN31", ["K7MHI", "W1AW"]))
|
||||
self.assertTrue(check_callsign_match("K7MHI DE W1AW", ["K7MHI", "W1AW"]))
|
||||
|
||||
# Test portable/mobile suffixes
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI/P CN87", ["K7MHI"]))
|
||||
self.assertTrue(check_callsign_match("W1AW-7", ["W1AW"]))
|
||||
|
||||
# Test no false positives with partial matches
|
||||
self.assertFalse(check_callsign_match("CQ K7MHIX CN87", ["K7MHI"]))
|
||||
self.assertFalse(check_callsign_match("K7 TEST", ["K7MHI"]))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
78
modules/test_checklist.py
Normal file
78
modules/test_checklist.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# modules/test_checklist.py
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the parent directory to sys.path to allow module imports
|
||||
parent_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
sys.path.insert(0, parent_path)
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from checklist import process_checklist_command, initialize_checklist_database
|
||||
import time
|
||||
|
||||
class TestProcessChecklistCommand(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Always start with a fresh DB
|
||||
initialize_checklist_database()
|
||||
# Patch settings for consistent test behavior
|
||||
patcher1 = patch('modules.checklist.reverse_in_out', False)
|
||||
patcher2 = patch('modules.checklist.bbs_ban_list', [])
|
||||
patcher3 = patch('modules.checklist.bbs_admin_list', ['999'])
|
||||
self.mock_reverse = patcher1.start()
|
||||
self.mock_ban = patcher2.start()
|
||||
self.mock_admin = patcher3.start()
|
||||
self.addCleanup(patcher1.stop)
|
||||
self.addCleanup(patcher2.stop)
|
||||
self.addCleanup(patcher3.stop)
|
||||
|
||||
def test_checkin_command(self):
|
||||
result = process_checklist_command(1, "checkin test note", name="TESTUSER", location=["loc"])
|
||||
self.assertIn("Checked✅In: TESTUSER", result)
|
||||
|
||||
def test_checkout_command(self):
|
||||
# First checkin
|
||||
process_checklist_command(1, "checkin test note", name="TESTUSER", location=["loc"])
|
||||
# Then checkout
|
||||
result = process_checklist_command(1, "checkout", name="TESTUSER", location=["loc"])
|
||||
self.assertIn("Checked⌛️Out: TESTUSER", result)
|
||||
|
||||
def test_checkin_with_interval(self):
|
||||
result = process_checklist_command(1, "checkin 15 hiking", name="TESTUSER", location=["loc"])
|
||||
self.assertIn("monitoring every 15min", result)
|
||||
|
||||
def test_checkout_all(self):
|
||||
# Multiple checkins
|
||||
process_checklist_command(1, "checkin note1", name="TESTUSER", location=["loc"])
|
||||
process_checklist_command(1, "checkin note2", name="TESTUSER", location=["loc"])
|
||||
result = process_checklist_command(1, "checkout all", name="TESTUSER", location=["loc"])
|
||||
self.assertIn("Checked out", result)
|
||||
self.assertIn("check-ins for TESTUSER", result)
|
||||
|
||||
|
||||
def test_checklistapprove_nonadmin(self):
|
||||
process_checklist_command(1, "checkin foo", name="FOO", location=["loc"])
|
||||
result = process_checklist_command(2, "checklistapprove 1", name="NOTADMIN", location=["loc"])
|
||||
self.assertNotIn("approved", result)
|
||||
|
||||
def test_checklistdeny_nonadmin(self):
|
||||
process_checklist_command(1, "checkin foo", name="FOO", location=["loc"])
|
||||
result = process_checklist_command(2, "checklistdeny 1", name="NOTADMIN", location=["loc"])
|
||||
self.assertNotIn("denied", result)
|
||||
|
||||
def test_help_command(self):
|
||||
result = process_checklist_command(1, "checklist ?", name="TESTUSER", location=["loc"])
|
||||
self.assertIn("Command: checklist", result)
|
||||
|
||||
def test_checklist_listing(self):
|
||||
process_checklist_command(1, "checkin foo", name="FOO", location=["loc"])
|
||||
result = process_checklist_command(1, "checklist", name="FOO", location=["loc"])
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertIn("checked-In", result)
|
||||
|
||||
def test_invalid_command(self):
|
||||
result = process_checklist_command(1, "foobar", name="FOO", location=["loc"])
|
||||
self.assertEqual(result, "Invalid command.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
117
modules/wiki.py
117
modules/wiki.py
@@ -2,7 +2,7 @@
|
||||
|
||||
from modules.log import logger
|
||||
from modules.settings import (use_kiwix_server, kiwix_url, kiwix_library_name,
|
||||
urlTimeoutSeconds, wiki_return_limit, ERROR_FETCHING_DATA)
|
||||
urlTimeoutSeconds, wiki_return_limit, ERROR_FETCHING_DATA, wikipedia_enabled)
|
||||
#import wikipedia # pip install wikipedia
|
||||
import requests
|
||||
import bs4 as bs
|
||||
@@ -17,77 +17,63 @@ def tag_visible(element):
|
||||
return True
|
||||
|
||||
def text_from_html(body):
|
||||
"""Extract visible text from HTML content"""
|
||||
"""Extract main article text from HTML content"""
|
||||
soup = bs.BeautifulSoup(body, 'html.parser')
|
||||
texts = soup.find_all(string=True)
|
||||
# Try to find the main content div (works for both Kiwix and Wikipedia HTML)
|
||||
main = soup.find('div', class_='mw-parser-output')
|
||||
if not main:
|
||||
# Fallback: just use the body if main content div not found
|
||||
main = soup.body
|
||||
if not main:
|
||||
return ""
|
||||
texts = main.find_all(string=True)
|
||||
visible_texts = filter(tag_visible, texts)
|
||||
return " ".join(t.strip() for t in visible_texts if t.strip())
|
||||
|
||||
def get_kiwix_summary(search_term):
|
||||
"""Query local Kiwix server for Wikipedia article"""
|
||||
def get_kiwix_summary(search_term, truncate=True):
|
||||
"""Query local Kiwix server for Wikipedia article using only search results."""
|
||||
if search_term is None or search_term.strip() == "":
|
||||
return ERROR_FETCHING_DATA
|
||||
try:
|
||||
search_encoded = quote(search_term)
|
||||
# Try direct article access first
|
||||
wiki_article = search_encoded.capitalize().replace("%20", "_")
|
||||
exact_url = f"{kiwix_url}/raw/{kiwix_library_name}/content/A/{wiki_article}"
|
||||
|
||||
response = requests.get(exact_url, timeout=urlTimeoutSeconds)
|
||||
if response.status_code == 200:
|
||||
# Extract and clean text
|
||||
text = text_from_html(response.text)
|
||||
# Remove common Wikipedia metadata prefixes
|
||||
text = text.split("Jump to navigation", 1)[-1]
|
||||
text = text.split("Jump to search", 1)[-1]
|
||||
# Truncate to reasonable length (first few sentences)
|
||||
sentences = text.split('. ')
|
||||
summary = '. '.join(sentences[:wiki_return_limit])
|
||||
if summary and not summary.endswith('.'):
|
||||
summary += '.'
|
||||
return summary.strip()[:500] # Hard limit at 500 chars
|
||||
|
||||
# If direct access fails, try search
|
||||
logger.debug(f"System: Kiwix direct article not found for:{search_term} Status Code:{response.status_code}")
|
||||
search_url = f"{kiwix_url}/search?content={kiwix_library_name}&pattern={search_encoded}"
|
||||
response = requests.get(search_url, timeout=urlTimeoutSeconds)
|
||||
|
||||
|
||||
if response.status_code == 200 and "No results were found" not in response.text:
|
||||
soup = bs.BeautifulSoup(response.text, 'html.parser')
|
||||
links = [a['href'] for a in soup.find_all('a', href=True) if "start=" not in a['href']]
|
||||
|
||||
for link in links[:3]: # Check first 3 results
|
||||
article_name = link.split("/")[-1]
|
||||
if not article_name or article_name[0].islower():
|
||||
results = soup.select('div.results ul li')
|
||||
logger.debug(f"Kiwix: Found {len(results)} results in search results for:{search_term}")
|
||||
for li in results[:3]:
|
||||
a = li.find('a', href=True)
|
||||
if not a:
|
||||
continue
|
||||
|
||||
article_url = f"{kiwix_url}{link}"
|
||||
article_url = f"{kiwix_url}{a['href']}"
|
||||
article_response = requests.get(article_url, timeout=urlTimeoutSeconds)
|
||||
if article_response.status_code == 200:
|
||||
text = text_from_html(article_response.text)
|
||||
text = text.split("Jump to navigation", 1)[-1]
|
||||
text = text.split("Jump to search", 1)[-1]
|
||||
# Remove navigation and search jump text
|
||||
# text = text.split("Jump to navigation", 1)[-1]
|
||||
# text = text.split("Jump to search", 1)[-1]
|
||||
sentences = text.split('. ')
|
||||
summary = '. '.join(sentences[:wiki_return_limit])
|
||||
if summary and not summary.endswith('.'):
|
||||
summary += '.'
|
||||
return summary.strip()[:500]
|
||||
|
||||
logger.warning(f"System: No Kiwix Results for:{search_term}")
|
||||
# try to fall back to online Wikipedia if available
|
||||
return get_wikipedia_summary(search_term, force=True)
|
||||
if truncate:
|
||||
return summary.strip()[:500]
|
||||
else:
|
||||
return summary.strip()
|
||||
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"System: Kiwix connection error: {e}")
|
||||
return "Unable to connect to local wiki server"
|
||||
# Fallback to online Wikipedia
|
||||
return get_wikipedia_summary(search_term, force=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Error with Kiwix for:{search_term} {e}")
|
||||
logger.debug(f"System: No Kiwix Results for:{search_term}")
|
||||
if wikipedia_enabled:
|
||||
logger.debug("Kiwix: Falling back to Wikipedia API.")
|
||||
return get_wikipedia_summary(search_term, force=True)
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_wikipedia_summary(search_term, location=None, force=False):
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Error with Kiwix for:{search_term} URL:{search_url} {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_wikipedia_summary(search_term, location=None, force=False, truncate=True):
|
||||
if use_kiwix_server and not force:
|
||||
return get_kiwix_summary(search_term)
|
||||
|
||||
@@ -105,22 +91,45 @@ def get_wikipedia_summary(search_term, location=None, force=False):
|
||||
return ERROR_FETCHING_DATA
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Check for error response from Wikipedia API
|
||||
logger.debug(f"Wikipedia API response for '{search_term}': {len(data)} keys")
|
||||
if "extract" not in data or not data.get("extract"):
|
||||
logger.warning(f"System: Wikipedia API returned no extract for:{search_term} (data: {data})")
|
||||
#logger.debug(f"System: Wikipedia API returned no extract for:{search_term} (data: {data})")
|
||||
return ERROR_FETCHING_DATA
|
||||
if data.get("type") == "disambiguation" or "may refer to:" in data.get("extract", ""):
|
||||
#logger.warning(f"System: Disambiguation page for:{search_term} (data: {data})")
|
||||
# Fetch and parse the HTML disambiguation page
|
||||
html_url = f"https://en.wikipedia.org/wiki/{requests.utils.quote(search_term)}"
|
||||
html_resp = requests.get(html_url, timeout=5, headers=headers)
|
||||
if html_resp.status_code == 200:
|
||||
soup = bs.BeautifulSoup(html_resp.text, 'html.parser')
|
||||
items = soup.select('div.mw-parser-output ul li a[href^="/wiki/"]')
|
||||
choices = []
|
||||
for a in items:
|
||||
title = a.get('title')
|
||||
href = a.get('href')
|
||||
# Filter out non-article links
|
||||
if title and href and ':' not in href:
|
||||
choices.append(f"{title} (https://en.wikipedia.org{href})")
|
||||
if len(choices) >= 5:
|
||||
break
|
||||
if choices:
|
||||
return f"'{search_term}' is ambiguous. Did you mean:\n- " + "\n- ".join(choices)
|
||||
return f"'{search_term}' is ambiguous. Please be more specific. See: {html_url}"
|
||||
summary = data.get("extract")
|
||||
if not summary or not isinstance(summary, str) or not summary.strip():
|
||||
logger.warning(f"System: No summary found for:{search_term}")
|
||||
#logger.debug(f"System: No summary found for:{search_term} (data: {data})")
|
||||
return ERROR_FETCHING_DATA
|
||||
sentences = [s for s in summary.split('. ') if s.strip()]
|
||||
if not sentences:
|
||||
logger.warning(f"System: Wikipedia summary split produced no sentences for:{search_term}")
|
||||
return ERROR_FETCHING_DATA
|
||||
summary = '. '.join(sentences[:wiki_return_limit])
|
||||
if summary and not summary.endswith('.'):
|
||||
summary += '.'
|
||||
return summary.strip()[:500]
|
||||
if truncate:
|
||||
# Truncate to 500 characters
|
||||
return summary.strip()[:500]
|
||||
else:
|
||||
return summary.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Wikipedia API error for:{search_term} {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
29
pong_bot.py
29
pong_bot.py
@@ -65,7 +65,11 @@ def handle_cmd(message, message_from_id, deviceID):
|
||||
def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number):
|
||||
global multiPing
|
||||
if "?" in message and isDM:
|
||||
return message.split("?")[0].title() + " command returns SNR and RSSI, or hopcount from your message. Try adding e.g. @place or #tag"
|
||||
pingHelp = "🤖Ping Command Help:\n" \
|
||||
"🏓 Send 'ping' or 'ack' or 'test' to get a response.\n" \
|
||||
"🏓 Send 'ping <number>' to get multiple pings in DM"
|
||||
"🏓 ping @USERID to send a Joke from the bot"
|
||||
return pingHelp
|
||||
|
||||
msg = ""
|
||||
type = ''
|
||||
@@ -303,10 +307,21 @@ def onReceive(packet, interface):
|
||||
# set the message_from_id
|
||||
message_from_id = packet['from']
|
||||
|
||||
# check if the packet has a channel flag use it
|
||||
if packet.get('channel'):
|
||||
channel_number = packet.get('channel', 0)
|
||||
# if message_from_id is not in the seenNodes list add it
|
||||
if not any(node.get('nodeID') == message_from_id for node in seenNodes):
|
||||
seenNodes.append({'nodeID': message_from_id, 'rxInterface': rxNode, 'channel': channel_number, 'welcome': False, 'first_seen': time.time(), 'lastSeen': time.time()})
|
||||
else:
|
||||
# update lastSeen time
|
||||
for node in seenNodes:
|
||||
if node.get('nodeID') == message_from_id:
|
||||
node['lastSeen'] = time.time()
|
||||
break
|
||||
|
||||
# CHECK with ban_hammer() if the node is banned
|
||||
if str(message_from_id) in my_settings.bbs_ban_list or str(message_from_id) in my_settings.autoBanlist:
|
||||
logger.warning(f"System: Banned Node {message_from_id} tried to send a message. Ignored. Try adding to node firmware-blocklist")
|
||||
return
|
||||
|
||||
# handle TEXT_MESSAGE_APP
|
||||
try:
|
||||
if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
|
||||
@@ -379,7 +394,7 @@ def onReceive(packet, interface):
|
||||
logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start} calculated_hop_count:{hop_count} final_hop_value:{hop} via_mqtt:{via_mqtt} transport_mechanism:{transport_mechanism} Hostname:{rxNodeHostName}")
|
||||
|
||||
# check with stringSafeChecker if the message is safe
|
||||
if stringSafeCheck(message_string) is False:
|
||||
if stringSafeCheck(message_string, message_from_id) is False:
|
||||
logger.warning(f"System: Possibly Unsafe Message from {get_name_from_number(message_from_id, 'long', rxNode)}")
|
||||
|
||||
if help_message in message_string or welcome_message in message_string or "CMD?:" in message_string:
|
||||
@@ -574,6 +589,10 @@ def handle_boot(mesh=True):
|
||||
if my_settings.useDMForResponse:
|
||||
logger.debug("System: Respond by DM only")
|
||||
|
||||
if my_settings.autoBanEnabled:
|
||||
logger.debug(f"System: Auto-Ban Enabled for {my_settings.autoBanThreshold} messages in {my_settings.autoBanTimeframe} seconds")
|
||||
load_bbsBanList()
|
||||
|
||||
if my_settings.log_messages_to_file:
|
||||
logger.debug("System: Logging Messages to disk")
|
||||
if my_settings.syslog_to_file:
|
||||
|
||||
@@ -7,5 +7,4 @@ maidenhead
|
||||
beautifulsoup4
|
||||
dadjokes
|
||||
geopy
|
||||
schedule
|
||||
googlesearch-python
|
||||
schedule
|
||||
@@ -1,22 +1,4 @@
|
||||
## script/runShell.sh
|
||||
|
||||
**Purpose:**
|
||||
`runShell.sh` is a simple demo shell script for the Mesh Bot project. It demonstrates how to execute shell commands within the project’s scripting environment.
|
||||
|
||||
**Usage:**
|
||||
Run this script from the terminal to see a basic example of shell scripting in the project context.
|
||||
|
||||
```sh
|
||||
bash script/runShell.sh
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Changes the working directory to the script’s location.
|
||||
- Prints the current directory path and a message indicating the script is running.
|
||||
- Serves as a template for creating additional shell scripts or automating tasks related to the project.
|
||||
|
||||
**Note:**
|
||||
You can modify this script to add more shell commands or automation steps as needed for your workflow.
|
||||
|
||||
## script/runShell.sh
|
||||
|
||||
@@ -57,4 +39,64 @@ bash script/sysEnv.sh
|
||||
- Designed to work on Linux systems, with special handling for Raspberry Pi hardware.
|
||||
|
||||
**Note:**
|
||||
You can expand or modify this script to include additional telemetry or environment checks as needed for your deployment.
|
||||
You can expand or modify this script to include additional telemetry or environment checks as needed for your deployment.
|
||||
|
||||
## script/configMerge.py
|
||||
|
||||
**Purpose:**
|
||||
`configMerge.py` is a Python script that merges your user configuration (`config.ini`) with the default template (`config.template`). This helps you keep your settings up to date when the default configuration changes, while preserving your customizations.
|
||||
|
||||
**Usage:**
|
||||
Run this script from the project root or the `script/` directory:
|
||||
|
||||
```sh
|
||||
python3 script/configMerge.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Backs up your current `config.ini` to `config.bak`.
|
||||
- Merges new or updated settings from `config.template` into your `config.ini`.
|
||||
- Saves the merged result as `config_new.ini`.
|
||||
- Shows a summary of changes between your config and the merged version.
|
||||
|
||||
**Note:**
|
||||
After reviewing the changes, you can replace your `config.ini` with the merged version:
|
||||
|
||||
```sh
|
||||
cp config_new.ini config.ini
|
||||
```
|
||||
|
||||
This script is useful for safely updating your configuration when new options are added upstream.
|
||||
|
||||
## script/addFav.py
|
||||
|
||||
**Purpose:**
|
||||
`addFav.py` is a Python script to help manage and add favorite nodes to all interfaces using data from `config.ini`. It supports both bot and roof (client_base) node workflows, making it easier to retain DM keys and manage node lists across devices.
|
||||
|
||||
**Usage:**
|
||||
Run this script from the main repo directory:
|
||||
|
||||
```sh
|
||||
python3 script/addFav.py
|
||||
```
|
||||
|
||||
- To print the contents of `roofNodeList.pkl` and exit, use:
|
||||
```sh
|
||||
# note it is not production ready
|
||||
python3 script/addFav.py -p
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Interactively asks if you are running on a roof (client_base) node or a bot.
|
||||
- On the bot:
|
||||
- Compiles a list of favorite nodes and saves it to `roofNodeList.pkl` for later use on the roof node.
|
||||
- On the roof node:
|
||||
- Loads the node list from `roofNodeList.pkl`.
|
||||
- Shows which favorite nodes will be added and asks for confirmation.
|
||||
- Adds favorite nodes to the appropriate devices, handling API rate limits.
|
||||
- Logs actions and errors for troubleshooting.
|
||||
|
||||
**Note:**
|
||||
- Always run this script from the main repo directory to ensure module imports work.
|
||||
- After running on the bot, copy `roofNodeList.pkl` to the roof node and rerun the script there to complete the process.
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ This is not a full turnkey setup for Docker yet?
|
||||
|
||||
`docker compose run meshing-around`
|
||||
|
||||
`docker compose run debug-console`
|
||||
|
||||
`docker compose run ollama`
|
||||
|
||||
|
||||
|
||||
|
||||
`docker compose run debug-console`
|
||||
`docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=http://127.0.0.1:11434 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main`
|
||||
15
script/game.ini
Normal file
15
script/game.ini
Normal file
@@ -0,0 +1,15 @@
|
||||
[network]
|
||||
MCAST_GRP = 224.0.0.69
|
||||
MCAST_PORT = 4403
|
||||
CHANNEL_ID = LongFast
|
||||
KEY = 1PG7OiApB1nwvP+rz05pAQ==
|
||||
PUBLIC_CHANNEL_IDS = LongFast,ShortSlow,Medium,LongSlow,ShortFast,ShortTurbo
|
||||
|
||||
[node]
|
||||
NODE_ID = !meshbotg
|
||||
LONG_NAME = Mesh Bot Game Server
|
||||
SHORT_NAME = MBGS
|
||||
|
||||
[game]
|
||||
SEEN_MESSAGES_MAX = 1000
|
||||
FULLSCREEN = True
|
||||
199
script/game_serve.py
Normal file
199
script/game_serve.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# UDP Interface game server for Meshtastic Meshing-Around Mesh Bot
|
||||
# depends on: pip install meshtastic protobuf mudp
|
||||
# 2025 Kelly Keeton K7MHI
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
import configparser
|
||||
|
||||
useSynchCompression = True
|
||||
|
||||
if useSynchCompression:
|
||||
import zlib
|
||||
|
||||
try:
|
||||
from pubsub import pub
|
||||
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
||||
except ImportError:
|
||||
print("meshtastic API not found. pip install -U meshtastic")
|
||||
exit(1)
|
||||
|
||||
try:
|
||||
from mudp import UDPPacketStream, node, conn
|
||||
from mudp.encryption import generate_hash
|
||||
except ImportError:
|
||||
print("mUDP module not found. pip install -U mudp")
|
||||
exit(1)
|
||||
try:
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from modules.games.tictactoe_vid import handle_tictactoe_payload, ttt_main
|
||||
except Exception as e:
|
||||
print(f"Error importing modules: {e}\nRun this program from the main project directory, e.g. 'python3 script/game_serve.py'")
|
||||
exit(1)
|
||||
|
||||
# import logging
|
||||
|
||||
# logger = logging.getLogger("MeshBot Game Server")
|
||||
# logger.setLevel(logging.DEBUG)
|
||||
# logger.propagate = False
|
||||
|
||||
# # Remove any existing handlers
|
||||
# if logger.hasHandlers():
|
||||
# logger.handlers.clear()
|
||||
|
||||
# handler = logging.StreamHandler(sys.stdout)
|
||||
# logger.addHandler(handler)
|
||||
# logger.debug("Mesh Bot Game Server Logger initialized")
|
||||
|
||||
# Load config from game.ini if it exists
|
||||
config = configparser.ConfigParser()
|
||||
config_path = os.path.join(os.path.dirname(__file__), "game.ini")
|
||||
if os.path.exists(config_path):
|
||||
config.read(config_path)
|
||||
MCAST_GRP = config.get("network", "MCAST_GRP", fallback="224.0.0.69")
|
||||
MCAST_PORT = config.getint("network", "MCAST_PORT", fallback=4403)
|
||||
CHANNEL_ID = config.get("network", "CHANNEL_ID", fallback="LongFast")
|
||||
KEY = config.get("network", "KEY", fallback="1PG7OiApB1nwvP+rz05pAQ==")
|
||||
PUBLIC_CHANNEL_IDS = [x.strip() for x in config.get("network", "PUBLIC_CHANNEL_IDS", fallback="LongFast,ShortSlow,Medium,LongSlow,ShortFast,ShortTurbo").split(",")]
|
||||
NODE_ID = config.get("node", "NODE_ID", fallback="!meshbotg")
|
||||
LONG_NAME = config.get("node", "LONG_NAME", fallback="Mesh Bot Game Server")
|
||||
SHORT_NAME = config.get("node", "SHORT_NAME", fallback="MBGS")
|
||||
SEEN_MESSAGES_MAX = config.getint("game", "SEEN_MESSAGES_MAX", fallback=1000)
|
||||
FULLSCREEN = config.getboolean("game", "FULLSCREEN", fallback=True)
|
||||
else:
|
||||
MCAST_GRP, MCAST_PORT, CHANNEL_ID, KEY = "224.0.0.69", 4403, "LongFast", "1PG7OiApB1nwvP+rz05pAQ=="
|
||||
PUBLIC_CHANNEL_IDS = ["LongFast", "ShortSlow", "Medium", "LongSlow", "ShortFast", "ShortTurbo"]
|
||||
NODE_ID, LONG_NAME, SHORT_NAME = "!meshbotg", "Mesh Bot Game Server", "MBGS"
|
||||
SEEN_MESSAGES_MAX = 1000 # Adjust as needed
|
||||
FULLSCREEN = True
|
||||
|
||||
CHANNEL_HASHES = {generate_hash(name, KEY): name for name in PUBLIC_CHANNEL_IDS}
|
||||
mudpEnabled, mudpInterface = True, None
|
||||
seen_messages = OrderedDict() # Track seen (from, to, payload) tuples
|
||||
is_running = False
|
||||
|
||||
def initalize_mudp():
|
||||
global mudpInterface
|
||||
if mudpEnabled and mudpInterface is None:
|
||||
mudpInterface = UDPPacketStream(MCAST_GRP, MCAST_PORT, key=KEY)
|
||||
node.node_id, node.long_name, node.short_name = NODE_ID, LONG_NAME, SHORT_NAME
|
||||
node.channel, node.key = CHANNEL_ID, KEY
|
||||
conn.setup_multicast(MCAST_GRP, MCAST_PORT)
|
||||
print(f"mUDP Interface initialized on {MCAST_GRP}:{MCAST_PORT} with Channel ID '{CHANNEL_ID}'")
|
||||
print(f"Node ID: {NODE_ID}, Long Name: {LONG_NAME}, Short Name: {SHORT_NAME}")
|
||||
print("Public Channel IDs:", PUBLIC_CHANNEL_IDS)
|
||||
|
||||
def get_channel_name(channel_hash):
|
||||
return CHANNEL_HASHES.get(channel_hash, '')
|
||||
|
||||
def add_seen_message(msg_tuple):
|
||||
if msg_tuple not in seen_messages:
|
||||
if len(seen_messages) >= SEEN_MESSAGES_MAX:
|
||||
seen_messages.popitem(last=False) # Remove oldest
|
||||
seen_messages[msg_tuple] = None
|
||||
|
||||
def compress_payload(data: str) -> bytes:
|
||||
"""Compress a string to bytes using zlib if enabled."""
|
||||
if useSynchCompression:
|
||||
return zlib.compress(data.encode("utf-8"))
|
||||
else:
|
||||
return data.encode("utf-8")
|
||||
|
||||
def decompress_payload(data: bytes) -> str:
|
||||
"""Decompress bytes to string using zlib if enabled, fallback to utf-8 if not compressed."""
|
||||
if useSynchCompression:
|
||||
try:
|
||||
return zlib.decompress(data).decode("utf-8")
|
||||
except Exception:
|
||||
return data.decode("utf-8", "ignore")
|
||||
else:
|
||||
return data.decode("utf-8", "ignore")
|
||||
|
||||
def on_private_app(packet: mesh_pb2.MeshPacket, addr=None):
|
||||
global seen_messages
|
||||
packet_payload = ""
|
||||
packet_from_id = None
|
||||
if packet.HasField("decoded"):
|
||||
try:
|
||||
# Try to decompress, fallback to decode if not compressed
|
||||
packet_payload = decompress_payload(packet.decoded.payload)
|
||||
packet_from_id = getattr(packet, 'from', None)
|
||||
port_name = portnums_pb2.PortNum.Name(packet.decoded.portnum) if packet.decoded.portnum else "N/A"
|
||||
rx_channel = get_channel_name(packet.channel)
|
||||
if packet_payload.startswith("MTTT:"):
|
||||
packet_payload = packet_payload[5:] # remove 'MTTT:'
|
||||
msg_tuple = (getattr(packet, 'from', None), packet.to, packet_payload)
|
||||
if msg_tuple not in seen_messages:
|
||||
add_seen_message(msg_tuple)
|
||||
handle_tictactoe_payload(packet_payload, from_id=packet_from_id)
|
||||
print(f"[Channel: {rx_channel}] [Port: {port_name}] Tic-Tac-Toe Message payload:", packet_payload)
|
||||
else:
|
||||
msg_tuple = (getattr(packet, 'from', None), packet.to, packet_payload)
|
||||
if msg_tuple not in seen_messages:
|
||||
add_seen_message(msg_tuple)
|
||||
print(f"[Channel: {rx_channel}] [Port: {port_name}] Private App payload:", packet_payload)
|
||||
except Exception:
|
||||
print(" Private App extraction error payload (raw bytes):", packet.decoded.payload)
|
||||
|
||||
def on_text_message(packet: mesh_pb2.MeshPacket, addr=None):
|
||||
global seen_messages
|
||||
try:
|
||||
packet_payload = ""
|
||||
if packet.HasField("decoded"):
|
||||
rx_channel = get_channel_name(packet.channel)
|
||||
port_name = portnums_pb2.PortNum.Name(packet.decoded.portnum) if packet.decoded.portnum else "N/A"
|
||||
try:
|
||||
# Try to decompress, fallback to decode if not compressed
|
||||
packet_payload = decompress_payload(packet.decoded.payload)
|
||||
msg_tuple = (getattr(packet, 'from', None), packet.to, packet_payload)
|
||||
if msg_tuple not in seen_messages:
|
||||
add_seen_message(msg_tuple)
|
||||
#print(f"[Channel: {rx_channel}] [Port: {port_name}] TEXT Message payload:", packet_payload)
|
||||
except Exception:
|
||||
print(" extraction error payload (raw bytes):", packet.decoded.payload)
|
||||
except Exception as e:
|
||||
print("Error processing received packet:", e)
|
||||
|
||||
# def on_recieve(packet: mesh_pb2.MeshPacket, addr=None):
|
||||
# print(f"\n[RECV] Packet received from {addr}")
|
||||
# print(packet)
|
||||
#pub.subscribe(on_recieve, "mesh.rx.packet")
|
||||
pub.subscribe(on_text_message, "mesh.rx.port.1") # TEXT_MESSAGE
|
||||
pub.subscribe(on_private_app, "mesh.rx.port.256") # PRIVATE_APP DEFAULT_PORTNUM
|
||||
|
||||
def main():
|
||||
global mudpInterface, is_running
|
||||
print(r"""
|
||||
___
|
||||
/ \
|
||||
| HOT | Mesh Bot Display Server v0.9.5
|
||||
| TOT | (aka tot-bot)
|
||||
\___/
|
||||
|
||||
""")
|
||||
print("Press escape (ESC) key to exit")
|
||||
initalize_mudp() # initialize MUDP interface
|
||||
mudpInterface.start()
|
||||
is_running = True
|
||||
try:
|
||||
while is_running:
|
||||
ttt_main(fullscreen=FULLSCREEN)
|
||||
is_running = False
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n[INFO] KeyboardInterrupt received. Shutting down Mesh Bot Game Server...")
|
||||
is_running = False
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Exception during main loop: {e}")
|
||||
finally:
|
||||
print("[INFO] Stopping mUDP interface...")
|
||||
if mudpInterface:
|
||||
mudpInterface.stop()
|
||||
print("[INFO] mUDP interface stopped.")
|
||||
print("[INFO] Mesh Bot Game Server shutdown complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
106
update.sh
Normal file → Executable file
106
update.sh
Normal file → Executable file
@@ -2,37 +2,38 @@
|
||||
# MeshBot Update Script
|
||||
# Usage: bash update.sh or ./update.sh after making it executable with chmod +x update.sh
|
||||
|
||||
# Check if the mesh_bot.service or pong_bot.service
|
||||
service_stopped=false
|
||||
if systemctl is-active --quiet mesh_bot.service; then
|
||||
echo "Stopping mesh_bot.service..."
|
||||
systemctl stop mesh_bot.service
|
||||
service_stopped=true
|
||||
fi
|
||||
if systemctl is-active --quiet pong_bot.service; then
|
||||
echo "Stopping pong_bot.service..."
|
||||
systemctl stop pong_bot.service
|
||||
service_stopped=true
|
||||
fi
|
||||
if systemctl is-active --quiet mesh_bot_reporting.service; then
|
||||
echo "Stopping mesh_bot_reporting.service..."
|
||||
systemctl stop mesh_bot_reporting.service
|
||||
service_stopped=true
|
||||
fi
|
||||
if systemctl is-active --quiet mesh_bot_w3.service; then
|
||||
echo "Stopping mesh_bot_w3.service..."
|
||||
systemctl stop mesh_bot_w3.service
|
||||
service_stopped=true
|
||||
fi
|
||||
echo "=============================================="
|
||||
echo " MeshBot Automated Update & Backup Tool "
|
||||
echo "=============================================="
|
||||
echo
|
||||
|
||||
# Fetch latest changes from GitHub
|
||||
# --- Service Management ---
|
||||
service_stopped=false
|
||||
for svc in mesh_bot.service pong_bot.service mesh_bot_reporting.service mesh_bot_w3.service; do
|
||||
if systemctl is-active --quiet "$svc"; then
|
||||
echo ">> Stopping $svc ..."
|
||||
systemctl stop "$svc"
|
||||
service_stopped=true
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Git Operations ---
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Fetching latest changes from GitHub..."
|
||||
echo "----------------------------------------------"
|
||||
if ! git fetch origin; then
|
||||
echo "Error: Failed to fetch from GitHub, check your network connection."
|
||||
echo "ERROR: Failed to fetch from GitHub. Check your network connection. Script expects to be run inside a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# git pull with rebase to avoid unnecessary merge commits
|
||||
if [[ $(git symbolic-ref --short -q HEAD) == "" ]]; then
|
||||
echo "WARNING: You are in a detached HEAD state."
|
||||
echo "You may not be on a branch. To return to the main branch, run:"
|
||||
echo " git checkout main"
|
||||
echo "Proceed with caution; changes may not be saved to a branch."
|
||||
fi
|
||||
|
||||
echo "Pulling latest changes from GitHub..."
|
||||
if ! git pull origin main --rebase; then
|
||||
read -p "Git pull resulted in conflicts. Do you want to reset hard to origin/main? This will discard local changes. (y/n): " choice
|
||||
@@ -45,34 +46,59 @@ if ! git pull origin main --rebase; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# copy modules/custom_scheduler.py template if it does not exist
|
||||
|
||||
if [[ ! -f modules/custom_scheduler.py ]]; then
|
||||
# --- Scheduler Template ---
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Checking custom scheduler template..."
|
||||
echo "----------------------------------------------"
|
||||
cp -n etc/custom_scheduler.py modules/
|
||||
printf "\nCustom scheduler template copied to modules/custom_scheduler.py\n"
|
||||
elif ! cmp -s modules/custom_scheduler.py etc/custom_scheduler.py; then
|
||||
printf "Custom scheduler template copied to modules/custom_scheduler.py\n"
|
||||
elif ! cmp -s modules/custom_scheduler.template etc/custom_scheduler.py; then
|
||||
echo "custom_scheduler.py is set. To check changes run: diff etc/custom_scheduler.py modules/custom_scheduler.py"
|
||||
fi
|
||||
|
||||
# Backup the data/ directory
|
||||
# --- Data Templates ---
|
||||
if [[ -d data ]]; then
|
||||
mkdir -p data
|
||||
for f in etc/data/*; do
|
||||
base=$(basename "$f")
|
||||
if [[ ! -e "data/$base" ]]; then
|
||||
if [[ -d "$f" ]]; then
|
||||
cp -r "$f" "data/"
|
||||
echo "Copied new data/directory $base"
|
||||
else
|
||||
cp "$f" "data/"
|
||||
echo "Copied new data/$base"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Backup ---
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Backing up data/ directory..."
|
||||
#backup_file="backup_$(date +%Y%m%d_%H%M%S).tar.gz"
|
||||
echo "----------------------------------------------"
|
||||
backup_file="data_backup.tar.gz"
|
||||
path2backup="data/"
|
||||
#copy custom_scheduler.py if it exists
|
||||
if [[ -f "modules/custom_scheduler.py" ]]; then
|
||||
echo "Including custom_scheduler.py in backup..."
|
||||
cp modules/custom_scheduler.py data/
|
||||
fi
|
||||
tar -czf "$backup_file" "$path2backup"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Backup failed."
|
||||
echo "ERROR: Backup failed."
|
||||
else
|
||||
echo "Backup of ${path2backup} completed: ${backup_file}"
|
||||
fi
|
||||
|
||||
|
||||
# Build a config_new.ini file merging user config with new defaults
|
||||
# --- Config Merge ---
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Merging configuration files..."
|
||||
echo "----------------------------------------------"
|
||||
python3 script/configMerge.py > ini_merge_log.txt 2>&1
|
||||
if [[ -f ini_merge_log.txt ]]; then
|
||||
if grep -q "Error during configuration merge" ini_merge_log.txt; then
|
||||
@@ -81,11 +107,15 @@ if [[ -f ini_merge_log.txt ]]; then
|
||||
echo "Configuration merge completed. Please review config_new.ini and ini_merge_log.txt."
|
||||
fi
|
||||
else
|
||||
echo "Configuration merge log (ini_merge_log.txt) not found. check out the script/configMerge.py tool!"
|
||||
echo "Configuration merge log (ini_merge_log.txt) not found. Check out the script/configMerge.py tool!"
|
||||
fi
|
||||
|
||||
# --- Service Restart ---
|
||||
if [[ "$service_stopped" = true ]]; then
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Restarting services..."
|
||||
echo "----------------------------------------------"
|
||||
for svc in mesh_bot.service pong_bot.service mesh_bot_reporting.service mesh_bot_w3.service; do
|
||||
if systemctl list-unit-files | grep -q "^$svc"; then
|
||||
systemctl start "$svc"
|
||||
@@ -94,7 +124,9 @@ if [[ "$service_stopped" = true ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Print completion message
|
||||
echo "Update completed successfully?"
|
||||
echo
|
||||
echo "=============================================="
|
||||
echo " MeshBot Update Completed Successfully! "
|
||||
echo "=============================================="
|
||||
exit 0
|
||||
# End of script
|
||||
Reference in New Issue
Block a user