mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-03-28 17:32:36 +01:00
Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cfb45d2b1 | ||
|
|
0fb351ef4d | ||
|
|
2f6abade80 | ||
|
|
5247f8d9d3 | ||
|
|
b36059183c | ||
|
|
f737e401a5 | ||
|
|
98b5f4fb7f | ||
|
|
17fa03ff9d | ||
|
|
40aaa7202c | ||
|
|
5088397856 | ||
|
|
db1c31579c | ||
|
|
dcf1b8f3cc | ||
|
|
2a7000a2e6 | ||
|
|
aa0aaed0b5 | ||
|
|
9db4dc8ab9 | ||
|
|
85e8f41dca | ||
|
|
ddb123b759 | ||
|
|
10afde663e | ||
|
|
c931d13e6e | ||
|
|
ba6075b616 | ||
|
|
68c065825b | ||
|
|
213f121807 | ||
|
|
530d78482a | ||
|
|
09515b9bc0 | ||
|
|
9b8c9d80c8 | ||
|
|
8ee838f5c6 | ||
|
|
757d6d30b8 | ||
|
|
1ee785d388 | ||
|
|
c3284f0a0f | ||
|
|
bdcc479360 | ||
|
|
b1444b24e4 | ||
|
|
aef67da492 | ||
|
|
b8b8145447 | ||
|
|
42a4842a5b | ||
|
|
201591d469 | ||
|
|
4ecdc7b108 | ||
|
|
3f78bf7a67 | ||
|
|
8af21b760c | ||
|
|
ea3ed46e86 | ||
|
|
d78d6acd1e | ||
|
|
e9b483f4e8 | ||
|
|
94660e7993 | ||
|
|
12aeaef250 | ||
|
|
2a6f76ab5b | ||
|
|
05df1e1a3c | ||
|
|
38131b4180 | ||
|
|
397c39b13d | ||
|
|
af7dfe8a51 | ||
|
|
d5d163aab9 | ||
|
|
58cc3e4314 | ||
|
|
3274dfdbc0 | ||
|
|
84a1a163d3 | ||
|
|
289eb70738 | ||
|
|
a6d51e41bf | ||
|
|
a63020bbb7 | ||
|
|
2416e73fbf | ||
|
|
f87f34f8bf | ||
|
|
eaed034d20 | ||
|
|
ec9ac1b1fe | ||
|
|
e84ce13878 | ||
|
|
a5fc8aca82 | ||
|
|
c31947194e | ||
|
|
c79f3cdfbc | ||
|
|
14b876b989 | ||
|
|
2cc5b23753 | ||
|
|
a5b0fda3ac | ||
|
|
9c5c332e01 | ||
|
|
ac5e96e463 | ||
|
|
0ce7deb740 | ||
|
|
a60333318b | ||
|
|
665acaa904 | ||
|
|
0aa8bccd04 | ||
|
|
2e5e8a7589 | ||
|
|
e3e6393bad | ||
|
|
be38588292 | ||
|
|
14fb3f9cb6 | ||
|
|
c40cd86592 | ||
|
|
69df48957e | ||
|
|
e29573ebc0 | ||
|
|
13b9b75f86 | ||
|
|
0bfe908391 | ||
|
|
5baee422c2 | ||
|
|
38ff05fd40 | ||
|
|
e1def5422a | ||
|
|
93031010cb | ||
|
|
21e614ab8e | ||
|
|
a5322867e3 | ||
|
|
2863a64ec8 | ||
|
|
678fde7b2c | ||
|
|
ec0f9f966c | ||
|
|
fd114301f6 | ||
|
|
1778cb6feb | ||
|
|
fc7ca37184 | ||
|
|
fe2110ca2b | ||
|
|
179113e83a | ||
|
|
79348be644 | ||
|
|
35c6232b0c | ||
|
|
2aa7ffb0e8 | ||
|
|
a7060bc516 | ||
|
|
998d979d71 | ||
|
|
cdfb451d67 | ||
|
|
994405955a | ||
|
|
17d92dc78d | ||
|
|
d0e33f943f | ||
|
|
f55c7311fa | ||
|
|
5f4f832af6 | ||
|
|
c3e8f4a93e | ||
|
|
e72b3c191e | ||
|
|
3774b8407b | ||
|
|
0e074a6885 | ||
|
|
c8800d837f | ||
|
|
289ada1fc0 | ||
|
|
e64e60358d | ||
|
|
658fb33b69 | ||
|
|
1568d026f2 | ||
|
|
232bf98efd | ||
|
|
3cce938334 | ||
|
|
e6a17d9258 | ||
|
|
d403e4c8c0 | ||
|
|
ae19c5b83f | ||
|
|
532efda9e8 | ||
|
|
d20eab03e9 | ||
|
|
df43b61a0c | ||
|
|
862347cbec | ||
|
|
9c412b8328 | ||
|
|
c3fcacd64b | ||
|
|
68b5de2950 | ||
|
|
f578ba6084 | ||
|
|
961bb3abba | ||
|
|
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 |
8
.github/workflows/docker-image.yml
vendored
8
.github/workflows/docker-image.yml
vendored
@@ -25,10 +25,10 @@ jobs:
|
||||
#
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
# 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@28fdb31ff34708d19615a74d67103ddc2ea9725c
|
||||
uses: docker/login-action@3227f5311cb93ffd14d13e65d8cc400d30f4dd8a
|
||||
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@032a4b3bda1b716928481836ac5bfe36e1feaad6
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
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@9e436ba9f2d7bcd1d038c8e55d039d37896ddc5d
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
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!"
|
||||
37
.gitignore
vendored
37
.gitignore
vendored
@@ -2,39 +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
|
||||
|
||||
# checklist and inventory databases
|
||||
data/checklist.db
|
||||
data/inventory.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__/
|
||||
|
||||
@@ -52,6 +52,7 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http
|
||||
- **Customizable Triggers**: Use proximity events for creative applications like "king of the hill" or 🧭 geocache games by adjusting the alert cycle.
|
||||
- **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).
|
||||
- **YOLOv5 alerts**: Use camera modules to detect objects or OCR
|
||||
|
||||
### EAS Alerts
|
||||
- **FEMA iPAWS/EAS Alerts**: Receive Emergency Alerts from FEMA via API on internet-connected nodes.
|
||||
@@ -72,6 +73,7 @@ Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](http
|
||||
- **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.
|
||||
- **Offline Tone out Decoder**: Decode fire Tone out and DTMF and action with alerts to mesh
|
||||
|
||||
### 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.
|
||||
@@ -100,7 +102,7 @@ Advanced check-in/check-out and asset tracking for people and equipment—ideal
|
||||
- **Automatic Message Chunking**: Messages over 160 characters are automatically split to ensure reliable delivery across multiple hops.
|
||||
|
||||
## Getting Started
|
||||
This project is developed on Linux (specifically a Raspberry Pi) but should work on any platform where the [Meshtastic protobuf API](https://meshtastic.org/docs/software/python/cli/) modules are supported, and with any compatible [Meshtastic](https://meshtastic.org/docs/getting-started/) hardware. For pico or low-powered devices, see projects for embedding, armbian or [buildroot](https://github.com/buildroot-meshtastic/buildroot-meshtastic), also see [femtofox](https://github.com/noon92/femtofox) for running on luckfox hardware. If you need a local console consider the [firefly](https://github.com/pdxlocations/firefly) project.
|
||||
This project is developed on Linux (specifically a Raspberry Pi) but should work on any platform where the [Meshtastic protobuf API](https://meshtastic.org/docs/software/python/cli/) modules are supported, and with any compatible [Meshtastic](https://meshtastic.org/docs/getting-started/) hardware, however it is **recomended to use the latest firmware code**. For pico or low-powered devices, see projects for embedding, armbian or [buildroot](https://github.com/buildroot-meshtastic/buildroot-meshtastic), also see [femtofox](https://github.com/noon92/femtofox) for running on luckfox hardware. If you need a local console consider the [firefly](https://github.com/pdxlocations/firefly) project.
|
||||
|
||||
🥔 Please use responsibly and follow local rulings for such equipment. This project captures packets, logs them, and handles over the air communications which can include PII such as GPS locations.
|
||||
|
||||
@@ -172,6 +174,7 @@ For testing and feature ideas on Discord and GitHub, if its stable its thanks to
|
||||
- **mrpatrick1991**: For OG Docker configurations. 💻
|
||||
- **A-c0rN**: Assistance with iPAWS and 🚨
|
||||
- **Mike O'Connell/skrrt**: For [eas_alert_parser](etc/eas_alert_parser.py) enhanced by **sheer.cold**
|
||||
- **dadud**: For idea on [etc/icad_tone.py](etc/icad_tone.py)
|
||||
- **WH6GXZ nurse dude**: Volcano Alerts 🌋
|
||||
- **mikecarper**: hamtest, leading to quiz etc.. 📋
|
||||
- **c.merphy360**: high altitude alerts. 🚀
|
||||
|
||||
@@ -62,7 +62,7 @@ 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 headline command which uses NewsAPI.org key at https://newsapi.org/register
|
||||
# enable or disable the 'latest' command which uses NewsAPI.org key at https://newsapi.org/register
|
||||
enableNewsAPI = False
|
||||
newsAPI_KEY =
|
||||
newsAPIregion = us
|
||||
@@ -79,31 +79,29 @@ kiwixURL = http://127.0.0.1:8080
|
||||
# Kiwix library name (e.g., wikipedia_en_100_nopic_2025-09)
|
||||
kiwixLibraryName = wikipedia_en_100_nopic_2025-09
|
||||
|
||||
# Enable ollama LLM see more at https://ollama.com
|
||||
# Enable LLM local Ollama integration, set true for any LLM support
|
||||
ollama = False
|
||||
# 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)
|
||||
# Ollama 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 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)
|
||||
# Use OpenWebUI instead of direct Ollama API / still leave ollama = True
|
||||
useOpenWebUI = False
|
||||
# OpenWebUI server URL (e.g., http://localhost:3000)
|
||||
openWebUIURL = http://localhost:3000
|
||||
# OpenWebUI API key/token (required when useOpenWebUI is True)
|
||||
openWebUIAPIKey =
|
||||
|
||||
# Ollama model to use (defaults to gemma3:270m) gemma2 is good for older SYSTEM prompt
|
||||
# ollamaModel is used for both Ollama and OpenWebUI when useOpenWebUI its just the model name
|
||||
# ollamaModel = gemma3:latest
|
||||
# ollamaModel = gemma2:2b
|
||||
# if True, the query is sent raw to the LLM, if False uses internal SYSTEM prompt
|
||||
rawLLMQuery = True
|
||||
|
||||
# If False, the LLM only replies to the "ask:" and "askai" commands. otherwise DM's automatically go to LLM
|
||||
llmReplyToNonCommands = 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
|
||||
|
||||
# StoreForward Enabled and Limits
|
||||
StoreForward = True
|
||||
StoreLimit = 3
|
||||
@@ -325,6 +323,7 @@ value =
|
||||
# interval to use when time is not set (e.g. every 2 days)
|
||||
interval =
|
||||
# time of day in 24:00 hour format when value is 'day' and interval is not set
|
||||
# Process run :00,:20,:40 try and vary the 20 minute offsets to avoid collision
|
||||
time =
|
||||
|
||||
[radioMon]
|
||||
@@ -392,8 +391,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
|
||||
@@ -446,6 +447,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
|
||||
@@ -481,3 +483,17 @@ 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
|
||||
|
||||
[dataPersistence]
|
||||
# Enable or disable the data persistence loop service
|
||||
enabled = True
|
||||
# Interval in seconds for the persistence loop (how often to save data)
|
||||
interval = 300
|
||||
@@ -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 |
@@ -1,264 +0,0 @@
|
||||
# Implementation Summary: Enhanced Check-in/Check-out and Point of Sale System
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation addresses the GitHub issue requesting enhancements to the check-in/check-out system and the addition of a complete Point of Sale (POS) functionality to the meshing-around project.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Enhanced Check-in/Check-out System
|
||||
|
||||
#### New Features Added:
|
||||
- **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**:
|
||||
- `checklistapprove <id>` - Approve pending check-ins (admin)
|
||||
- `checklistdeny <id>` - Deny/remove check-ins (admin)
|
||||
- Support for approval-based workflows
|
||||
|
||||
- **Enhanced Database Schema**:
|
||||
- Added `approved` field for approval workflows
|
||||
- Added `expected_checkin_interval` field for safety monitoring
|
||||
- Automatic migration for existing databases
|
||||
|
||||
#### New Commands:
|
||||
- `checklistapprove <id>` - Approve a check-in
|
||||
- `checklistdeny <id>` - Deny a check-in
|
||||
- Enhanced `checkin [interval] [note]` - Now supports interval parameter
|
||||
|
||||
### 2. Complete Point of Sale System
|
||||
|
||||
#### Features Implemented:
|
||||
|
||||
**Item Management:**
|
||||
- Add items with price, quantity, and location
|
||||
- Remove items from inventory
|
||||
- Update item prices and quantities
|
||||
- Quick sell functionality
|
||||
- Transaction returns/reversals
|
||||
- Full inventory listing with valuations
|
||||
|
||||
**Cart System:**
|
||||
- Per-user shopping carts
|
||||
- Add/remove items from cart
|
||||
- View cart with totals
|
||||
- Complete transactions (buy/sell)
|
||||
- Clear cart functionality
|
||||
|
||||
**Financial Features:**
|
||||
- Penny rounding support (USA mode)
|
||||
- Cash sales round down to nearest nickel
|
||||
- Taxed sales round up to nearest nickel
|
||||
- Transaction logging with full audit trail
|
||||
- Daily sales statistics
|
||||
- Revenue tracking
|
||||
- Hot item detection (best sellers)
|
||||
|
||||
**Database Schema:**
|
||||
Four tables for complete functionality:
|
||||
- `items` - Product inventory
|
||||
- `transactions` - Sales records
|
||||
- `transaction_items` - Line items per transaction
|
||||
- `carts` - Temporary shopping carts
|
||||
|
||||
#### Commands Implemented:
|
||||
|
||||
**Item Management:**
|
||||
- `itemadd <name> <price> <qty> [location]` - Add new item
|
||||
- `itemremove <name>` - Remove item
|
||||
- `itemreset <name> [price=X] [qty=Y]` - Update item
|
||||
- `itemsell <name> <qty> [notes]` - Quick sale
|
||||
- `itemreturn <transaction_id>` - Reverse transaction
|
||||
- `itemlist` - View all inventory
|
||||
- `itemstats` - Daily statistics
|
||||
|
||||
**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
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files:
|
||||
1. **modules/inventory.py** (625 lines)
|
||||
- Complete inventory and POS module
|
||||
- All item management functions
|
||||
- Cart system implementation
|
||||
- Transaction processing
|
||||
- Penny rounding logic
|
||||
|
||||
2. **modules/inventory.md** (8,529 chars)
|
||||
- Comprehensive user guide
|
||||
- Command reference
|
||||
- Use case examples
|
||||
- Database schema documentation
|
||||
|
||||
3. **modules/checklist.md** (9,058 chars)
|
||||
- Enhanced checklist user guide
|
||||
- Safety monitoring documentation
|
||||
- Best practices
|
||||
- Scenario examples
|
||||
|
||||
### Modified Files:
|
||||
1. **modules/checklist.py**
|
||||
- Added time interval monitoring
|
||||
- Added approval workflow functions
|
||||
- Enhanced database schema
|
||||
- Updated command processing
|
||||
|
||||
2. **modules/settings.py**
|
||||
- Added inventory configuration section
|
||||
- Added `inventory_enabled` setting
|
||||
- Added `inventory_db` path setting
|
||||
- Added `disable_penny` setting
|
||||
|
||||
3. **config.template**
|
||||
- Added `[inventory]` section
|
||||
- Documentation for penny rounding
|
||||
|
||||
4. **modules/system.py**
|
||||
- Integrated inventory module
|
||||
- Added trap list for inventory commands
|
||||
|
||||
5. **mesh_bot.py**
|
||||
- Added inventory command handlers
|
||||
- Added checklist approval commands
|
||||
- Created `handle_inventory()` function
|
||||
|
||||
6. **modules/README.md**
|
||||
- Updated checklist section with new features
|
||||
- Added complete inventory/POS section
|
||||
- Updated table of contents
|
||||
|
||||
7. **.gitignore**
|
||||
- Added database files to ignore list
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable Inventory System:
|
||||
```ini
|
||||
[inventory]
|
||||
enabled = True
|
||||
inventory_db = data/inventory.db
|
||||
disable_penny = False # Set to True for USA penny rounding
|
||||
```
|
||||
|
||||
### Checklist Already Configured:
|
||||
```ini
|
||||
[checklist]
|
||||
enabled = False # Set to True to enable
|
||||
checklist_db = data/checklist.db
|
||||
reverse_in_out = False
|
||||
```
|
||||
|
||||
## Testing Results
|
||||
|
||||
All functionality tested and verified:
|
||||
- ✅ Module imports work correctly
|
||||
- ✅ Database initialization successful
|
||||
- ✅ Inventory commands function properly
|
||||
- ✅ Cart system working as expected
|
||||
- ✅ Checklist enhancements operational
|
||||
- ✅ Time interval monitoring active
|
||||
- ✅ Trap lists properly registered
|
||||
- ✅ Help commands return correct information
|
||||
|
||||
## Use Cases Addressed
|
||||
|
||||
### From Issue Comments:
|
||||
|
||||
1. **Point of Sale Logic** ✅
|
||||
- Complete POS system with inventory management
|
||||
- Cart-based transactions
|
||||
- Sales tracking and reporting
|
||||
|
||||
2. **Check-in Time Windows** ✅
|
||||
- Interval-based monitoring
|
||||
- Overdue detection
|
||||
- Safety accountability for solo activities
|
||||
|
||||
3. **Geo-location Awareness** ✅
|
||||
- Automatic GPS capture when checking in/out
|
||||
- Location stored with each check-in
|
||||
- Foundation for "are you ok" alerts
|
||||
|
||||
4. **Asset Management** ✅
|
||||
- Track any type of asset (tools, equipment, supplies)
|
||||
- Multiple locations support
|
||||
- Full transaction history
|
||||
|
||||
5. **Penny Rounding** ✅
|
||||
- Configurable USA cash sale rounding
|
||||
- Separate logic for cash vs taxed sales
|
||||
- Down for cash, up for tax
|
||||
|
||||
## Security Features
|
||||
|
||||
- Users on `bbs_ban_list` cannot use inventory or checklist commands
|
||||
- Admin-only approval commands
|
||||
- Parameterized SQL queries prevent injection
|
||||
- Per-user cart isolation
|
||||
- Full transaction audit trail
|
||||
|
||||
## Documentation Provided
|
||||
|
||||
1. **User Guides:**
|
||||
- Comprehensive inventory.md with examples
|
||||
- Detailed checklist.md with safety scenarios
|
||||
- Updated main README.md
|
||||
|
||||
2. **Technical Documentation:**
|
||||
- Database schema details
|
||||
- Configuration examples
|
||||
- Command reference
|
||||
- API documentation in code comments
|
||||
|
||||
3. **Examples:**
|
||||
- Emergency supply tracking
|
||||
- Event merchandise sales
|
||||
- Field equipment management
|
||||
- Safety monitoring scenarios
|
||||
|
||||
## Future Enhancement Opportunities
|
||||
|
||||
The implementation provides foundation for:
|
||||
- Scheduled overdue check-in alerts
|
||||
- Email/SMS notifications for overdue status
|
||||
- Dashboard/reporting interface
|
||||
- Barcode/QR code support
|
||||
- Multi-location inventory tracking
|
||||
- Inventory forecasting
|
||||
- Integration with external systems
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing checklist databases automatically migrate
|
||||
- New features are opt-in via configuration
|
||||
- No breaking changes to existing commands
|
||||
- Graceful handling of missing database columns
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- SQLite databases for reliability and simplicity
|
||||
- Indexed primary keys for fast lookups
|
||||
- Efficient query design
|
||||
- Minimal memory footprint
|
||||
- No external dependencies beyond stdlib
|
||||
|
||||
## Conclusion
|
||||
|
||||
This implementation fully addresses all requirements from the GitHub issue:
|
||||
- ✅ Enhanced check-in/check-out with SQL improvements
|
||||
- ✅ Point of sale logic with inventory management
|
||||
- ✅ Time window notifications for safety
|
||||
- ✅ Asset tracking for any item type
|
||||
- ✅ Penny rounding for USA cash sales
|
||||
- ✅ Cart management system
|
||||
- ✅ Comprehensive documentation
|
||||
|
||||
The system is production-ready, well-tested, and documented for immediate use.
|
||||
@@ -72,4 +72,61 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## icad_tone.py
|
||||
|
||||
**Purpose:**
|
||||
`icad_tone.py` is a utility script for detecting fire and EMS radio tones using the [icad_tone_detection](https://github.com/thegreatcodeholio/icad_tone_detection) library. It analyzes audio from a live stream, soundcard, or WAV file, identifies various tone types (such as two-tone, long tone, hi/low, pulsed, MDC, and DTMF), and writes detected alerts to `alert.txt` for integration with Mesh Bot or Meshtastic.
|
||||
|
||||
**Usage:**
|
||||
Run the script from the command line, specifying a WAV file for offline analysis or configuring it to listen to a stream or soundcard for real-time monitoring.
|
||||
|
||||
```sh
|
||||
python etc/icad_tone.py --wav path/to/file.wav
|
||||
```
|
||||
Or, for live monitoring (after setting `HTTP_STREAM_URL` in the script):
|
||||
```sh
|
||||
python etc/icad_tone.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Loads audio from a stream, soundcard, or WAV file.
|
||||
- Uses `icad_tone_detection` to analyze audio for tone patterns.
|
||||
- Prints raw detection results and summaries to the console.
|
||||
- Writes a summary of detected tones to `alert.txt` (overwriting each time).
|
||||
- Handles errors and missing dependencies gracefully.
|
||||
|
||||
**Configuration:**
|
||||
- `ALERT_FILE_PATH`: Path to the alert output file (default: `alert.txt`).
|
||||
- `AUDIO_SOURCE`: Set to `"http"` for streaming or `"soundcard"` for local audio input.
|
||||
- `HTTP_STREAM_URL`: URL of the audio stream (required if using HTTP source).
|
||||
- `SAMPLE_RATE`, `INPUT_CHANNELS`, `CHUNK_DURATION`: Audio processing parameters.
|
||||
|
||||
**Note:**
|
||||
- Requires installation of dependencies (`icad_tone_detection`)
|
||||
- Set `HTTP_STREAM_URL` to a valid stream if using HTTP mode.
|
||||
- Intended for experimental or hobbyist use; may require customization for your workflow.
|
||||
@@ -15,6 +15,7 @@ def setup_custom_schedules(send_message, tell_joke, welcome_message, handle_wxc,
|
||||
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.
|
||||
6. System Tasks run every 20min try and avoid overlapping schedules to reduce API rapid fire issues. use like 8:05
|
||||
|
||||
Available functions you can import and use, be sure they are enabled modules in config.ini:
|
||||
- tell_joke() - Returns a random joke
|
||||
@@ -94,6 +95,8 @@ def setup_custom_schedules(send_message, tell_joke, welcome_message, handle_wxc,
|
||||
#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
|
||||
|
||||
@@ -959,18 +959,6 @@
|
||||
"To relay messages between satellites"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E2A13",
|
||||
"correct": 1,
|
||||
"refs": "",
|
||||
"question": "Which of the following techniques is used by digital satellites to relay messages?",
|
||||
"answers": [
|
||||
"Digipeating",
|
||||
"Store-and-forward",
|
||||
"Multisatellite relaying",
|
||||
"Node hopping"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E2B01",
|
||||
"correct": 0,
|
||||
@@ -2495,18 +2483,6 @@
|
||||
"Utilizing a Class D final amplifier"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E4D05",
|
||||
"correct": 0,
|
||||
"refs": "",
|
||||
"question": "What transmitter frequencies would create an intermodulation-product signal in a receiver tuned to 146.70 MHz when a nearby station transmits on 146.52 MHz?",
|
||||
"answers": [
|
||||
"146.34 MHz and 146.61 MHz",
|
||||
"146.88 MHz and 146.34 MHz",
|
||||
"146.10 MHz and 147.30 MHz",
|
||||
"146.30 MHz and 146.90 MHz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E4D06",
|
||||
"correct": 2,
|
||||
@@ -3851,18 +3827,6 @@
|
||||
"Permeability"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E6D07",
|
||||
"correct": 3,
|
||||
"refs": "",
|
||||
"question": "What is the current that flows in the primary winding of a transformer when there is no load on the secondary winding?",
|
||||
"answers": [
|
||||
"Stabilizing current",
|
||||
"Direct current",
|
||||
"Excitation current",
|
||||
"Magnetizing current"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E6D08",
|
||||
"correct": 1,
|
||||
@@ -35,18 +35,6 @@
|
||||
"12 meters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G1A04",
|
||||
"correct": 3,
|
||||
"refs": "[97.303(h)]",
|
||||
"question": "Which of the following amateur bands is restricted to communication only on specific channels, rather than frequency ranges?",
|
||||
"answers": [
|
||||
"11 meters",
|
||||
"12 meters",
|
||||
"30 meters",
|
||||
"60 meters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G1A05",
|
||||
"correct": 0,
|
||||
@@ -347,18 +335,6 @@
|
||||
"Submit a rule-making proposal to the FCC describing the codes and methods of the technique"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G1C09",
|
||||
"correct": 2,
|
||||
"refs": "[97.313(i)]",
|
||||
"question": "What is the maximum power limit on the 60-meter band?",
|
||||
"answers": [
|
||||
"1500 watts PEP",
|
||||
"10 watts RMS",
|
||||
"ERP of 100 watts PEP with respect to a dipole",
|
||||
"ERP of 100 watts PEP with respect to an isotropic antenna"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G1C11",
|
||||
"correct": 3,
|
||||
@@ -611,18 +587,6 @@
|
||||
"1500 watts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G1E09",
|
||||
"correct": 0,
|
||||
"refs": "[97.115]",
|
||||
"question": "Under what circumstances are messages that are sent via digital modes exempt from Part 97 third-party rules that apply to other modes of communication?",
|
||||
"answers": [
|
||||
"Under no circumstances",
|
||||
"When messages are encrypted",
|
||||
"When messages are not encrypted",
|
||||
"When under automatic control"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G1E10",
|
||||
"correct": 0,
|
||||
@@ -4079,18 +4043,6 @@
|
||||
"All these choices are correct"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G8C01",
|
||||
"correct": 2,
|
||||
"refs": "",
|
||||
"question": "On what band do amateurs share channels with the unlicensed Wi-Fi service?",
|
||||
"answers": [
|
||||
"432 MHz",
|
||||
"902 MHz",
|
||||
"2.4 GHz",
|
||||
"10.7 GHz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "G8C02",
|
||||
"correct": 0,
|
||||
222
etc/icad_tone.py
Normal file
222
etc/icad_tone.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# icad_tone.py - uses icad_tone_detection, for fire and EMS tone detection
|
||||
# https://github.com/thegreatcodeholio/icad_tone_detection
|
||||
# output to alert.txt for meshing-around bot
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
|
||||
# ---------------------------
|
||||
# User Configuration Section
|
||||
# ---------------------------
|
||||
ALERT_FILE_PATH = "alert.txt" # Path to alert log file, or None to disable logging
|
||||
AUDIO_SOURCE = "soundcard" # "soundcard" for mic/line-in, "http" for stream
|
||||
HTTP_STREAM_URL = "" # Set to your stream URL if using "http"
|
||||
SAMPLE_RATE = 16000 # Audio sample rate (Hz)
|
||||
INPUT_CHANNELS = 1 # Number of input channels (1=mono)
|
||||
MIN_SAMPLES = 4096 # Minimum samples per detection window (increase for better accuracy)
|
||||
STREAM_BUFFER = 32000 # Number of bytes to buffer before detection (for MP3 streams)
|
||||
INPUT_DEVICE = 0 # Set to device index or name, or None for default
|
||||
# ---------------------------
|
||||
|
||||
import sys
|
||||
import time
|
||||
from icad_tone_detection import tone_detect
|
||||
from pydub import AudioSegment
|
||||
import requests
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import argparse
|
||||
import io
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message="nperseg = .* is greater than input length")
|
||||
def write_alert(message):
|
||||
if ALERT_FILE_PATH:
|
||||
try:
|
||||
with open(ALERT_FILE_PATH, "w") as f: # overwrite each time
|
||||
f.write(message + "\n")
|
||||
except Exception as e:
|
||||
print(f"Error writing to alert file: {e}", file=sys.stderr)
|
||||
|
||||
def detect_and_alert(audio_data, sample_rate):
|
||||
try:
|
||||
result = tone_detect(audio_data, sample_rate)
|
||||
except Exception as e:
|
||||
print(f"Detection error: {e}", file=sys.stderr)
|
||||
return
|
||||
# Only print if something is detected
|
||||
if result and any(getattr(result, t, []) for t in [
|
||||
"two_tone_result", "long_result", "hi_low_result", "pulsed_result", "mdc_result", "dtmf_result"
|
||||
]):
|
||||
print("Raw detection result:", result)
|
||||
# Prepare alert summary for all relevant tone types
|
||||
summary = []
|
||||
if hasattr(result, "dtmf_result") and result.dtmf_result:
|
||||
for dtmf in result.dtmf_result:
|
||||
summary.append(f"DTMF Digit: {dtmf.get('digit', '?')} | Duration: {dtmf.get('length', '?')}s")
|
||||
if hasattr(result, "hi_low_result") and result.hi_low_result:
|
||||
for hl in result.hi_low_result:
|
||||
summary.append(
|
||||
f"Hi/Low Alternations: {hl.get('alternations', '?')} | Duration: {hl.get('length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "mdc_result") and result.mdc_result:
|
||||
for mdc in result.mdc_result:
|
||||
summary.append(
|
||||
f"MDC UnitID: {mdc.get('unitID', '?')} | Op: {mdc.get('op', '?')} | Duration: {mdc.get('length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "pulsed_result") and result.pulsed_result:
|
||||
for pl in result.pulsed_result:
|
||||
summary.append(
|
||||
f"Pulsed Tone: {pl.get('detected', '?')}Hz | Cycles: {pl.get('cycles', '?')} | Duration: {pl.get('length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "two_tone_result") and result.two_tone_result:
|
||||
for tt in result.two_tone_result:
|
||||
summary.append(
|
||||
f"Two-Tone: {tt.get('detected', ['?','?'])[0]}Hz/{tt.get('detected', ['?','?'])[1]}Hz | Tone A: {tt.get('tone_a_length', '?')}s | Tone B: {tt.get('tone_b_length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "long_result") and result.long_result:
|
||||
for lt in result.long_result:
|
||||
summary.append(
|
||||
f"Long Tone: {lt.get('detected', '?')}Hz | Duration: {lt.get('length', '?')}s"
|
||||
)
|
||||
if summary:
|
||||
write_alert("\n".join(summary))
|
||||
|
||||
def get_supported_sample_rate(device, channels=1):
|
||||
# Try common sample rates
|
||||
for rate in [44100, 48000, 16000, 8000]:
|
||||
try:
|
||||
sd.check_input_settings(device=device, channels=channels, samplerate=rate)
|
||||
return rate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def main():
|
||||
print("="*80)
|
||||
print(" iCAD Tone Decoder for Meshing-Around Booting Up!")
|
||||
if AUDIO_SOURCE == "soundcard":
|
||||
try:
|
||||
if INPUT_DEVICE is not None:
|
||||
sd.default.device = INPUT_DEVICE
|
||||
device_info = sd.query_devices(INPUT_DEVICE, kind='input')
|
||||
else:
|
||||
device_info = sd.query_devices(sd.default.device, kind='input')
|
||||
device_name = device_info['name']
|
||||
# Detect supported sample rate
|
||||
detected_rate = get_supported_sample_rate(sd.default.device, INPUT_CHANNELS)
|
||||
if detected_rate:
|
||||
SAMPLE_RATE = detected_rate
|
||||
else:
|
||||
print("No supported sample rate found, using default.", file=sys.stderr)
|
||||
except Exception:
|
||||
device_name = "Unknown"
|
||||
print(f" Mode: Soundcard | Device: {device_name} | Sample Rate: {SAMPLE_RATE} Hz | Channels: {INPUT_CHANNELS}")
|
||||
elif AUDIO_SOURCE == "http":
|
||||
print(f" Mode: HTTP Stream | URL: {HTTP_STREAM_URL} | Buffer: {STREAM_BUFFER} bytes")
|
||||
else:
|
||||
print(f" Mode: {AUDIO_SOURCE}")
|
||||
print("="*80)
|
||||
time.sleep(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description="ICAD Tone Detection")
|
||||
parser.add_argument("--wav", type=str, help="Path to WAV file for detection")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.wav:
|
||||
print(f"Processing WAV file: {args.wav}")
|
||||
try:
|
||||
audio = AudioSegment.from_file(args.wav)
|
||||
if audio.channels > 1:
|
||||
audio = audio.set_channels(1)
|
||||
print(f"AudioSegment: channels={audio.channels}, frame_rate={audio.frame_rate}, duration={len(audio)}ms")
|
||||
detect_and_alert(audio, audio.frame_rate)
|
||||
except Exception as e:
|
||||
print(f"Error processing WAV file: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
print("Starting ICAD Tone Detection...")
|
||||
|
||||
if AUDIO_SOURCE == "http":
|
||||
if not HTTP_STREAM_URL or HTTP_STREAM_URL.startswith("http://your-stream-url-here"):
|
||||
print("ERROR: Please set a valid HTTP_STREAM_URL or provide a WAV file using --wav option.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(f"Listening to HTTP stream: {HTTP_STREAM_URL}")
|
||||
try:
|
||||
response = requests.get(HTTP_STREAM_URL, stream=True, timeout=10)
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=4096):
|
||||
buffer.write(chunk)
|
||||
# Use STREAM_BUFFER for detection window
|
||||
if buffer.tell() > STREAM_BUFFER:
|
||||
buffer.seek(0)
|
||||
audio = AudioSegment.from_file(buffer, format="mp3")
|
||||
if audio.channels > 1:
|
||||
audio = audio.set_channels(1)
|
||||
# --- Simple audio level detection ---
|
||||
samples = np.array(audio.get_array_of_samples())
|
||||
if samples.dtype != np.float32:
|
||||
samples = samples.astype(np.float32) / 32767.0 # Normalize to -1..1
|
||||
rms = np.sqrt(np.mean(samples**2))
|
||||
if rms > 0.01:
|
||||
print(f"Audio detected! RMS: {rms:.3f} ", end='\r')
|
||||
if rms > 0.5:
|
||||
print(f"WARNING: Audio too loud! RMS: {rms:.3f} ", end='\r')
|
||||
# --- End audio level detection ---
|
||||
detect_and_alert(audio, audio.frame_rate)
|
||||
buffer = io.BytesIO()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped by user.")
|
||||
sys.exit(0)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Connection error: {e}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
except Exception as e:
|
||||
print(f"Error processing HTTP stream: {e}", file=sys.stderr)
|
||||
sys.exit(4)
|
||||
elif AUDIO_SOURCE == "soundcard":
|
||||
print("Listening to audio device:")
|
||||
buffer = np.array([], dtype=np.float32)
|
||||
min_samples = MIN_SAMPLES # Use configured minimum samples
|
||||
|
||||
def callback(indata, frames, time_info, status):
|
||||
nonlocal buffer
|
||||
try:
|
||||
samples = indata[:, 0]
|
||||
buffer = np.concatenate((buffer, samples))
|
||||
# --- Simple audio level detection ---
|
||||
rms = np.sqrt(np.mean(samples**2))
|
||||
if rms > 0.01:
|
||||
print(f"Audio detected! RMS: {rms:.3f} ", end='\r')
|
||||
if rms > 0.5:
|
||||
print(f"WARNING: Audio too loud! RMS: {rms:.3f} ", end='\r')
|
||||
# --- End audio level detection ---
|
||||
# Only process when buffer is large enough
|
||||
while buffer.size >= min_samples:
|
||||
int_samples = np.int16(buffer[:min_samples] * 32767)
|
||||
audio = AudioSegment(
|
||||
data=int_samples.tobytes(),
|
||||
sample_width=2,
|
||||
frame_rate=SAMPLE_RATE,
|
||||
channels=1
|
||||
)
|
||||
detect_and_alert(audio, SAMPLE_RATE)
|
||||
buffer = buffer[min_samples:] # keep remainder for next window
|
||||
except Exception as e:
|
||||
print(f"Callback error: {e}", file=sys.stderr)
|
||||
try:
|
||||
with sd.InputStream(samplerate=SAMPLE_RATE, channels=INPUT_CHANNELS, dtype='float32', callback=callback):
|
||||
print("Press Ctrl+C to stop.")
|
||||
import signal
|
||||
signal.pause() # Wait for Ctrl+C, keeps CPU usage minimal
|
||||
except KeyboardInterrupt:
|
||||
print("Stopped by user.")
|
||||
except Exception as e:
|
||||
print(f"Error accessing soundcard: {e}", file=sys.stderr)
|
||||
sys.exit(5)
|
||||
else:
|
||||
print("Unknown AUDIO_SOURCE. Set to 'http' or 'soundcard'.", file=sys.stderr)
|
||||
sys.exit(6)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,9 +13,10 @@ 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
|
||||
|
||||
@@ -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,9 +13,10 @@ 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
|
||||
|
||||
@@ -9,15 +9,18 @@ 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; then
|
||||
if ! id "$TARGET_USER" >/dev/null 2>&1; then
|
||||
echo "User '$TARGET_USER' does not exist."
|
||||
read -p "Would you like to use the current user ($(logname)) instead? [y/N]: " yn
|
||||
if [[ "$yn" =~ ^[Yy]$ ]]; then
|
||||
TARGET_USER="$(logname)"
|
||||
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; then
|
||||
if ! id "$TARGET_USER" >/dev/null 2>&1; then
|
||||
echo "Current user '$TARGET_USER' does not exist or cannot be determined."
|
||||
exit 1
|
||||
fi
|
||||
@@ -27,14 +30,24 @@ if ! id "$TARGET_USER" &>/dev/null; then
|
||||
fi
|
||||
fi
|
||||
|
||||
id "$TARGET_USER"
|
||||
|
||||
echo "Setting ownership to $TARGET_USER:$TARGET_USER"
|
||||
|
||||
chown -R "$TARGET_USER:$TARGET_USER" "/opt/meshing-around/-around"
|
||||
chown -R "$TARGET_USER:$TARGET_USER" "/opt/meshing-around/-around/logs"
|
||||
chown -R "$TARGET_USER:$TARGET_USER" "/opt/meshing-around/-around/data"
|
||||
chown "$TARGET_USER:$TARGET_USER" "/opt/meshing-around/-around/config.ini"
|
||||
chmod 640 "/opt/meshing-around/-around/config.ini"
|
||||
chmod 750 "/opt/meshing-around/-around/logs"
|
||||
chmod 750 "/opt/meshing-around/-around/data"
|
||||
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."
|
||||
213
etc/yolo_vision.py
Normal file
213
etc/yolo_vision.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/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 `sudo apt install imx500-all`
|
||||
# NVIDIA GPU PyTorch: https://developer.nvidia.com/cuda-downloads
|
||||
# OCR with Tesseract: https://tesseract-ocr.github.io/tessdoc/Installation.html. `sudo apt-get install tesseract-ocr`
|
||||
# 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
|
||||
OCR_PROCESSING_ENABLED = True # Whether to perform OCR on detected objects
|
||||
SAVE_EVIDENCE_IMAGES = True # Whether to save evidence images when OCR text is found in bbox
|
||||
EVIDENCE_IMAGE_DIR = "." # Change to desired directory, e.g., "/opt/meshing-around/data/images"
|
||||
EVIDENCE_IMAGE_PATTERN = "evidence_{timestamp}.png"
|
||||
|
||||
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 os
|
||||
import datetime
|
||||
if OCR_PROCESSING_ENABLED:
|
||||
import pytesseract # pip install pytesseract
|
||||
|
||||
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("="*80)
|
||||
print(f" Sentinal Vision 3000 Booting Up!")
|
||||
print(f" Model: {YOLO_MODEL} | Camera: {CAMERA_TYPE} | Resolution: {RESOLUTION} | OCR: {'Enabled' if OCR_PROCESSING_ENABLED else 'Disabled'}")
|
||||
print("="*80)
|
||||
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")
|
||||
|
||||
def extract_text_from_bbox(img, bbox):
|
||||
try:
|
||||
cropped = img.crop((bbox[0], bbox[1], bbox[2], bbox[3]))
|
||||
text = pytesseract.image_to_string(cropped, config="--psm 7")
|
||||
text_stripped = text.strip()
|
||||
if text_stripped and SAVE_EVIDENCE_IMAGES:
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
image_path = os.path.join(EVIDENCE_IMAGE_DIR, EVIDENCE_IMAGE_PATTERN.format(timestamp=timestamp))
|
||||
cropped.save(image_path)
|
||||
print(f"Saved evidence image: {image_path}")
|
||||
return f"{text_stripped}"
|
||||
except Exception as e:
|
||||
print(f"Error during OCR: {e}")
|
||||
print("More at https://tesseract-ocr.github.io/tessdoc/Installation.html")
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
# OCR on detected region
|
||||
bbox = [row['xmin'], row['ymin'], row['xmax'], row['ymax']]
|
||||
if OCR_PROCESSING_ENABLED:
|
||||
ocr_text = extract_text_from_bbox(img, bbox)
|
||||
|
||||
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:
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
__builtins__.stationary_reported.add(obj_id)
|
||||
else:
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
else:
|
||||
direction = "moving right" if delta > 0 else "moving left"
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
__builtins__.stationary_reported.discard(obj_id)
|
||||
else:
|
||||
direction = "detected"
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
|
||||
# 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!")
|
||||
242
install.sh
242
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
|
||||
|
||||
@@ -54,6 +56,14 @@ if [[ $NOPE -eq 1 ]]; then
|
||||
sudo rm -f /etc/systemd/system/ollama.service
|
||||
sudo rm -rf /usr/local/bin/ollama
|
||||
sudo rm -rf ~/.ollama
|
||||
# remove ollama service account if exists
|
||||
if id ollama &>/dev/null; then
|
||||
sudo userdel ollama || true
|
||||
fi
|
||||
# remove ollama group if exists
|
||||
if getent group ollama &>/dev/null; then
|
||||
sudo groupdel ollama || true
|
||||
fi
|
||||
echo "Ollama removed."
|
||||
else
|
||||
echo "Ollama not removed."
|
||||
@@ -66,48 +76,84 @@ 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 have git and curl installed
|
||||
if ! command -v git &> /dev/null
|
||||
then
|
||||
printf "git not found, trying 'apt-get install git'\n"
|
||||
sudo apt-get install git
|
||||
fi
|
||||
if ! command -v curl &> /dev/null
|
||||
then
|
||||
printf "curl not found, trying 'apt-get install curl'\n"
|
||||
sudo apt-get install curl
|
||||
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"
|
||||
sudo git config --global --add safe.directory /opt/meshing-around
|
||||
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 +183,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
|
||||
@@ -157,6 +209,10 @@ if [[ ! -f modules/custom_scheduler.py ]]; then
|
||||
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 +222,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 +293,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 +311,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,10 +351,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 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 && \
|
||||
@@ -334,6 +405,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
|
||||
@@ -395,25 +470,20 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
|
||||
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "\n older chron statment to run the report generator hourly:\n" >> install_notes.txt
|
||||
printf "0 * * * * /usr/bin/python3 $program_path/etc/report_generator5.py" >> install_notes.txt
|
||||
printf " to edit crontab run 'crontab -e'\n" >> install_notes.txt
|
||||
#printf "0 * * * * /usr/bin/python3 $program_path/etc/report_generator5.py" >> install_notes.txt
|
||||
#printf " to edit crontab run 'crontab -e'\n" >> install_notes.txt
|
||||
printf "\nmesh_bot_reporting.timer installed to run daily at 4:20 am\n" >> install_notes.txt
|
||||
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 "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
|
||||
@@ -426,21 +496,6 @@ else
|
||||
# add service dependency for meshtasticd into service file
|
||||
#replace="s|After=network.target|After=network.target meshtasticd.service|g"
|
||||
|
||||
# Set up the meshing around service
|
||||
sudo cp /opt/meshing-around/etc/$service.service /etc/systemd/system/$service.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable $service.service
|
||||
sudo systemctl start $service.service
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
# # check if the cron job already exists
|
||||
# if ! crontab -l | grep -q "$chronjob"; then
|
||||
# # add the cron job to run the report_generator5.py script
|
||||
# (crontab -l 2>/dev/null; echo "$chronjob") | crontab -
|
||||
# printf "\nAdded cron job to run report_generator5.py\n"
|
||||
# else
|
||||
# printf "\nCron job already exists, skipping\n"
|
||||
# fi
|
||||
# document the service install
|
||||
printf "Reference following commands:\n\n" > install_notes.txt
|
||||
printf "sudo systemctl status %s.service\n" "$service" >> install_notes.txt
|
||||
@@ -451,25 +506,35 @@ else
|
||||
printf "sudo systemctl stop %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "older crontab to run the report generator hourly:" >> install_notes.txt
|
||||
printf "0 * * * * /usr/bin/python3 $program_path/etc/report_generator5.py" >> install_notes.txt
|
||||
printf " to edit crontab run 'crontab -e'" >> install_notes.txt
|
||||
#printf "0 * * * * /usr/bin/python3 $program_path/etc/report_generator5.py" >> install_notes.txt
|
||||
#printf " to edit crontab run 'crontab -e'" >> install_notes.txt
|
||||
printf "\nmesh_bot_reporting.timer installed to run daily at 4:20 am\n" >> install_notes.txt
|
||||
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
|
||||
|
||||
sudo chown -R "$whoami:$whoami" "$program_path/logs"
|
||||
sudo chown -R "$whoami:$whoami" "$program_path/data"
|
||||
sudo chown "$whoami:$whoami" "$program_path/config.ini"
|
||||
sudo chmod 640 "$program_path/config.ini"
|
||||
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 750 "$program_path/logs"
|
||||
sudo chmod 750 "$program_path/data"
|
||||
sudo chmod 775 "$program_path/logs"
|
||||
sudo chmod 775 "$program_path/data"
|
||||
echo "Permissions set for meshbot on logs and data directories"
|
||||
|
||||
printf "\nInstallation complete?\n"
|
||||
|
||||
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
|
||||
@@ -512,6 +577,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
|
||||
|
||||
|
||||
380
mesh_bot.py
380
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,
|
||||
@@ -84,7 +85,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
|
||||
"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),
|
||||
"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),
|
||||
@@ -108,7 +109,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
|
||||
"setsms": lambda: handle_sms( message_from_id, message),
|
||||
"sitrep": lambda: handle_lheard(message, message_from_id, deviceID, isDM),
|
||||
"sms:": lambda: handle_sms(message_from_id, message),
|
||||
"solar": lambda: drap_xray_conditions() + "\n" + solar_conditions(),
|
||||
"solar": lambda: drap_xray_conditions() + "\n" + solar_conditions() + "\n" + get_noaa_scales_summary(),
|
||||
"sun": lambda: handle_sun(message_from_id, deviceID, channel_number),
|
||||
"survey": lambda: surveyHandler(message, message_from_id, deviceID),
|
||||
"s:": lambda: surveyHandler(message, message_from_id, deviceID),
|
||||
@@ -119,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),
|
||||
@@ -249,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 = ''
|
||||
@@ -281,10 +287,12 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
|
||||
#flood
|
||||
msg += " [F]"
|
||||
|
||||
if (float(snr) != 0 or float(rssi) != 0) and "Hops" not in hop:
|
||||
if (float(snr) != 0 or float(rssi) != 0) and "Hop" not in hop:
|
||||
msg += f"\nSNR:{snr} RSSI:{rssi}"
|
||||
elif "Hops" in hop:
|
||||
msg += f"\n{hop}🐇 "
|
||||
elif "Hop" in hop:
|
||||
# janky, remove the words Gateway or MQTT if present
|
||||
hop = hop.replace("Gateway", "").replace("Direct", "").replace("MQTT", "").strip()
|
||||
msg += f"\n{hop} "
|
||||
|
||||
if "@" in message:
|
||||
msg = msg + " @" + message.split("@")[1]
|
||||
@@ -330,8 +338,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
|
||||
|
||||
@@ -358,7 +369,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:
|
||||
@@ -390,11 +402,42 @@ 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:"
|
||||
parts = message.split("echo ", 1)
|
||||
@@ -403,25 +446,29 @@ def handle_echo(message, message_from_id, deviceID, isDM, channel_number):
|
||||
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}"
|
||||
else:
|
||||
return "Please provide a message to echo back to you. Example:echo Hello World"
|
||||
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:
|
||||
@@ -440,15 +487,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
|
||||
@@ -543,6 +601,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:
|
||||
@@ -599,7 +662,7 @@ def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel
|
||||
if not any(node['nodeID'] == message_from_id and node['welcome'] == True for node in seenNodes):
|
||||
if (channel_number == publicChannel and my_settings.antiSpam) or my_settings.useDMForResponse:
|
||||
# send via DM
|
||||
send_message(my_settings.welcome_message, channel_number, message_from_id, deviceID)
|
||||
send_message(my_settings.welcome_message, 0, message_from_id, deviceID)
|
||||
else:
|
||||
# send via channel
|
||||
send_message(my_settings.welcome_message, channel_number, 0, deviceID)
|
||||
@@ -632,7 +695,7 @@ def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel
|
||||
if msg != '':
|
||||
if (channel_number == publicChannel and my_settings.antiSpam) or my_settings.useDMForResponse:
|
||||
# send via DM
|
||||
send_message(msg, channel_number, message_from_id, deviceID)
|
||||
send_message(msg, 0, message_from_id, deviceID)
|
||||
else:
|
||||
# send via channel
|
||||
send_message(msg, channel_number, 0, deviceID)
|
||||
@@ -675,11 +738,6 @@ def handleDopeWars(message, nodeID, rxNode):
|
||||
if p.get('userID') == nodeID:
|
||||
p['last_played'] = time.time()
|
||||
msg = playDopeWars(nodeID, message)
|
||||
|
||||
# if message starts wth 'e'xit remove player from tracker
|
||||
if message.lower().startswith('e'):
|
||||
dwPlayerTracker[:] = [p for p in dwPlayerTracker if p.get('userID') != nodeID]
|
||||
msg = 'You have exited Dope Wars.'
|
||||
return msg
|
||||
|
||||
def handle_gTnW(chess = False):
|
||||
@@ -1003,32 +1061,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)
|
||||
@@ -1415,10 +1590,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":
|
||||
@@ -1583,6 +1766,8 @@ def handle_boot(mesh=True):
|
||||
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.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:
|
||||
@@ -1614,6 +1799,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:
|
||||
@@ -1717,24 +1906,38 @@ def onReceive(packet, interface):
|
||||
# check if the packet has a channel flag use it ## FIXME needs to be channel hash lookup
|
||||
if packet.get('channel'):
|
||||
channel_number = packet.get('channel')
|
||||
# get channel name from channel number from connected devices
|
||||
for device in channel_list:
|
||||
if device["interface_id"] == rxNode:
|
||||
device_channels = device['channels']
|
||||
for chan_name, info in device_channels.items():
|
||||
if info['number'] == channel_number:
|
||||
channel_name = chan_name
|
||||
channel_name = "unknown"
|
||||
try:
|
||||
res = resolve_channel_name(channel_number, rxNode, interface)
|
||||
if res:
|
||||
try:
|
||||
channel_name, _ = res
|
||||
except Exception:
|
||||
channel_name = "unknown"
|
||||
else:
|
||||
# Search all interfaces for this channel
|
||||
cache = build_channel_cache()
|
||||
found_on_other = None
|
||||
for device in cache:
|
||||
for chan_name, info in device.get("channels", {}).items():
|
||||
if str(info.get('number')) == str(channel_number) or str(info.get('hash')) == str(channel_number):
|
||||
found_on_other = device.get("interface_id")
|
||||
found_chan_name = chan_name
|
||||
break
|
||||
if found_on_other:
|
||||
break
|
||||
|
||||
# get channel hashes for the interface
|
||||
device = next((d for d in channel_list if d["interface_id"] == rxNode), None)
|
||||
if device:
|
||||
# Find the channel name whose hash matches channel_number
|
||||
for chan_name, info in device['channels'].items():
|
||||
if info['hash'] == channel_number:
|
||||
print(f"Matched channel hash {info['hash']} to channel name {chan_name}")
|
||||
channel_name = chan_name
|
||||
break
|
||||
if found_on_other and found_on_other != rxNode:
|
||||
logger.debug(
|
||||
f"System: Received Packet on Channel:{channel_number} ({found_chan_name}) on Interface:{rxNode}, but this channel is configured on Interface:{found_on_other}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: channel resolution error: {e}")
|
||||
|
||||
#debug channel info
|
||||
# if "unknown" in str(channel_name):
|
||||
# logger.debug(f"System: Received Packet on Channel:{channel_number} on Interface:{rxNode}")
|
||||
# else:
|
||||
# logger.debug(f"System: Received Packet on Channel:{channel_number} Name:{channel_name} on Interface:{rxNode}")
|
||||
|
||||
# check if the packet has a simulator flag
|
||||
simulator_flag = packet.get('decoded', {}).get('simulator', False)
|
||||
@@ -1746,9 +1949,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)
|
||||
@@ -1757,7 +1965,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':
|
||||
@@ -1813,31 +2026,38 @@ def onReceive(packet, interface):
|
||||
else:
|
||||
hop_count = hop_away
|
||||
|
||||
if hop == "" and hop_count > 0:
|
||||
if hop_count > 0:
|
||||
# set hop string from calculated hop count
|
||||
hop = f"{hop_count} Hop" if hop_count == 1 else f"{hop_count} Hops"
|
||||
|
||||
if hop_start == hop_limit and "lora" in str(transport_mechanism).lower() and (snr != 0 or rssi != 0):
|
||||
if hop_start == hop_limit and "lora" in str(transport_mechanism).lower() and (snr != 0 or rssi != 0) and hop_count == 0:
|
||||
# 2.7+ firmware direct hop over LoRa
|
||||
hop = "Direct"
|
||||
|
||||
if ((hop_start == 0 and hop_limit >= 0) or via_mqtt or ("mqtt" in str(transport_mechanism).lower())):
|
||||
if via_mqtt or "mqtt" in str(transport_mechanism).lower():
|
||||
hop = "MQTT"
|
||||
elif hop == "" and hop_count == 0 and (snr != 0 or rssi != 0):
|
||||
# this came from a UDP but we had signal info so gateway is used
|
||||
hop = "Gateway"
|
||||
elif "unknown" in str(transport_mechanism).lower() and (snr == 0 and rssi == 0):
|
||||
# we for sure detected this sourced from a UDP like host
|
||||
via_mqtt = True
|
||||
elif "udp" in str(transport_mechanism).lower():
|
||||
hop = "Gateway"
|
||||
|
||||
if hop in ("MQTT", "Gateway") and hop_count > 0:
|
||||
hop = f"{hop_count} Hops"
|
||||
hop = f" {hop_count} Hops"
|
||||
|
||||
# Add relay node info if present
|
||||
if packet.get('relayNode') is not None:
|
||||
relay_val = packet['relayNode']
|
||||
last_byte = relay_val & 0xFF
|
||||
if last_byte == 0x00:
|
||||
hex_val = 'OldFW'
|
||||
else:
|
||||
hex_val = f"{last_byte:02X}"
|
||||
hop += f" Relay:{hex_val}"
|
||||
|
||||
if enableHopLogs:
|
||||
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:
|
||||
@@ -2039,6 +2259,7 @@ gameTrackers = [
|
||||
(hamtestTracker, "HamTest", handleHamtest),
|
||||
(tictactoeTracker, "TicTacToe", handleTicTacToe),
|
||||
(surveyTracker, "Survey", surveyHandler),
|
||||
(battleshipTracker, "Battleship", handleBattleship),
|
||||
# quiz does not use a tracker (quizGamePlayer) always active
|
||||
]
|
||||
|
||||
@@ -2051,8 +2272,11 @@ async def main():
|
||||
# Create core tasks
|
||||
tasks.append(asyncio.create_task(start_rx(), name="mesh_rx"))
|
||||
tasks.append(asyncio.create_task(watchdog(), name="watchdog"))
|
||||
|
||||
|
||||
# Add optional tasks
|
||||
if my_settings.dataPersistence_enabled:
|
||||
tasks.append(asyncio.create_task(dataPersistenceLoop(), name="data_persistence"))
|
||||
|
||||
if my_settings.file_monitor_enabled:
|
||||
tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor"))
|
||||
|
||||
|
||||
@@ -12,16 +12,17 @@ This document provides an overview of all modules available in the Mesh-Bot proj
|
||||
- [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)
|
||||
@@ -38,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
|
||||
@@ -153,7 +210,7 @@ The checklist module provides asset tracking and accountability features with sa
|
||||
- `approvecl <id>` - Approve a pending check-in (admin)
|
||||
- `denycl <id>` - Deny/remove a check-in (admin)
|
||||
|
||||
more at [modules/checklist.md](modules/checklist.md)
|
||||
more at [modules/checklist.md](checklist.md)
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -213,7 +270,7 @@ The inventory module provides a full point-of-sale (POS) system with inventory t
|
||||
| `cartbuy` or `cartsell` | Complete transaction |
|
||||
| `cartclear` | Empty your cart |
|
||||
|
||||
more at [modules/inventory.py](modules/inventory.py)
|
||||
more at [modules/inventory.py](inventory.py)
|
||||
|
||||
#### Features
|
||||
|
||||
@@ -280,23 +337,23 @@ The system uses SQLite with four tables:
|
||||
|
||||
## 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:
|
||||
@@ -341,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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -363,10 +419,6 @@ The Radio Monitoring module provides several ways to integrate amateur radio sof
|
||||
|
||||
### Hamlib Integration
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `radio` | Monitor radio SNR via Hamlib |
|
||||
|
||||
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
|
||||
@@ -457,9 +509,6 @@ 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)
|
||||
|
||||
@@ -471,7 +520,7 @@ More at [LLM Readme](llm.md)
|
||||
|--------------|-----------------------------------------------|
|
||||
| `wiki` | Search Wikipedia or local Kiwix server |
|
||||
|
||||
Configure in `[wikipedia]` section of `config.ini`.
|
||||
Configure in `[general]` section of `config.ini`.
|
||||
|
||||
---
|
||||
|
||||
@@ -743,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
|
||||
@@ -756,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.
|
||||
|
||||
@@ -1194,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!
|
||||
@@ -33,12 +33,12 @@ The enhanced checklist module provides asset tracking and accountability feature
|
||||
- Provides `get_overdue_checkins()` function for alert integration
|
||||
|
||||
- **Approval Workflow**:
|
||||
- `clok <id>` - Approve pending check-ins (admin)
|
||||
- `approvecl <id>` - Approve pending check-ins (admin)
|
||||
- `denycl <id>` - Deny/remove check-ins (admin)
|
||||
- Support for approval-based workflows
|
||||
|
||||
#### New Commands:
|
||||
- `clok <id>` - Approve a check-in
|
||||
- `approvecl <id>` - Approve a check-in
|
||||
- `denycl <id>` - Deny a check-in
|
||||
- Enhanced `checkin [interval] [note]` - Now supports interval parameter
|
||||
|
||||
|
||||
@@ -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:**
|
||||
@@ -720,11 +808,22 @@ This module implements a survey system for the Meshtastic mesh-bot.
|
||||
|
||||
**Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025**
|
||||
|
||||
|
||||
|
||||
___
|
||||
|
||||
Pay no attention to the..
|
||||
# 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.
|
||||
|
||||
@@ -747,5 +846,14 @@ 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
|
||||
```
|
||||
510
modules/games/battleship.py
Normal file
510
modules/games/battleship.py
Normal file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# Battleship game module Meshing Around
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import random
|
||||
import copy
|
||||
import uuid
|
||||
import time
|
||||
from modules.settings import battleshipTracker
|
||||
|
||||
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
|
||||
106
modules/games/battleship_vid.py
Normal file
106
modules/games/battleship_vid.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/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
|
||||
|
||||
latest_battleship_board = None
|
||||
latest_battleship_meta = None
|
||||
|
||||
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()
|
||||
|
||||
def parse_battleship_message(msg):
|
||||
# Expected payload:
|
||||
# MBSP|label|timestamp|nodeID|deviceID|sessionID|status|shotsFired|boardType|shipsStatus|boardString
|
||||
print("Parsing Battleship message:", msg)
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# # Example: create a new game and show the boards
|
||||
# game = Battleship(vs_ai=True)
|
||||
# battleship_visual_main(game)
|
||||
@@ -5,6 +5,7 @@ import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger
|
||||
from modules.settings import dwPlayerTracker
|
||||
|
||||
# Global variables
|
||||
total_days = 7 # number of days or rotations the player has to play
|
||||
@@ -391,6 +392,13 @@ def endGameDw(nodeID):
|
||||
return msg
|
||||
if cash < starting_cash:
|
||||
msg = "You lost money, better go get a real job.💸"
|
||||
|
||||
# remove player from all trackers and databases
|
||||
dwPlayerTracker[:] = [p for p in dwPlayerTracker if p.get('userID') != nodeID]
|
||||
dwCashDb[:] = [p for p in dwCashDb if p.get('userID') != nodeID]
|
||||
dwInventoryDb[:] = [p for p in dwInventoryDb if p.get('userID') != nodeID]
|
||||
dwLocationDb[:] = [p for p in dwLocationDb if p.get('userID') != nodeID]
|
||||
dwGameDayDb[:] = [p for p in dwGameDayDb if p.get('userID') != nodeID]
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger
|
||||
from modules.settings import golfTracker
|
||||
|
||||
# Clubs setup
|
||||
driver_distances = list(range(230, 280, 5))
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import random
|
||||
import os
|
||||
from modules.log import logger
|
||||
from modules.settings import hamtestTracker
|
||||
|
||||
class HamTest:
|
||||
def __init__(self):
|
||||
@@ -135,8 +136,16 @@ class HamTest:
|
||||
|
||||
# remove the game[id] from the list
|
||||
del self.game[id]
|
||||
# hamtestTracker stores dicts like {"nodeID": nodeID, ...}
|
||||
for i in range(len(hamtestTracker)):
|
||||
try:
|
||||
if hamtestTracker[i].get('nodeID') == id:
|
||||
hamtestTracker.pop(i)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return msg
|
||||
|
||||
hamtestTracker = []
|
||||
hamtest = HamTest()
|
||||
|
||||
@@ -3,6 +3,7 @@ from modules.log import logger, getPrettyTime
|
||||
import os
|
||||
import json
|
||||
import random
|
||||
from modules.settings import hangmanTracker
|
||||
|
||||
class Hangman:
|
||||
WORDS = [
|
||||
|
||||
@@ -211,7 +211,7 @@ def compareCodeMMind(secret_code, user_guess, nodeID):
|
||||
def playGameMMind(diff, secret_code, turn_count, nodeID, message):
|
||||
msg = ''
|
||||
won = False
|
||||
if turn_count <= 10:
|
||||
if turn_count < 11:
|
||||
user_guess = getGuessMMind(diff, message, nodeID)
|
||||
if user_guess == "XXXX":
|
||||
msg += f"⛔️Invalid guess. Please enter 4 valid colors letters.\n🔴🟢🔵🔴 is RGBR"
|
||||
@@ -240,7 +240,7 @@ def playGameMMind(diff, secret_code, turn_count, nodeID, message):
|
||||
# reset turn count in tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['turns'] = 0
|
||||
mindTracker[i]['turns'] = 1
|
||||
mindTracker[i]['secret_code'] = ''
|
||||
mindTracker[i]['cmd'] = 'new'
|
||||
|
||||
@@ -277,6 +277,7 @@ def start_mMind(nodeID, message):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['cmd'] = 'makeCode'
|
||||
mindTracker[i]['diff'] = diff
|
||||
mindTracker[i]['turns'] = 1
|
||||
# Return color message to player
|
||||
msg += chooseDifficultyMMind(message.lower()[0])
|
||||
return msg
|
||||
|
||||
@@ -4,273 +4,298 @@
|
||||
import random
|
||||
import time
|
||||
import modules.settings as my_settings
|
||||
from modules.settings import tictactoeTracker
|
||||
|
||||
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"
|
||||
self.digit_emojis = None
|
||||
else:
|
||||
self.X = "❌"
|
||||
self.O = "⭕️"
|
||||
# Unicode emoji digits 1️⃣-9️⃣
|
||||
self.digit_emojis = [
|
||||
"1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"
|
||||
]
|
||||
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]
|
||||
if cell != " ":
|
||||
row.append(cell)
|
||||
else:
|
||||
if self.digit_emojis:
|
||||
row.append(self.digit_emojis[i*3+j])
|
||||
else:
|
||||
row.append(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
|
||||
|
||||
# Accept emoji digits as input
|
||||
pos = None
|
||||
# Try to match emoji digits if enabled
|
||||
if self.digit_emojis:
|
||||
try:
|
||||
# Remove variation selectors for matching
|
||||
normalized_input = input_msg.replace("\ufe0f", "")
|
||||
for idx, emoji in enumerate(self.digit_emojis[:max_pos]):
|
||||
if normalized_input == emoji.replace("\ufe0f", ""):
|
||||
pos = idx + 1
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if pos is None:
|
||||
try:
|
||||
pos = int(input_msg)
|
||||
except Exception:
|
||||
return f"Enter a number or emoji 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()
|
||||
@@ -4,6 +4,7 @@ import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger, getPrettyTime
|
||||
from modules.settings import vpTracker
|
||||
|
||||
vpStartingCash = 20
|
||||
# Define the Card class
|
||||
@@ -260,6 +261,7 @@ class PlayerVP:
|
||||
|
||||
|
||||
def getLastCmdVp(nodeID):
|
||||
global vpTracker
|
||||
last_cmd = ""
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
@@ -267,6 +269,7 @@ def getLastCmdVp(nodeID):
|
||||
return last_cmd
|
||||
|
||||
def setLastCmdVp(nodeID, cmd):
|
||||
global vpTracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
vpTracker[i]['cmd'] = cmd
|
||||
|
||||
@@ -83,8 +83,10 @@ def getRepeaterBook(lat=0, lon=0):
|
||||
elsewhereapi = "https://www.repeaterbook.com/row_repeaters/prox2_result.php?"
|
||||
if grid[:2] in ['CN', 'DN', 'EN', 'FN', 'CM', 'DM', 'EM', 'FM', 'DL', 'EL', 'FL']:
|
||||
repeater_url = usapi
|
||||
logger.debug("Location: Fetching repeater data from RepeaterBook US API for grid " + grid)
|
||||
else:
|
||||
repeater_url = elsewhereapi
|
||||
logger.debug("Location: Fetching repeater data from RepeaterBook International API for grid " + grid)
|
||||
|
||||
repeater_url += f"city={grid}&lat=&long=&distance=50&Dunit=m&band%5B%5D=4&band%5B%5D=16&freq=&call=&mode%5B%5D=1&mode%5B%5D=2&mode%5B%5D=4&mode%5B%5D=64&status_id=1&use=%25&use=OPEN&order=distance_calc%2C+state_id+ASC"
|
||||
|
||||
@@ -92,10 +94,14 @@ def getRepeaterBook(lat=0, lon=0):
|
||||
msg = ''
|
||||
user_agent = {'User-agent': 'Mozilla/5.0'}
|
||||
response = requests.get(repeater_url, headers=user_agent, timeout=my_settings.urlTimeoutSeconds)
|
||||
if response.status_code!=200:
|
||||
# Fail early on bad HTTP status
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Location:Error fetching repeater data from {repeater_url} with status code {response.status_code}")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
|
||||
soup = bs.BeautifulSoup(response.text, 'html.parser')
|
||||
table = soup.find('table', attrs={'class': 'table table-striped table-hover align-middle sortable'})
|
||||
# match the repeater table by presence of the "sortable" class (class order/extra classes may vary)
|
||||
table = soup.select_one('table.sortable')
|
||||
if table is not None:
|
||||
cells = table.find_all('td')
|
||||
data = []
|
||||
@@ -115,6 +121,8 @@ def getRepeaterBook(lat=0, lon=0):
|
||||
}
|
||||
data.append(repeater)
|
||||
else:
|
||||
# No table found — could be legitimately no data or markup change.
|
||||
logger.debug("Location: No repeater table found on RepeaterBook page, scraping failed or no data for region.")
|
||||
msg = "No Data for your Region"
|
||||
except Exception as e:
|
||||
msg = "No repeaters found 😔"
|
||||
@@ -261,7 +269,7 @@ def get_NOAAweather(lat=0, lon=0, unit=0, report_days=None):
|
||||
logger.warning("Location:Error fetching weather data from NOAA for location")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
except Exception:
|
||||
logger.warning(f"Location:Error fetching weather data error: {Exception}")
|
||||
logger.warning(f"Location:Error fetching weather data malformed: {Exception}")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
# get the forecast URL from the JSON response
|
||||
weather_json = weather_data.json()
|
||||
@@ -272,7 +280,7 @@ def get_NOAAweather(lat=0, lon=0, unit=0, report_days=None):
|
||||
logger.warning("Location:Error fetching weather forecast from NOAA")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
except Exception:
|
||||
logger.warning(f"Location:Error fetching weather data error: {Exception}")
|
||||
logger.warning(f"Location:Error fetching weather data missing: {Exception}")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
|
||||
# from periods, get the detailedForecast from number of days in NOAAforecastDuration
|
||||
@@ -409,21 +417,52 @@ def getWeatherAlertsNOAA(lat=0, lon=0, useDefaultLatLon=False):
|
||||
try:
|
||||
alert_data = requests.get(alert_url, timeout=my_settings.urlTimeoutSeconds)
|
||||
if not alert_data.ok:
|
||||
logger.warning("Location:Error fetching weather alerts from NOAA")
|
||||
logger.warning("Location:Error fetching weather alerts from NOAA bad data")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
except Exception:
|
||||
logger.warning(f"Location:Error fetching weather data error: {Exception}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Location:Error fetching weather alert request error: {type(e).__name__}: {e}")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
|
||||
alerts = ""
|
||||
alertxml = xml.dom.minidom.parseString(alert_data.text)
|
||||
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"
|
||||
area_desc_nodes = i.getElementsByTagName("cap:areaDesc")
|
||||
if area_desc_nodes and area_desc_nodes[0].childNodes:
|
||||
area_desc = area_desc_nodes[0].childNodes[0].nodeValue
|
||||
else:
|
||||
alerts += f"{title}\n"
|
||||
area_desc = ""
|
||||
|
||||
# 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:
|
||||
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
|
||||
@@ -482,10 +521,10 @@ def getActiveWeatherAlertsDetailNOAA(lat=0, lon=0):
|
||||
try:
|
||||
alert_data = requests.get(alert_url, timeout=my_settings.urlTimeoutSeconds)
|
||||
if not alert_data.ok:
|
||||
logger.warning("Location:Error fetching weather alerts from NOAA")
|
||||
logger.warning("Location:Error fetching weather alerts from NOAA bad data")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
except Exception:
|
||||
logger.warning(f"Location:Error fetching weather data error: {Exception}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Location:Error fetching active weather alert request error: {type(e).__name__}: {e}")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
|
||||
alerts = ""
|
||||
@@ -1004,60 +1043,133 @@ 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
|
||||
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
|
||||
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}"
|
||||
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
|
||||
except (requests.exceptions.RequestException):
|
||||
logger.warning("Location:Error fetching aircraft data from OpenSky Network")
|
||||
return my_settings.ERROR_FETCHING_DATA
|
||||
aircraft_json = aircraft_data.json()
|
||||
if 'states' not in aircraft_json or not aircraft_json['states']:
|
||||
return my_settings.NO_ALERTS
|
||||
aircraft_list = aircraft_json['states']
|
||||
aircraft_report = ""
|
||||
logger.debug(f"Location: OpenSky Network: Found {len(aircraft_list)} possible aircraft in area")
|
||||
for aircraft in aircraft_list:
|
||||
if len(aircraft_report.split("\n")) >= search_limit:
|
||||
break
|
||||
# extract values from JSON
|
||||
def get_openskynetwork(lat=0, lon=0, altitude=0, node_altitude=0, altitude_window=900):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
def _to_float(v):
|
||||
try:
|
||||
callsign = aircraft[1].strip() if aircraft[1] else "N/A"
|
||||
origin_country = aircraft[2]
|
||||
velocity = aircraft[9]
|
||||
true_track = aircraft[10]
|
||||
vertical_rate = aircraft[11]
|
||||
sensors = aircraft[12]
|
||||
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
|
||||
# handle numeric and numeric-strings, treat empty/'N/A' as None
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip()
|
||||
if s == "" or s.upper() == "N/A":
|
||||
return None
|
||||
return float(s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
# basic input validation/coercion
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
node_altitude = _to_float(node_altitude) or 0.0
|
||||
except Exception:
|
||||
node_altitude = 0.0
|
||||
|
||||
if lat == 0 and lon == 0:
|
||||
return False
|
||||
|
||||
box_size = 0.45 # approx 50km
|
||||
lamin = lat - box_size
|
||||
lamax = lat + box_size
|
||||
lomin = lon - box_size
|
||||
lomax = lon + box_size
|
||||
|
||||
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 False
|
||||
except (requests.exceptions.RequestException):
|
||||
logger.warning("Location:Error fetching aircraft data from OpenSky Network")
|
||||
return False
|
||||
|
||||
aircraft_json = aircraft_data.json()
|
||||
if 'states' not in aircraft_json or not aircraft_json['states']:
|
||||
return False
|
||||
|
||||
aircraft_list = aircraft_json['states']
|
||||
logger.debug(f"Location: OpenSky Network: Found {len(aircraft_list)} possible aircraft in area")
|
||||
closest = None
|
||||
min_diff = float('inf')
|
||||
|
||||
if len(aircraft_list) == 1:
|
||||
# Only one aircraft found; return normalized values (altitudes coerced to numbers or None)
|
||||
aircraft = aircraft_list[0]
|
||||
baro_alt = _to_float(aircraft[7]) # barometric altitude
|
||||
geo_alt = _to_float(aircraft[13]) # geometric altitude
|
||||
return {
|
||||
"callsign": aircraft[1].strip() if aircraft[1] else "N/A",
|
||||
"origin_country": aircraft[2] if aircraft[2] is not None else "N/A",
|
||||
"velocity": aircraft[9] if aircraft[9] is not None else "N/A",
|
||||
"true_track": aircraft[10] if aircraft[10] is not None else "N/A",
|
||||
"vertical_rate": aircraft[11] if aircraft[11] is not None else "N/A",
|
||||
"sensors": aircraft[12] if aircraft[12] is not None else "N/A",
|
||||
"altitude": baro_alt,
|
||||
"geo_altitude": geo_alt,
|
||||
"squawk": aircraft[14] if len(aircraft) > 14 and aircraft[14] is not None else "N/A",
|
||||
}
|
||||
|
||||
for aircraft in aircraft_list:
|
||||
try:
|
||||
callsign = aircraft[1].strip() if aircraft[1] else "N/A"
|
||||
origin_country = aircraft[2] if aircraft[2] is not None else "N/A"
|
||||
velocity = aircraft[9] if aircraft[9] is not None else "N/A"
|
||||
true_track = aircraft[10] if aircraft[10] is not None else "N/A"
|
||||
vertical_rate = aircraft[11] if aircraft[11] is not None else "N/A"
|
||||
sensors = aircraft[12] if aircraft[12] is not None else "N/A"
|
||||
baro_altitude = _to_float(aircraft[7])
|
||||
geo_altitude = _to_float(aircraft[13])
|
||||
squawk = aircraft[14] if len(aircraft) > 14 and aircraft[14] is not None else "N/A"
|
||||
except Exception:
|
||||
logger.debug("Location:Error extracting aircraft data from OpenSky Network")
|
||||
continue
|
||||
|
||||
# Prefer geo_altitude, fallback to baro_altitude
|
||||
plane_alt = geo_altitude if geo_altitude is not None else baro_altitude
|
||||
# skip if we can't get a numeric plane altitude or node_altitude is zero/unset
|
||||
if plane_alt is None or (node_altitude == 0 or node_altitude is None):
|
||||
continue
|
||||
|
||||
# safe numeric diff
|
||||
try:
|
||||
diff = abs(float(plane_alt) - float(node_altitude))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
logger.debug(f"SYSTEM: Location HighFly: Error processing OpenSky Network data: {e}")
|
||||
return False
|
||||
|
||||
def log_locationData_toMap(userID, location, message):
|
||||
"""
|
||||
|
||||
@@ -138,9 +138,15 @@ def get_rss_feed(msg):
|
||||
logger.error(f"Error fetching RSS feed from {feed_url}: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_newsAPI(user_search="meshtastic"):
|
||||
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:
|
||||
|
||||
@@ -5,6 +5,7 @@ import schedule
|
||||
from datetime import datetime
|
||||
from modules.log import logger
|
||||
from modules.system import send_message
|
||||
from modules.settings import MOTD, schedulerMotd, schedulerMessage, schedulerChannel, schedulerInterface, schedulerValue, schedulerTime, schedulerInterval
|
||||
|
||||
async def run_scheduler_loop(interval=1):
|
||||
logger.debug(f"System: Scheduler loop started Tasks: {len(schedule.jobs)}, Details:{extract_schedule_fields(schedule.get_jobs())}")
|
||||
@@ -24,11 +25,12 @@ async def run_scheduler_loop(interval=1):
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("System: Scheduler loop cancelled, shutting down.")
|
||||
|
||||
def safe_int(val, default=0, type=""):
|
||||
def safe_int(val, default=0, type=''):
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(f"System: Scheduler config {type} error '{val}' to int, using default {default}")
|
||||
if val != '':
|
||||
logger.debug(f"System: Scheduler config {type} error '{val}' to int, using default {default}")
|
||||
return default
|
||||
|
||||
def extract_schedule_fields(jobs):
|
||||
@@ -174,6 +176,12 @@ def setup_scheduler(
|
||||
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
|
||||
|
||||
@@ -35,6 +35,8 @@ 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
|
||||
@@ -48,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
|
||||
@@ -81,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:
|
||||
@@ -253,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)
|
||||
@@ -319,13 +323,15 @@ try:
|
||||
coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days
|
||||
|
||||
# location alerts
|
||||
alert_duration = config['location'].getint('alertDuration', 20) # default 20 minutes
|
||||
if alert_duration < 10: # the API calls need throttle time
|
||||
alert_duration = 10
|
||||
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
|
||||
wxAlertsEnabled = config['location'].getboolean('NOAAalertsEnabled', True) # default 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
|
||||
@@ -351,7 +357,6 @@ try:
|
||||
ipawsAlertEnabled or
|
||||
wxAlertBroadcastEnabled or
|
||||
volcanoAlertBroadcastEnabled or
|
||||
wxAlertsEnabled or
|
||||
eAlertBroadcastEnabled
|
||||
)
|
||||
|
||||
@@ -457,6 +462,9 @@ 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('twoFactor_enabled', True) # default True
|
||||
@@ -480,6 +488,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
|
||||
@@ -494,6 +503,14 @@ 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
|
||||
|
||||
# data persistence settings
|
||||
dataPersistence_enabled = config.getboolean('dataPersistence', 'enabled', fallback=True) # default True
|
||||
dataPersistence_interval = config.getint('dataPersistence', 'interval', fallback=300) # default 300 seconds (5 minutes)
|
||||
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.")
|
||||
|
||||
105
modules/space.py
105
modules/space.py
@@ -37,19 +37,27 @@ def hf_band_conditions():
|
||||
def solar_conditions():
|
||||
# radio related solar conditions from hamsql.com
|
||||
solar_cond = ""
|
||||
solar_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
|
||||
if(solar_cond.ok):
|
||||
solar_xml = xml.dom.minidom.parseString(solar_cond.text)
|
||||
for i in solar_xml.getElementsByTagName("solardata"):
|
||||
solar_a_index = i.getElementsByTagName("aindex")[0].childNodes[0].data
|
||||
solar_k_index = i.getElementsByTagName("kindex")[0].childNodes[0].data
|
||||
solar_xray = i.getElementsByTagName("xray")[0].childNodes[0].data
|
||||
solar_flux = i.getElementsByTagName("solarflux")[0].childNodes[0].data
|
||||
sunspots = i.getElementsByTagName("sunspots")[0].childNodes[0].data
|
||||
signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data
|
||||
solar_cond = "A-Index: " + solar_a_index + "\nK-Index: " + solar_k_index + "\nSunspots: " + sunspots + "\nX-Ray Flux: " + solar_xray + "\nSolar Flux: " + solar_flux + "\nSignal Noise: " + signalnoise
|
||||
else:
|
||||
logger.error("Solar: Error fetching solar conditions")
|
||||
try:
|
||||
solar_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
|
||||
if solar_cond.ok:
|
||||
try:
|
||||
solar_xml = xml.dom.minidom.parseString(solar_cond.text)
|
||||
except Exception as e:
|
||||
logger.error(f"Solar: XML parse error: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
for i in solar_xml.getElementsByTagName("solardata"):
|
||||
solar_a_index = i.getElementsByTagName("aindex")[0].childNodes[0].data
|
||||
solar_k_index = i.getElementsByTagName("kindex")[0].childNodes[0].data
|
||||
solar_xray = i.getElementsByTagName("xray")[0].childNodes[0].data
|
||||
solar_flux = i.getElementsByTagName("solarflux")[0].childNodes[0].data
|
||||
sunspots = i.getElementsByTagName("sunspots")[0].childNodes[0].data
|
||||
signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data
|
||||
solar_cond = "A-Index: " + solar_a_index + "\nK-Index: " + solar_k_index + "\nSunspots: " + sunspots + "\nX-Ray Flux: " + solar_xray + "\nSolar Flux: " + solar_flux + "\nSignal Noise: " + signalnoise
|
||||
else:
|
||||
logger.error("Solar: Error fetching solar conditions")
|
||||
solar_cond = ERROR_FETCHING_DATA
|
||||
except Exception as e:
|
||||
logger.error(f"Solar: Exception fetching or parsing: {e}")
|
||||
solar_cond = ERROR_FETCHING_DATA
|
||||
return solar_cond
|
||||
|
||||
@@ -68,6 +76,77 @@ def drap_xray_conditions():
|
||||
xray_flux = ERROR_FETCHING_DATA
|
||||
return xray_flux
|
||||
|
||||
def get_noaa_scales_summary():
|
||||
"""
|
||||
Show latest observed, 24-hour max, and predicted geomagnetic, storm, and blackout data.
|
||||
"""
|
||||
try:
|
||||
response = requests.get("https://services.swpc.noaa.gov/products/noaa-scales.json", timeout=urlTimeoutSeconds)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
today = datetime.utcnow().date()
|
||||
latest_entry = None
|
||||
latest_dt = None
|
||||
max_g_today = None
|
||||
max_g_scale = -1
|
||||
predicted_g = None
|
||||
predicted_g_scale = -1
|
||||
|
||||
# Find latest observed and 24-hour max for today
|
||||
for entry in data.values():
|
||||
date_str = entry.get("DateStamp")
|
||||
time_str = entry.get("TimeStamp")
|
||||
if date_str and time_str:
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
|
||||
g = entry.get("G", {})
|
||||
g_scale = int(g.get("Scale", -1)) if g.get("Scale") else -1
|
||||
# Latest observed for today
|
||||
if dt.date() == today:
|
||||
if latest_dt is None or dt > latest_dt:
|
||||
latest_dt = dt
|
||||
latest_entry = entry
|
||||
# 24-hour max for today
|
||||
if g_scale > max_g_scale:
|
||||
max_g_scale = g_scale
|
||||
max_g_today = entry
|
||||
# Predicted (future)
|
||||
elif dt.date() > today:
|
||||
if g_scale > predicted_g_scale:
|
||||
predicted_g_scale = g_scale
|
||||
predicted_g = entry
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def format_entry(label, entry):
|
||||
if not entry:
|
||||
return f"{label}: No data"
|
||||
g = entry.get("G", {})
|
||||
s = entry.get("S", {})
|
||||
r = entry.get("R", {})
|
||||
parts = [f"{label} {g.get('Text', 'N/A')} (G:{g.get('Scale', 'N/A')})"]
|
||||
|
||||
# Only show storm if it's happening
|
||||
if s.get("Text") and s.get("Text") != "none":
|
||||
parts.append(f"Currently:{s.get('Text')} (S:{s.get('Scale', 'N/A')})")
|
||||
|
||||
# Only show blackout if it's not "none" or scale is not 0
|
||||
if r.get("Text") and r.get("Text") != "none" and r.get("Scale") not in [None, "0", 0]:
|
||||
parts.append(f"RF Blackout:{r.get('Text')} (R:{r.get('Scale', 'N/A')})")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
output = []
|
||||
#output.append(format_entry("Latest Observed", latest_entry))
|
||||
output.append(format_entry("24hrMax:", max_g_today))
|
||||
output.append(format_entry("Predicted:", predicted_g))
|
||||
return "\n".join(output)
|
||||
else:
|
||||
return NO_ALERTS
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching services.swpc.noaa.gov: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_sun(lat=0, lon=0):
|
||||
# get sunrise and sunset times using callers location or default
|
||||
obs = ephem.Observer()
|
||||
|
||||
@@ -154,11 +154,12 @@ if wikipedia_enabled or use_kiwix_server:
|
||||
|
||||
# RSS Feed Configuration
|
||||
if rssEnable or enable_headlines:
|
||||
from modules.rss import * # from the spudgunman/meshing-around repo
|
||||
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"
|
||||
|
||||
@@ -213,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:
|
||||
@@ -233,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"
|
||||
@@ -260,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 = ""
|
||||
@@ -301,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:",)
|
||||
@@ -319,24 +331,6 @@ if ble_count > 1:
|
||||
logger.critical(f"System: Multiple BLE interfaces detected. Only one BLE interface is allowed. Exiting")
|
||||
exit()
|
||||
|
||||
def xor_hash(data: bytes) -> int:
|
||||
"""Compute an XOR hash from bytes."""
|
||||
result = 0
|
||||
for char in data:
|
||||
result ^= char
|
||||
return result
|
||||
|
||||
def generate_hash(name: str, key: str) -> int:
|
||||
"""generate the channel number by hashing the channel name and psk"""
|
||||
if key == "AQ==":
|
||||
key = "1PG7OiApB1nwvP+rz05pAQ=="
|
||||
replaced_key = key.replace("-", "+").replace("_", "/")
|
||||
key_bytes = base64.b64decode(replaced_key.encode("utf-8"))
|
||||
h_name = xor_hash(bytes(name, "utf-8"))
|
||||
h_key = xor_hash(key_bytes)
|
||||
result: int = h_name ^ h_key
|
||||
return result
|
||||
|
||||
# Initialize interfaces
|
||||
logger.debug(f"System: Initializing Interfaces")
|
||||
interface1 = interface2 = interface3 = interface4 = interface5 = interface6 = interface7 = interface8 = interface9 = None
|
||||
@@ -391,44 +385,90 @@ for i in range(1, 10):
|
||||
globals()[f'myNodeNum{i}'] = 777
|
||||
|
||||
# Fetch channel list from each device
|
||||
channel_list = []
|
||||
for i in range(1, 10):
|
||||
if globals().get(f'interface{i}') and globals().get(f'interface{i}_enabled'):
|
||||
_channel_cache = None
|
||||
|
||||
def build_channel_cache(force_refresh: bool = False):
|
||||
"""
|
||||
Build and cache channel_list from interfaces once (or when forced).
|
||||
"""
|
||||
global _channel_cache
|
||||
if _channel_cache is not None and not force_refresh:
|
||||
return _channel_cache
|
||||
|
||||
cache = []
|
||||
for i in range(1, 10):
|
||||
if not globals().get(f'interface{i}') or not globals().get(f'interface{i}_enabled'):
|
||||
continue
|
||||
try:
|
||||
node = globals()[f'interface{i}'].getNode('^local')
|
||||
channels = node.channels
|
||||
channel_dict = {}
|
||||
for channel in channels:
|
||||
if hasattr(channel, 'role') and channel.role:
|
||||
channel_name = getattr(channel.settings, 'name', '').strip()
|
||||
channel_number = getattr(channel, 'index', 0)
|
||||
# Only add channels with a non-empty name
|
||||
if channel_name:
|
||||
channel_dict[channel_name] = channel_number
|
||||
channel_list.append({
|
||||
"interface_id": i,
|
||||
"channels": channel_dict
|
||||
})
|
||||
logger.debug(f"System: Fetched Channel List from Device{i}")
|
||||
except Exception as e:
|
||||
logger.error(f"System: Error fetching channel list from Device{i}: {e}")
|
||||
# Try to use the node-provided channel/hash table if available
|
||||
try:
|
||||
ch_hash_table_raw = node.get_channels_with_hash()
|
||||
#print(f"System: Device{i} Channel Hash Table: {ch_hash_table_raw}")
|
||||
except Exception:
|
||||
logger.warning(f"System: API version error update API `pip3 install --upgrade meshtastic[cli]`")
|
||||
ch_hash_table_raw = []
|
||||
|
||||
# add channel hash to channel_list
|
||||
for device in channel_list:
|
||||
interface_id = device["interface_id"]
|
||||
interface = globals().get(f'interface{interface_id}')
|
||||
for channel_name, channel_number in device["channels"].items():
|
||||
psk_base64 = "AQ==" # default PSK
|
||||
channel_hash = generate_hash(channel_name, psk_base64)
|
||||
# add hash to the channel entry in channel_list under key 'hash'
|
||||
for entry in channel_list:
|
||||
if entry["interface_id"] == interface_id:
|
||||
entry["channels"][channel_name] = {
|
||||
"number": channel_number,
|
||||
"hash": channel_hash
|
||||
}
|
||||
channel_dict = {}
|
||||
# Use the hash table as the source of truth for channels
|
||||
if isinstance(ch_hash_table_raw, list):
|
||||
for entry in ch_hash_table_raw:
|
||||
channel_name = entry.get("name", "").strip()
|
||||
channel_number = entry.get("index")
|
||||
ch_hash = entry.get("hash")
|
||||
role = entry.get("role", "")
|
||||
# Always add PRIMARY/SECONDARY channels, even if name is empty
|
||||
if role in ("PRIMARY", "SECONDARY"):
|
||||
channel_dict[channel_name if channel_name else f"Channel{channel_number}"] = {
|
||||
"number": channel_number,
|
||||
"hash": ch_hash
|
||||
}
|
||||
elif isinstance(ch_hash_table_raw, dict):
|
||||
for channel_name, ch_hash in ch_hash_table_raw.items():
|
||||
channel_dict[channel_name] = {"number": None, "hash": ch_hash}
|
||||
# Always add the interface, even if no named channels
|
||||
cache.append({"interface_id": i, "channels": channel_dict})
|
||||
logger.debug(f"System: Fetched Channel List from Device{i} (cached)")
|
||||
except Exception as e:
|
||||
logger.debug(f"System: Error fetching channel list from Device{i}: {e}")
|
||||
|
||||
_channel_cache = cache
|
||||
return _channel_cache
|
||||
|
||||
def refresh_channel_cache():
|
||||
"""Force rebuild of channel cache (call only when channel config changes)."""
|
||||
return build_channel_cache(force_refresh=True)
|
||||
|
||||
channel_list = build_channel_cache()
|
||||
#print(f"System: Channel Cache Built: {channel_list}")
|
||||
|
||||
#### FUN-ctions ####
|
||||
def resolve_channel_name(channel_number, rxNode=1, interface_obj=None):
|
||||
"""
|
||||
Resolve a channel number/hash to its name using cached channel list.
|
||||
"""
|
||||
try:
|
||||
# ensure cache exists (cheap)
|
||||
cached = build_channel_cache()
|
||||
# quick search in cache first (no node calls)
|
||||
for device in cached:
|
||||
if device.get("interface_id") == rxNode:
|
||||
device_channels = device.get("channels", {}) or {}
|
||||
# info is dict: {name: {'number': X, 'hash': Y}}
|
||||
for chan_name, info in device_channels.items():
|
||||
try:
|
||||
if isinstance(info, dict):
|
||||
if str(info.get('number')) == str(channel_number) or str(info.get('hash')) == str(channel_number):
|
||||
return (chan_name, info.get('number') or info.get('hash'))
|
||||
else:
|
||||
if str(info) == str(channel_number):
|
||||
return (chan_name, info)
|
||||
except Exception:
|
||||
continue
|
||||
break # stop searching other devices
|
||||
except Exception as e:
|
||||
logger.debug(f"System: Error resolving channel name from cache: {e}")
|
||||
|
||||
|
||||
def cleanup_memory():
|
||||
"""Clean up memory by limiting list sizes and removing stale entries"""
|
||||
@@ -478,7 +518,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:
|
||||
@@ -951,21 +991,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
|
||||
@@ -983,7 +1145,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:
|
||||
@@ -1003,8 +1165,6 @@ def isNodeAdmin(nodeID):
|
||||
for admin in bbs_admin_list:
|
||||
if str(nodeID) == admin:
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
def isNodeBanned(nodeID):
|
||||
@@ -1015,6 +1175,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📵"
|
||||
@@ -1143,8 +1304,8 @@ def handleAlertBroadcast(deviceID=1):
|
||||
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):
|
||||
# Only allow API call every alert_duration minutes at xx:00, xx:20, xx:40
|
||||
if not (clock.minute % alert_duration == 0 and clock.second <= 17):
|
||||
return False
|
||||
|
||||
# Collect alerts
|
||||
@@ -1187,7 +1348,7 @@ def handleAlertBroadcast(deviceID=1):
|
||||
|
||||
def onDisconnect(interface):
|
||||
# Handle disconnection of the interface
|
||||
logger.warning(f"System: Abrupt Disconnection of Interface detected")
|
||||
logger.warning(f"System: Abrupt Disconnection of Interface detected, attempting reconnect...")
|
||||
interface.close()
|
||||
|
||||
# Telemetry Functions
|
||||
@@ -1333,6 +1494,7 @@ def initializeMeshLeaderboard():
|
||||
'lowestBattery': {'nodeID': None, 'value': 101, 'timestamp': 0}, # 🪫
|
||||
'longestUptime': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 🕰️
|
||||
'fastestSpeed': {'nodeID': None, 'value': 0, 'timestamp': 0}, # 🚓
|
||||
'fastestAirSpeed': {'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}, # 🥶
|
||||
@@ -1479,33 +1641,39 @@ def consumeMetadata(packet, rxNode=0, channel=-1):
|
||||
positionMetadata[nodeID] = {}
|
||||
for key in position_stats_keys:
|
||||
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
|
||||
if speed > meshLeaderboard['fastestSpeed']['value']:
|
||||
meshLeaderboard['fastestSpeed'] = {'nodeID': nodeID, 'value': speed, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
logger.info(f"System: 🚓 New speed record: {speed} km/h from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}")
|
||||
# Track highest altitude 🚀 (also log if over highfly_altitude threshold)
|
||||
if position_data.get('altitude') is not None:
|
||||
altitude = position_data['altitude']
|
||||
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)
|
||||
|
||||
# Track altitude and speed records
|
||||
if position_data.get('altitude') is not None:
|
||||
altitude = position_data['altitude']
|
||||
highflying = altitude > highfly_altitude
|
||||
|
||||
# Tallest node (below highfly_altitude - 100m)
|
||||
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)}")
|
||||
|
||||
|
||||
# Highest altitude (above highfly_altitude)
|
||||
if highflying:
|
||||
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 speed records
|
||||
if position_data.get('groundSpeed') is not None:
|
||||
speed = position_data['groundSpeed']
|
||||
# Fastest ground speed (not highflying)
|
||||
if not highflying and speed > meshLeaderboard['fastestSpeed']['value']:
|
||||
meshLeaderboard['fastestSpeed'] = {'nodeID': nodeID, 'value': speed, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
logger.info(f"System: 🚓 New speed record: {speed} km/h from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}")
|
||||
# Fastest air speed (highflying)
|
||||
elif highflying and speed > meshLeaderboard['fastestAirSpeed']['value']:
|
||||
meshLeaderboard['fastestAirSpeed'] = {'nodeID': nodeID, 'value': speed, 'timestamp': time.time()}
|
||||
if logMetaStats:
|
||||
logger.info(f"System: ✈️ New air speed record: {speed} km/h 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)}")
|
||||
@@ -1518,25 +1686,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) <= 1000: # within 1000 meters
|
||||
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
|
||||
@@ -1804,6 +1973,16 @@ def get_mesh_leaderboard(msg, fromID, deviceID):
|
||||
result += f"🚓 Speed: {value_kmh} km/h {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
else:
|
||||
result += f"🚓 Speed: {value_mph} mph {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"
|
||||
|
||||
# Highest altitude
|
||||
if meshLeaderboard['highestAltitude']['nodeID']:
|
||||
@@ -1815,15 +1994,15 @@ def get_mesh_leaderboard(msg, fromID, deviceID):
|
||||
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)
|
||||
# Fastest airspeed
|
||||
if meshLeaderboard['fastestAirSpeed']['nodeID']:
|
||||
nodeID = meshLeaderboard['fastestAirSpeed']['nodeID']
|
||||
value_kmh = round(meshLeaderboard['fastestAirSpeed']['value'], 1)
|
||||
value_mph = round(value_kmh / 1.60934, 1)
|
||||
if use_metric:
|
||||
result += f"🪜 Tallest: {int(round(value_m, 0))}m {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
result += f"✈️ Airspeed: {value_kmh} km/h {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
else:
|
||||
result += f"🪜 Tallest: {int(value_ft)}ft {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
result += f"✈️ Airspeed: {value_mph} mph {get_name_from_number(nodeID, 'short', 1)}\n"
|
||||
|
||||
# Coldest temperature
|
||||
if meshLeaderboard['coldestTemp']['nodeID']:
|
||||
@@ -1909,7 +2088,7 @@ def get_mesh_leaderboard(msg, fromID, deviceID):
|
||||
result = result.strip()
|
||||
|
||||
if result == "📊Leaderboard📊\n":
|
||||
result += "No records yet! Keep meshing! 📡"
|
||||
result += "No records yet! Keep meshing! 📡 \n firmware 2.7+ `Broadcast Device Metrics` in Telemetry Config, needs enabled for full use. Ideally not on AQ=="
|
||||
|
||||
return result
|
||||
|
||||
@@ -2246,8 +2425,36 @@ async def watchdog():
|
||||
load_bbsdm()
|
||||
load_bbsdb()
|
||||
|
||||
def saveAllData():
|
||||
try:
|
||||
# Save BBS data if enabled
|
||||
if bbs_enabled:
|
||||
save_bbsdb()
|
||||
save_bbsdm()
|
||||
logger.debug("Persistence: BBS data saved")
|
||||
|
||||
# Save leaderboard data if enabled
|
||||
if logMetaStats:
|
||||
saveLeaderboard()
|
||||
logger.debug("Persistence: Leaderboard data saved")
|
||||
|
||||
# Save ban list
|
||||
save_bbsBanList()
|
||||
logger.debug("Persistence: Ban list saved")
|
||||
|
||||
logger.info("Persistence: Save completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Persistence: Save error: {e}")
|
||||
|
||||
async def dataPersistenceLoop():
|
||||
"""Data persistence service loop for periodic data saving"""
|
||||
logger.debug("Persistence: Loop started")
|
||||
while True:
|
||||
await asyncio.sleep(dataPersistence_interval)
|
||||
saveAllData()
|
||||
|
||||
def exit_handler():
|
||||
# Close the interface and save the BBS messages
|
||||
# Close the interface and save all data
|
||||
logger.debug(f"System: Closing Autoresponder")
|
||||
try:
|
||||
logger.debug(f"System: Closing Interface1")
|
||||
@@ -2259,12 +2466,9 @@ def exit_handler():
|
||||
globals()[f'interface{i}'].close()
|
||||
except Exception as e:
|
||||
logger.error(f"System: closing: {e}")
|
||||
if bbs_enabled:
|
||||
save_bbsdb()
|
||||
save_bbsdm()
|
||||
logger.debug(f"System: BBS Messages Saved")
|
||||
if logMetaStats:
|
||||
saveLeaderboard()
|
||||
|
||||
saveAllData()
|
||||
|
||||
logger.debug(f"System: Exiting")
|
||||
asyncLoop.stop()
|
||||
asyncLoop.close()
|
||||
|
||||
@@ -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,11 +94,6 @@ 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!")
|
||||
@@ -150,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
|
||||
@@ -169,7 +174,9 @@ class TestBot(unittest.TestCase):
|
||||
self.assertIsInstance(haha, str)
|
||||
|
||||
def test_tictactoe_initial_and_move(self):
|
||||
from games.tictactoe import tictactoe
|
||||
from games.tictactoe import TicTacToe
|
||||
# Create an instance (no display module required for tests)
|
||||
tictactoe = TicTacToe(display_module=None)
|
||||
user_id = "testuser"
|
||||
# Start a new game (no move yet)
|
||||
initial = tictactoe.play(user_id, "")
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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()
|
||||
115
pong_bot.py
115
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 = ''
|
||||
@@ -100,12 +104,12 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
|
||||
#flood
|
||||
msg += " [F]"
|
||||
|
||||
if (float(snr) != 0 or float(rssi) != 0) and "Hops" not in hop:
|
||||
if (float(snr) != 0 or float(rssi) != 0) and "Hop" not in hop:
|
||||
msg += f"\nSNR:{snr} RSSI:{rssi}"
|
||||
elif "Hops" in hop:
|
||||
msg += f"\n{hop}🐇 "
|
||||
else:
|
||||
msg += "\nflood route"
|
||||
elif "Hop" in hop:
|
||||
# janky, remove the words Gateway or MQTT if present
|
||||
hop = hop.replace("Gateway", "").replace("Direct", "").replace("MQTT", "").strip()
|
||||
msg += f"\n{hop} "
|
||||
|
||||
if "@" in message:
|
||||
msg = msg + " @" + message.split("@")[1]
|
||||
@@ -275,24 +279,38 @@ def onReceive(packet, interface):
|
||||
# check if the packet has a channel flag use it ## FIXME needs to be channel hash lookup
|
||||
if packet.get('channel'):
|
||||
channel_number = packet.get('channel')
|
||||
# get channel name from channel number from connected devices
|
||||
for device in channel_list:
|
||||
if device["interface_id"] == rxNode:
|
||||
device_channels = device['channels']
|
||||
for chan_name, info in device_channels.items():
|
||||
if info['number'] == channel_number:
|
||||
channel_name = chan_name
|
||||
channel_name = "unknown"
|
||||
try:
|
||||
res = resolve_channel_name(channel_number, rxNode, interface)
|
||||
if res:
|
||||
try:
|
||||
channel_name, _ = res
|
||||
except Exception:
|
||||
channel_name = "unknown"
|
||||
else:
|
||||
# Search all interfaces for this channel
|
||||
cache = build_channel_cache()
|
||||
found_on_other = None
|
||||
for device in cache:
|
||||
for chan_name, info in device.get("channels", {}).items():
|
||||
if str(info.get('number')) == str(channel_number) or str(info.get('hash')) == str(channel_number):
|
||||
found_on_other = device.get("interface_id")
|
||||
found_chan_name = chan_name
|
||||
break
|
||||
if found_on_other:
|
||||
break
|
||||
if found_on_other and found_on_other != rxNode:
|
||||
logger.debug(
|
||||
f"System: Received Packet on Channel:{channel_number} ({found_chan_name}) on Interface:{rxNode}, but this channel is configured on Interface:{found_on_other}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: channel resolution error: {e}")
|
||||
|
||||
# get channel hashes for the interface
|
||||
device = next((d for d in channel_list if d["interface_id"] == rxNode), None)
|
||||
if device:
|
||||
# Find the channel name whose hash matches channel_number
|
||||
for chan_name, info in device['channels'].items():
|
||||
if info['hash'] == channel_number:
|
||||
print(f"Matched channel hash {info['hash']} to channel name {chan_name}")
|
||||
channel_name = chan_name
|
||||
break
|
||||
#debug channel info
|
||||
# if "unknown" in str(channel_name):
|
||||
# logger.debug(f"System: Received Packet on Channel:{channel_number} on Interface:{rxNode}")
|
||||
# else:
|
||||
# logger.debug(f"System: Received Packet on Channel:{channel_number} Name:{channel_name} on Interface:{rxNode}")
|
||||
|
||||
# check if the packet has a simulator flag
|
||||
simulator_flag = packet.get('decoded', {}).get('simulator', False)
|
||||
@@ -303,10 +321,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':
|
||||
@@ -355,31 +384,38 @@ def onReceive(packet, interface):
|
||||
else:
|
||||
hop_count = hop_away
|
||||
|
||||
if hop == "" and hop_count > 0:
|
||||
if hop_count > 0:
|
||||
# set hop string from calculated hop count
|
||||
hop = f"{hop_count} Hop" if hop_count == 1 else f"{hop_count} Hops"
|
||||
|
||||
if hop_start == hop_limit and "lora" in str(transport_mechanism).lower() and (snr != 0 or rssi != 0):
|
||||
if hop_start == hop_limit and "lora" in str(transport_mechanism).lower() and (snr != 0 or rssi != 0) and hop_count == 0:
|
||||
# 2.7+ firmware direct hop over LoRa
|
||||
hop = "Direct"
|
||||
|
||||
if ((hop_start == 0 and hop_limit >= 0) or via_mqtt or ("mqtt" in str(transport_mechanism).lower())):
|
||||
if via_mqtt or "mqtt" in str(transport_mechanism).lower():
|
||||
hop = "MQTT"
|
||||
elif hop == "" and hop_count == 0 and (snr != 0 or rssi != 0):
|
||||
# this came from a UDP but we had signal info so gateway is used
|
||||
hop = "Gateway"
|
||||
elif "unknown" in str(transport_mechanism).lower() and (snr == 0 and rssi == 0):
|
||||
# we for sure detected this sourced from a UDP like host
|
||||
via_mqtt = True
|
||||
elif "udp" in str(transport_mechanism).lower():
|
||||
hop = "Gateway"
|
||||
|
||||
if hop in ("MQTT", "Gateway") and hop_count > 0:
|
||||
hop = f"{hop_count} Hops"
|
||||
hop = f" {hop_count} Hops"
|
||||
|
||||
# Add relay node info if present
|
||||
if packet.get('relayNode') is not None:
|
||||
relay_val = packet['relayNode']
|
||||
last_byte = relay_val & 0xFF
|
||||
if last_byte == 0x00:
|
||||
hex_val = 'FF'
|
||||
else:
|
||||
hex_val = f"{last_byte:02X}"
|
||||
hop += f" (Relay:{hex_val})"
|
||||
|
||||
if my_settings.enableHopLogs:
|
||||
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 +610,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:
|
||||
@@ -631,8 +671,11 @@ async def main():
|
||||
# Create core tasks
|
||||
tasks.append(asyncio.create_task(start_rx(), name="mesh_rx"))
|
||||
tasks.append(asyncio.create_task(watchdog(), name="watchdog"))
|
||||
|
||||
|
||||
# Add optional tasks
|
||||
if my_settings.dataPersistence_enabled:
|
||||
tasks.append(asyncio.create_task(dataPersistenceLoop(), name="data_persistence"))
|
||||
|
||||
if my_settings.file_monitor_enabled:
|
||||
tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor"))
|
||||
|
||||
|
||||
@@ -9,9 +9,13 @@ 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 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`
|
||||
|
||||
|
||||
|
||||
`docker compose run debug-console`
|
||||
### Other Stuff
|
||||
A cool tool to use with RAG creation with open-webui
|
||||
- https://github.com/microsoft/markitdown
|
||||
|
||||
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
|
||||
207
script/game_serve.py
Normal file
207
script/game_serve.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/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
|
||||
from modules.games.battleship_vid import parse_battleship_message
|
||||
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)
|
||||
elif packet_payload.startswith("MBSP:"):
|
||||
packet_payload = packet_payload[5:] # remove 'MBSP:'
|
||||
msg_tuple = (getattr(packet, 'from', None), packet.to, packet_payload)
|
||||
if msg_tuple not in seen_messages:
|
||||
add_seen_message(msg_tuple)
|
||||
#parse_battleship_message(packet_payload, from_id=packet_from_id)
|
||||
print(f"[Channel: {rx_channel}] [Port: {port_name}] Battleship 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.5b
|
||||
| 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()
|
||||
99
update.sh
Normal file → Executable file
99
update.sh
Normal file → Executable file
@@ -2,37 +2,31 @@
|
||||
# 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. script expects to be run inside a git repository."
|
||||
echo "ERROR: Failed to fetch from GitHub. Check your network connection. Script expects to be run inside a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for detached HEAD state
|
||||
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:"
|
||||
@@ -40,7 +34,6 @@ if [[ $(git symbolic-ref --short -q HEAD) == "" ]]; then
|
||||
echo "Proceed with caution; changes may not be saved to a branch."
|
||||
fi
|
||||
|
||||
# git pull with rebase to avoid unnecessary merge commits
|
||||
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
|
||||
@@ -53,35 +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"
|
||||
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
|
||||
|
||||
#create the tar.gz backup
|
||||
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
|
||||
@@ -90,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"
|
||||
@@ -103,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