Compare commits

...

154 Commits
v1.9 ... v1.9.5

Author SHA1 Message Date
Kelly
42e07d44e6 Merge pull request #198 from martinbogo/feature/tictactoe-game
Feature/tictactoe game
2025-10-06 14:11:23 -07:00
SpudGunMan
11f5218c2e Merge branch 'feature/tictactoe-game' of https://github.com/martinbogo/meshing-around into pr/198 2025-10-06 14:08:49 -07:00
SpudGunMan
e137420138 patch-2 2025-10-06 14:08:07 -07:00
Kelly
80c0f698b6 Update modules/games/tictactoe.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-06 13:51:56 -07:00
SpudGunMan
2045bf98f7 🧩 2025-10-06 13:45:02 -07:00
SpudGunMan
c36ce2c3a6 Update mesh_bot.py 2025-10-06 13:09:47 -07:00
SpudGunMan
7ff36a3d5f Update mesh_bot.py 2025-10-06 13:05:32 -07:00
SpudGunMan
ae1a3040b5 patches
dont need no stinking patches. thanks again.
2025-10-06 12:54:41 -07:00
Martin Bogomolni
84b6b48d60 feat: Add tic-tac-toe game with compact messaging
🎮 New Tic-Tac-Toe Game Features:
- Compact 3x3 board display using ASCII art
- Smart AI opponent with win/block/random strategy
- All messages under 200-character meshtastic limit (tested: 10-50 chars)
- Player vs Bot gameplay with X (player) vs O (bot)
- Win detection for rows, columns, and diagonals
- Tie game detection when board is full
- Game statistics tracking (games played, won)

🔧 Integration Features:
- Follows established game patterns from hangman/hamtest
- Added to restrictedCommands (DM-only like other games)
- Integrated with game tracker system for memory cleanup
- Added configuration option in config.template
- Automatic cleanup of stale game sessions

🎯 Game Mechanics:
- Players pick positions 1-9 corresponding to board layout
- Simple input parsing (extracts first digit from message)
- Graceful error handling for invalid moves
- 'end' command to quit game
- Automatic game cleanup on completion

📊 Message Examples:
- New game: 39 chars
- Game moves: 50 chars
- Win/lose: 40 chars
- Invalid move: 23 chars
- All well under 200-char limit

Tested: Complete game scenarios, AI behavior, message lengths
Follows: Existing game implementation patterns and memory management
2025-10-06 00:08:56 -07:00
Martin Bogomolni
9f3446b605 feat: Implement comprehensive memory management and stability improvements
🔧 Memory Management Enhancements:
- Add memory cleanup constants (MAX_CMD_HISTORY=1000, MAX_SEEN_NODES=500, MAX_MSG_HISTORY=100)
- Implement cleanup_memory() function to prevent unbounded list growth
- Add periodic cleanup every hour via watchdog process
- Clean up stale game tracker entries automatically
- Limit cmdHistory and msg_history sizes to prevent memory bloat

🚀 Async Task Management Improvements:
- Fix async task management in both mesh_bot.py and pong_bot.py
- Implement proper task cleanup and cancellation on shutdown
- Add task names for better debugging and monitoring
- Use asyncio.gather() with return_exceptions=True for better error handling
- Prevent task hanging and resource leaks

🛡️ Enhanced Resource Management:
- Improve exit_handler() with proper interface cleanup
- Add atexit.register() for automatic graceful shutdown
- Ensure all meshtastic interfaces are properly closed
- Save persistent data (BBS, email, SMS, game scores) on exit
- Perform final memory cleanup during shutdown

🔍 Better Exception Handling:
- Replace bare except: blocks with specific exception handling
- Add proper error logging throughout the codebase
- Improve BBS database operations with better error recovery
- Add try/catch blocks for file operations and imports

📈 System Stability Improvements:
- Prevent memory leaks from growing lists and dictionaries
- Add automatic cleanup of stale player tracking data
- Improve error recovery in watchdog and async loops
- Better handling of interface connection failures

These changes address critical memory management issues that could cause
the bot to consume increasing memory over time, eventually leading to
system instability. The improvements ensure long-term reliability and
better resource utilization.

Fixes: Memory leaks, async task hanging, resource cleanup issues
Improves: System stability, error handling, resource management
Tested: Code analysis and review completed
2025-10-05 23:45:40 -07:00
SpudGunMan
10add3147d Update system.py 2025-10-05 23:10:23 -07:00
SpudGunMan
4e074a309f Update system.py 2025-10-05 23:05:55 -07:00
SpudGunMan
f394f58b9f paxCounter 2025-10-05 22:30:03 -07:00
SpudGunMan
30bcee498d Update config.template 2025-10-05 22:24:35 -07:00
SpudGunMan
5c54ce0b70 better waypoint data 2025-10-05 21:50:56 -07:00
SpudGunMan
b4684f49ff Update system.py 2025-10-05 21:38:19 -07:00
SpudGunMan
c6c1e9f637 refactor more consumeMetadata 2025-10-05 21:13:39 -07:00
SpudGunMan
54540b1656 cleanup 2025-10-05 20:26:48 -07:00
SpudGunMan
006c9f58c6 enhance bbsLink 2025-10-05 19:44:00 -07:00
SpudGunMan
3d582e9b77 enhance import of BBS
for ssh copy
2025-10-05 19:20:44 -07:00
SpudGunMan
0578c0b233 more enhancing metadata 2025-10-05 19:00:34 -07:00
SpudGunMan
98ccf8708f enhanceTelemetry
logging and handlers for telemetry
2025-10-05 17:47:19 -07:00
SpudGunMan
47280f4330 Update bbstools.py
comment from https://github.com/SpudGunMan/meshing-around/issues/194
2025-10-05 15:26:50 -07:00
SpudGunMan
4b0b074ba7 mathWasntMathn'
@mesb1 thanks
2025-10-05 09:08:47 -07:00
SpudGunMan
d8d79f46b5 Update README.md
@pdxlocations
2025-10-04 15:45:43 -07:00
SpudGunMan
1a49a81cf5 load_bbsdb with 'api'
allows for ssh file synch?
2025-10-04 12:54:20 -07:00
SpudGunMan
5c0b04f0b7 Update injectDM.py 2025-10-03 19:11:40 -07:00
SpudGunMan
8f1cb6265d Update injectDM.py 2025-10-03 19:07:25 -07:00
SpudGunMan
c51a4584ae enhance bbsAPI
switch for diskIO
2025-10-03 19:06:14 -07:00
SpudGunMan
13ee6d4fd6 enhance bbsDM
enable a new 'API' to inject into the pkl file for DM's also see 2d44faac98
2025-10-03 19:02:15 -07:00
SpudGunMan
2d44faac98 Create injectDM.py
Usage: python3 script/injectDM.py -s NODEID -d NODEID -m "message"
2025-10-03 18:15:57 -07:00
SpudGunMan
0a2daeac1f Update joke.py 2025-10-03 15:42:05 -07:00
SpudGunMan
da7ba256d8 Update joke.py 2025-10-03 15:41:40 -07:00
SpudGunMan
42e99a0dc1 Update joke.py 2025-10-03 15:40:54 -07:00
SpudGunMan
ca5896a015 Update locationdata.py 2025-10-03 15:35:04 -07:00
SpudGunMan
ebe2636104 refactor riverflow 2025-10-03 15:34:03 -07:00
SpudGunMan
971a421d01 riverflow refactor 2025-10-03 15:15:40 -07:00
SpudGunMan
9d080de8f3 Update locationdata.py 2025-10-03 15:01:41 -07:00
SpudGunMan
5209092928 better riverflow logic 2025-10-03 14:54:12 -07:00
SpudGunMan
6439f49fb1 Update filemon.py 2025-10-03 12:28:47 -07:00
SpudGunMan
c115cdf82f enhance x: with subprocess 2025-10-03 12:15:14 -07:00
SpudGunMan
63fccbdf3e Update config.template 2025-10-03 12:01:41 -07:00
SpudGunMan
c23564d8b5 Update system.py 2025-10-02 19:37:57 -07:00
SpudGunMan
0b9db28951 Update README.md 2025-10-02 19:18:39 -07:00
SpudGunMan
1aa4eddb3b Update filemon.py 2025-10-02 19:07:37 -07:00
SpudGunMan
a7de64b385 Update filemon.py 2025-10-02 19:03:15 -07:00
SpudGunMan
2e8206d4ec x:ShellCommands
this x: is a direct shell access from DM, to enable it needs the enable_runShellCmd, allowXcmd, xcmdChannel set. Make sure your secure.
2025-10-02 18:58:27 -07:00
SpudGunMan
b47c13503b Update filemon.py 2025-10-02 13:37:17 -07:00
SpudGunMan
a66dbd13fd Update config.template 2025-10-02 13:17:48 -07:00
SpudGunMan
02322cdf91 cleanup
fixes per https://github.com/SpudGunMan/meshing-around/issues/192
2025-10-02 04:51:40 -07:00
SpudGunMan
955d3681e9 Update joke.py 2025-10-01 11:41:59 -07:00
SpudGunMan
9d96c02870 catch em all 2025-09-29 16:59:13 -07:00
SpudGunMan
c87dba1e06 fixWikiErrors 2025-09-29 16:58:34 -07:00
SpudGunMan
1c3d2f7f18 Update mesh_bot.py 2025-09-29 16:16:33 -07:00
SpudGunMan
b53a7d3832 Update joke.py 2025-09-29 16:13:01 -07:00
SpudGunMan
99e74ae8c0 lower! 2025-09-28 18:22:56 -07:00
SpudGunMan
1bdfc3828f cleanup 2025-09-28 18:20:42 -07:00
SpudGunMan
26f39e76e6 somdays tabs killl me 2025-09-28 17:41:13 -07:00
SpudGunMan
c49dcfbfc8 aarg 2025-09-28 17:38:59 -07:00
SpudGunMan
1008ec6afa yarp 2025-09-28 17:36:57 -07:00
SpudGunMan
8c0a1bbd0d cleanup
need to fix this
2025-09-28 17:27:55 -07:00
SpudGunMan
30d8f00aeb Update update.sh 2025-09-28 17:25:31 -07:00
SpudGunMan
033b1bcd51 Update update.sh 2025-09-28 17:21:00 -07:00
SpudGunMan
d80b2da06a Update launch.sh 2025-09-28 17:18:56 -07:00
SpudGunMan
dc02464662 Update update.sh 2025-09-28 17:17:53 -07:00
SpudGunMan
b738881ff1 Update update.sh 2025-09-28 17:16:04 -07:00
SpudGunMan
b8f0601684 enhance 2025-09-28 17:11:09 -07:00
SpudGunMan
910c045b08 Update update.sh 2025-09-28 17:07:12 -07:00
SpudGunMan
35ba139577 enhance 2025-09-28 17:03:59 -07:00
SpudGunMan
eddd990cc5 Update README.md
ffs
2025-09-28 16:32:40 -07:00
SpudGunMan
6e3d83401f enhance echo
enhance echo
2025-09-28 16:28:31 -07:00
SpudGunMan
f9ab6a79d3 echo
echo command will just echo, off by default its handy for things like making a demo or node speak
2025-09-28 15:04:56 -07:00
SpudGunMan
4b2402c286 Update space.py 2025-09-27 17:24:21 -07:00
SpudGunMan
47e0276f0c howtall
returns height of something you give a shadow by using sun angle
2025-09-26 19:23:12 -07:00
SpudGunMan
6dc54abf43 enhance 2025-09-26 19:16:16 -07:00
SpudGunMan
53ff37c782 howtall
is this handy? I thought it might be for tree tower use
2025-09-26 19:02:02 -07:00
SpudGunMan
5c48e008ee Update checklist.py 2025-09-26 19:00:46 -07:00
SpudGunMan
ddac18bb13 Update README.md 2025-09-26 16:13:34 -07:00
SpudGunMan
507919bb4c aarg 2025-09-26 16:05:55 -07:00
SpudGunMan
b4f3f9887d OCD 2025-09-26 15:58:10 -07:00
SpudGunMan
aa051abbd4 better Logic for handler 2025-09-26 15:54:51 -07:00
SpudGunMan
8ba0c6f14c Update system.py 2025-09-26 15:47:51 -07:00
SpudGunMan
166b15463a Update launch.sh 2025-09-26 15:46:36 -07:00
SpudGunMan
d02924bfda Update launch.sh 2025-09-26 15:45:26 -07:00
SpudGunMan
0a123251f4 Update README.md 2025-09-26 15:44:23 -07:00
SpudGunMan
a80f926d08 Update launch.sh 2025-09-26 15:43:29 -07:00
SpudGunMan
b8318f8f3e Update README.md 2025-09-26 15:38:36 -07:00
SpudGunMan
adb6fa3b5a Update system.py 2025-09-26 15:36:49 -07:00
SpudGunMan
a9254c9c79 addFav
Helper Script to Add Favorite to the bot node for admin and other use
2025-09-26 15:32:24 -07:00
SpudGunMan
0ac642ac44 Update config.template 2025-09-24 15:21:02 -07:00
SpudGunMan
ec24a8b8dd typo 2025-09-24 15:19:57 -07:00
SpudGunMan
f79026a95f enhance
DEBUGpackets, debugMetadata hidden config.ini values
2025-09-24 15:16:37 -07:00
SpudGunMan
99acaf28a1 Update system.py 2025-09-24 15:14:17 -07:00
SpudGunMan
9c068c8d28 Update settings.py 2025-09-24 15:14:07 -07:00
SpudGunMan
d9ab1b88c1 Update system.py 2025-09-24 15:10:26 -07:00
SpudGunMan
8499b6c851 Update README.md 2025-09-19 09:36:28 -07:00
SpudGunMan
ea47bf9329 bugfix parser
thanks again Iris!
2025-09-19 09:32:53 -07:00
SpudGunMan
229043c32a fix config.ini bug
Thanks Iris for pointing out this long time bug
2025-09-19 09:05:37 -07:00
SpudGunMan
5dbd137f14 enhance 2025-09-19 08:51:43 -07:00
SpudGunMan
ca83117180 Update locationdata.py
fix polygone at 4+
2025-09-17 12:13:03 -07:00
SpudGunMan
fde37313f5 Update locationdata.py 2025-09-17 11:55:34 -07:00
Kelly
a39f588f9f Merge pull request #188 from SpudGunMan/lab
Noisy Telemetry
lost work on location data
2025-09-17 11:52:50 -07:00
SpudGunMan
39e348f701 Update locationdata.py
got lost somehow
2025-09-17 11:43:43 -07:00
SpudGunMan
4fdfa49b87 Revert "Update locationdata.py"
This reverts commit 8a64b8e7ad.
2025-09-17 11:43:22 -07:00
SpudGunMan
8a64b8e7ad Update locationdata.py
this got lost somewhere
2025-09-17 11:41:35 -07:00
SpudGunMan
c585f60882 Update README.md
think this was a typo
2025-09-17 10:55:38 -07:00
SpudGunMan
4e91801cb9 Update system.py
reset alerts
2025-09-17 10:55:30 -07:00
SpudGunMan
4e1d3e2b58 lower volume 2025-09-17 10:55:17 -07:00
SpudGunMan
38d5006236 Update system.py 2025-09-16 17:24:55 -07:00
SpudGunMan
642738e3b6 noisyNodeLogging
telemetry logger idea
2025-09-16 17:09:47 -07:00
SpudGunMan
5728d6b9e3 Update locationdata.py
didnt like this
2025-09-13 18:25:35 -07:00
SpudGunMan
82bec43f22 Update locationdata.py
move this and add miles
2025-09-13 18:21:09 -07:00
SpudGunMan
7ca8b6793a Update locationdata.py
limit noaa which just gave me a 11 digit richter ooof
2025-09-13 17:33:18 -07:00
SpudGunMan
d589d3e155 Update system.py 2025-09-11 18:00:42 -07:00
SpudGunMan
452c4aa520 Update locationdata.py 2025-09-11 17:50:19 -07:00
SpudGunMan
e5d2ea4bcb Update system.py 2025-09-10 19:24:44 -07:00
SpudGunMan
2596d133fd Update system.py 2025-09-10 19:23:08 -07:00
SpudGunMan
c3221d64a8 enhance 2025-09-10 19:03:44 -07:00
SpudGunMan
dcabfc0f50 Update system.py 2025-09-10 19:00:37 -07:00
SpudGunMan
2eca5f644a add aircraft lookup to highFlying info
@Cisien for the idea
2025-09-10 18:58:33 -07:00
SpudGunMan
3f90a7fc39 dedupe emergency alerts
double check we didnt already send the message here it could be duplicated elsewhere
2025-09-10 18:06:43 -07:00
SpudGunMan
9639c793d9 Update README.md
i can spell well
2025-09-10 13:09:54 -07:00
SpudGunMan
18294f4ca3 Update locationdata.py 2025-09-10 12:48:33 -07:00
SpudGunMan
14043dd950 Merge branch 'main' of https://github.com/SpudGunMan/meshing-around 2025-09-10 12:46:25 -07:00
SpudGunMan
fe1c264b19 Update locationdata.py 2025-09-10 12:46:01 -07:00
Kelly
8848d9b6fe Merge pull request #184 from ulab/patch-2
Add fileMon files to .gitignore
2025-09-10 09:49:28 -07:00
Kelly
daa6e85318 Merge pull request #185 from sodoku/dealert-fixes
fix: wrong variable use
2025-09-10 09:49:01 -07:00
sodoku
f7f127590d fix: wrong variable use 2025-09-10 18:41:45 +02:00
Balu
5b0fd65d31 Add fileMon files to .gitignore 2025-09-10 14:52:19 +02:00
SpudGunMan
fe12e1f107 enhance 2025-09-09 15:55:34 -07:00
SpudGunMan
11025f101f add QRN to hfcond 2025-09-09 15:19:46 -07:00
SpudGunMan
92a5fc2ed5 Update space.py 2025-09-09 15:03:37 -07:00
SpudGunMan
3f891d93d2 add space
same here to stop runaway commands
2025-09-09 15:03:22 -07:00
SpudGunMan
398c9ddb60 remove space
this happens to stop a multi-bot runaway
2025-09-09 15:02:37 -07:00
SpudGunMan
518eb25f0d Merge branch 'main' of https://github.com/SpudGunMan/meshing-around 2025-09-09 15:00:54 -07:00
SpudGunMan
0613cc7b3d Update README.md 2025-09-09 15:00:51 -07:00
Kelly
be2d1fbf72 Merge pull request #179 from ulab/space_spaces
Add some spaces around emojiis and after punctuation
2025-09-09 13:26:46 -07:00
Kelly
8595190ed1 Merge pull request #181 from SpudGunMan/lab
HowFar
2025-09-09 13:26:04 -07:00
SpudGunMan
5703cfb381 🗺️ howfar 2025-09-09 12:42:03 -07:00
SpudGunMan
ff9b76c966 Update locationdata.py 2025-09-09 12:08:59 -07:00
Balu
e41f692038 Add some spaces around emojiis and after punctuation 2025-09-09 14:06:27 +02:00
SpudGunMan
19935d9f08 howfar
initial idea goofin to see if this works
2025-09-07 19:12:57 -07:00
SpudGunMan
f0e8b2c057 🐛 2025-09-02 11:19:23 -07:00
SpudGunMan
eb2d809fe4 from: in bbs_read_message
Reading Messages has the last 4 of the HexID now "from"
2025-08-31 20:37:40 -07:00
SpudGunMan
b276bbb40a rlist command in help 2025-08-31 19:47:59 -07:00
SpudGunMan
35ea7cb505 fix HTML parsing for rlist getRepeaterBook
Thanks to rhinodods on discord for the alert
2025-08-31 18:02:21 -07:00
SpudGunMan
eb78c2e5e8 Update install.sh 2025-08-29 17:48:30 -07:00
SpudGunMan
3df16b7626 Update system.py 2025-08-29 17:25:51 -07:00
SpudGunMan
8c01433d14 🚨fixes/enhancments
better multi network control of alerts and 🪫 alert to mesh
2025-08-27 12:10:38 -07:00
SpudGunMan
a8dbef7e12 Update install.sh 2025-08-26 17:24:29 -07:00
SpudGunMan
23478812e0 Update install.sh 2025-08-26 16:51:33 -07:00
SpudGunMan
08c2c668f9 EarthQuake
from USGS data seismicportal.eu wont let me set a radius on lookups but looking into it
2025-08-26 14:21:10 -07:00
SpudGunMan
3b41e39ff5 Update system.py 2025-08-26 14:09:23 -07:00
SpudGunMan
78fa3209e6 Update install.sh
update gemma3:270m
2025-08-26 11:04:46 -07:00
21 changed files with 1544 additions and 268 deletions

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ data/rag/*
# qrz db
data/qrz.db
# fileMon
news.txt
alert.txt
bee.txt

View File

@@ -16,6 +16,7 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo
### Network Tools
- **Build, Test Local Mesh**: Ping allow for message delivery testing with more realistic packets vs. telemetry
- **Test Node Hardware**: `test` will send incremental sized data into the radio buffer for overall length of message testing
- **Network Monitoring**: Alert on noisy nodes, node locations, and best placment for relay nodes.
### Multi Radio/Node Support
- **Simultaneous Monitoring**: Monitor up to nine networks at the same time.
@@ -27,21 +28,22 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo
- **Store and Forward**: Replay messages with the `messages` command, and log messages locally to disk.
- **Send Mail**: Send mail to nodes using `bbspost @nodeNumber #message` or `bbspost @nodeShortName #message`.
- **BBS Linking**: Combine multiple bots to expand BBS reach.
- **E-Mail/SMS**: Send mesh-messages to E-Mail or SMS(Email) expanding visability.
- **E-Mail/SMS**: Send mesh-messages to E-Mail or SMS(Email) expanding visibility.
- **New Node Hello**: Send a hello to any new node seen in text message.
### Interactive AI and Data Lookup
- **NOAA location Data**: Get localized weather(alerts), River Flow, and Tide information. Open-Meteo is used for wx only outside NOAA coverage.
- **NOAA/USGS location Data**: Get localized weather(alerts), Earthquake, River Flow, and Tide information. Open-Meteo is used for wx only outside NOAA coverage.
- **Wiki Integration**: Look up data using Wikipedia results.
- **Ollama LLM AI**: Interact with the [Ollama](https://github.com/ollama/ollama/tree/main/docs) LLM AI for advanced queries and responses.
- **Satalite Pass Info**: Get passes for satalite at your location.
- **Satellite Pass Info**: Get passes for satellite at your location.
- **GeoMeasuring**: HowFar from point to point using collected GPS packets on the bot to plot a course or space. Find Center of points for Fox&Hound direction finding.
### Proximity Alerts
- **Location-Based Alerts**: Get notified when members arrive back at a configured lat/long, perfect for remote locations like campsites.
- **High Flying Alerts**: Get notified when nodes with high altitude are seen on mesh
### CheckList / Check In Out
- **Asset Tracking**: Maintain a list of node/asset checkin and checkout. Usefull for accountability of people, assets. Radio-Net, FEMA, Trailhead.
- **Asset Tracking**: Maintain a list of node/asset checkin and checkout. Useful foraccountability of people, assets. Radio-Net, FEMA, Trailhead.
### Fun and Games
- **Built-in Games**: Enjoy games like DopeWars, Lemonade Stand, BlackJack, and VideoPoker.
@@ -62,6 +64,7 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo
### File Monitor Alerts
- **File Monitor**: Monitor a flat/text file for changes, broadcast the contents of the message to the mesh channel.
- **News File**: On request of news, the contents of the file are returned.
- **Shell Command Access**: Pass commands via DM directly to the host OS
### Data Reporting
- **HTML Generator**: Visualize bot traffic and data flows with a built-in HTML generator for [data reporting](logs/README.md).
@@ -70,7 +73,7 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo
- **Message Chunking**: Automatically chunk messages over 160 characters to ensure higher delivery success across 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, [buildroot](https://github.com/buildroot-meshtastic/buildroot-meshtastic), also see [femtofox](https://github.com/noon92/femtofox). 🥔 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.
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, [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.
### Quick Setup
#### Clone the Repository
@@ -96,11 +99,13 @@ git clone https://github.com/spudgunman/meshing-around
| `whereami` | Returns the address of the sender's location if known |
| `whoami` | Returns details of the node asking, also returned when position exchanged 📍 | ✅ |
| `whois` | Returns details known about node, more data with bbsadmin node | ✅ |
| `echo` | Echo string back, disabled by default | ✅ |
### Radio Propagation & Weather Forcasting
### Radio Propagation & Weather Forecasting
| Command | Description | |
|---------|-------------|-------------------
| `ea` and `ealert` | Return FEMA iPAWS/EAS alerts in USA or DE Headline or expanded details for USA | |
| `earthquake` | Returns the largest and number of USGS events for the location | |
| `hfcond` | Returns a table of HF solar conditions | |
| `rlist` | Returns a table of nearby repeaters from RepeaterBook | |
| `riverflow` | Return information from NOAA for river flow info. Example: `riverflow modules/settings.py`| |
@@ -110,7 +115,7 @@ git clone https://github.com/spudgunman/meshing-around
| `valert` | Returns USGS Volcano Data | |
| `wx` | Return local weather forecast, NOAA or Open Meteo (which also has `wxc` for metric and imperial) | |
| `wxa` and `wxalert` | Return NOAA alerts. Short title or expanded details | |
| `mwx` | Return the NOAA Coastal Marine Forcast data | |
| `mwx` | Return the NOAA Coastal Marine Forecast data | |
### Bulletin Board & Mail
| Command | Description | |
@@ -124,7 +129,7 @@ git clone https://github.com/spudgunman/meshing-around
| `bbslink` | Links Bulletin Messages between BBS Systems | ✅ |
| `email:` | Sends email to address on file for the node or `email: bob@test.net # hello from mesh` | |
| `sms:` | Send sms-email to multiple address on file | |
| `setemail`| Sets the email for easy communciations | |
| `setemail`| Sets the email for easy communications | |
| `setsms` | Adds the SMS-Email for quick communications | |
| `clearsms` | Clears all SMS-Emails on file for node | |
@@ -136,6 +141,8 @@ git clone https://github.com/spudgunman/meshing-around
| `readnews` | returns the contents of a file (news.txt, by default) via the chunker on air | ✅ |
| `satpass` | returns the pass info from API for defined NORAD ID in config or Example: `satpass 25544,33591`| |
| `wiki:` | Searches Wikipedia and returns the first few sentences of the first result if a match. Example: `wiki: lora radio` |
| `howfar` | returns the distance you have traveled since your last HowFar. `howfar reset` to start over | ✅ |
| `howtall` | returns height of something you give a shadow by using sun angle | ✅ |
### CheckList
| Command | Description | |
@@ -152,7 +159,7 @@ git clone https://github.com/spudgunman/meshing-around
| `golfsim` | Plays a 9-hole Golf Simulator | ✅ |
| `hamtest` | FCC/ARRL Quiz `hamtest general` or `hamtest extra` and `score` | ✅ |
| `hangman` | Plays the classic word guess game | ✅ |
| `joke` | Tells a joke | |
| `joke` | Tells a joke | |
| `lemonstand` | Plays the classic Lemonade Stand finance game | ✅ |
| `mastermind` | Plays the classic code-breaking game | ✅ |
| `videopoker` | Plays basic 5-card hold Video Poker | ✅ |
@@ -225,7 +232,7 @@ lon = -123.0
UseMeteoWxAPI = True
coastalEnabled = False # NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
# Find the correct costal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/
# Find the correct coastal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/
# this map can help https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map'
myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt # myCoastalZone is the .txt file with the forecast data
coastalForecastDays = 3 # number of data points to return, default is 3
@@ -264,6 +271,7 @@ SentryHoldoff = 2 # channel to send a message to when the watchdog is triggered
sentryIgnoreList = # list of ignored nodes numbers ex: 2813308004,4258675309
highFlyingAlert = True # HighFlying Node alert
highFlyingAlertAltitude = 2000 # Altitude in meters to trigger the alert
highflyOpenskynetwork = True # check with OpenSkyNetwork if highfly detected for aircraft
```
### E-Mail / SMS Settings
@@ -272,8 +280,8 @@ To enable connectivity with SMTP allows messages from meshtastic into SMTP. The
```ini
[smtp]
# enable or disable the SMTP module, minimum required for outbound notifications
enableSMTP = True # enable or disable the IMAP module for inbound email, not implimented yet
enableImap = False # list of Sysop Emails seperate with commas, used only in emergemcy responder currently
enableSMTP = True # enable or disable the IMAP module for inbound email, not implemented yet
enableImap = False # list of Sysop Emails separate with commas, used only in emergency responder currently
sysopEmails =
# See config.template for all the SMTP settings
SMTP_SERVER = smtp.gmail.com
@@ -316,10 +324,9 @@ myRegionalKeysDE = 110000000000,120510000000
This uses the defined lat-long of the bot for collecting of data from the API. see [File-Monitoring](#File-Monitoring) for ideas to collect EAS alerts from a RTL-SDR.
```ini
# EAS Alert Broadcast
wxAlertBroadcastEnabled = True
# EAS Alert Broadcast Channels
wxAlertBroadcastCh = 2,4
wxAlertBroadcastEnabled = True # EAS Alert Broadcast
wxAlertBroadcastCh = 2,4 # EAS Alert Broadcast Channels
ignoreEASenable = True # Ignore any headline that includes followig word list
ignoreEASwords = test,advisory
```
@@ -390,12 +397,15 @@ Some dev notes for ideas of use
```ini
[fileMon]
filemon_enabled = True
file_path = alert.txt
broadcastCh = 2,4
enable_read_news = False
file_path = alert.txt # text file to monitor for changes
broadcastCh = 2 # channel to send the message to can be 2,3 multiple channels comma separated
enable_read_news = False # news command will return the contents of a text file
news_file_path = news.txt
news_random_line = False # only return a single random line from the news file
enable_runShellCmd = False # enables running of bash commands runShell.sh demo for sysinfo
enable_runShellCmd = False # enable the use of exernal shell commands, this enables some data in `sysinfo`
# if runShellCmd and you think it is safe to allow the x: command to run
# direct shell command handler the x: command in DMs user must be in bbs_admin_list
allowXcmd = True
```
#### Offline EAS
@@ -436,19 +446,13 @@ training = True # Training mode will not send the hello message to new nodes, us
In the config.ini enable the module
```ini
[scheduler]
# enable or disable the scheduler module
enabled = False
# interface to send the message to
interface = 1
# channel to send the message to
enabled = False # enable or disable the scheduler module
interface = 1 # channel to send the message to
channel = 2
message = "MeshBot says Hello! DM for more info."
# value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun
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
time =
value = # value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun
interval = # interval to use when time is not set (e.g. every 2 days)
time = # time of day in 24:00 hour format when value is 'day' and interval is not set
```
The basic brodcast message can be setup in condig.ini. For advanced, See mesh_bot.py around the bottom of file, line [1491](https://github.com/SpudGunMan/meshing-around/blob/e94581936530c76ea43500eebb43f32ba7ed5e19/mesh_bot.py#L1491) to edit the schedule. See [schedule documentation](https://schedule.readthedocs.io/en/stable/) for more. Recomend to backup changes so they dont get lost.
@@ -461,7 +465,7 @@ schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now
```
#### BBS Link
The scheduler also handles the BBS Link Brodcast message, this would be an esxample of a mesh-admin channel on 8 being used to pass BBS post traffic between two bots as the initator, one direction pull.
The scheduler also handles the BBS Link Broadcast message, this would be an example of a mesh-admin channel on 8 being used to pass BBS post traffic between two bots as the initiator, one direction pull.
```python
# Send bbslink looking for peers every other day at 10:00 using send_message function to channel 8 on device 1
schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", 8, 0, 1))
@@ -471,8 +475,20 @@ bbslink_enabled = True
bbslink_whitelist = # list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all
```
### Firmware 2.6 DM Key, and 2.7 CLIENT_BASE Favorite Nodes
Firmware 2.6 introduced [PKC](https://meshtastic.org/blog/introducing-new-public-key-cryptography-in-v2_5/), enabling secure private messaging by adding necessary keys to each node. To fully utilize this feature, you should add favorite nodes—such as BBS admins—to your nodes favorites list to ensure their keys are retained. A helper script is provided to simplify this process:
- Run the helper script from the main program directory: `python3 script/addFav.py`
- By default, this script adds nodes from `bbs_admin_list` and `bbslink_whitelist`
- If using a virtual environment, run: `launch.sh addfav`
To configure favorite nodes, add their numbers to your config file:
```conf
[general]
favoriteNodeList = # list of favorite nodes numbers ex: 2813308004,4258675309 used by script/addFav.py
```
### MQTT Notes
There is no direct support for MQTT in the code, however, reports from Discord are that using [meshtasticd](https://meshtastic.org/docs/hardware/devices/linux-native-hardware/) with no radio and attaching the bot to the software node, which is MQTT-linked, allows routing. Tested working fully Firmware:2.5.15.79da236 with [mosquitto](https://meshtastic.org/docs/software/integrations/mqtt/mosquitto/).
There is no direct support for MQTT in the code, however, reports from Discord are that using [meshtasticd](https://meshtastic.org/docs/hardware/devices/linux-native-hardware/) with no radio and attaching the bot to the software node, which is MQTT-linked, allows routing. Tested working fully Firmware:2.6.11 with [mosquitto](https://meshtastic.org/docs/software/integrations/mqtt/mosquitto/).
~~There also seems to be a quicker way to enable MQTT by having your bot node with the enabled [serial](https://meshtastic.org/docs/configuration/module/serial/) module with echo enabled and MQTT uplink and downlink. These two~~
@@ -506,7 +522,8 @@ I used ideas and snippets from other responder bots and want to call them out!
- **dj505**: trying it on windows!
- **mikecarper**: ideas, and testing. hamtest
- **c.merphy360**: high altitude alerts
- **Cisien, bitflip, **Woof**, **propstg**, **trs2982**, **Josh** and Hailo1999**: For testing and feature ideas on Discord and GitHub.
- **Iris**: testing and finding 🐞
- **Cisien, bitflip, Woof, propstg, trs2982, Josh, mesb1, and Hailo1999**: For testing and feature ideas on Discord and GitHub.
- **Meshtastic Discord Community**: For tossing out ideas and testing code.
### Tools

View File

@@ -38,6 +38,8 @@ ignoreChannels =
cmdBang = False
# require explicit command, the message will only be processed if it starts with a command word
explicitCmd = True
# list of favorite nodes numbers ex: 2813308004,4258675309 used by script/addFav.py
favoriteNodeList =
# motd is reset to this value on boot
motd = Thanks for using MeshBOT! Have a good day!
@@ -94,6 +96,11 @@ log_backup_count = 32
#Do not retry enabling interface if it fails, just exit to let OS restart the bot
dont_retry_disconnect = False
#echo command, will echo back your message as the bot
enableEcho = False
# command will only echo 1:1 if sent on this channel, otherwise it will prepend @yourname
echoChannel = 9
[emergencyHandler]
# enable or disable the emergency response handler
enabled = False
@@ -107,18 +114,24 @@ SentryEnabled = True
emailSentryAlerts = False
# radius in meters to detect someone close to the bot
SentryRadius = 100
# channel to send a message to when the watchdog is triggered
# device interface and channel to send the alert message to
SentryInterface = 1
SentryChannel = 2
# holdoff time multiplied by seconds(20) of the watchdog
SentryHoldoff = 9
# list of ignored nodes numbers ex: 2813308004,4258675309
sentryIgnoreList =
# Enable detection sensor alert, requires external sensor connected to node
detectionSensorAlert = False
# HighFlying Node alert
highFlyingAlert = True
# Altitude in meters to trigger the alert
highFlyingAlertAltitude = 2000
# check with OpenSkyNetwork if highfly detected for aircraft
highflyOpenskynetwork = True
# Channel to send Alert when the high flying node is detected
highFlyingAlertInterface = 1
highFlyingAlertChannel = 2
# list of nodes numbers to ignore high flying alert ex: 2813308004,4258675309
highFlyingIgnoreList =
@@ -132,7 +145,9 @@ bbs_admin_list =
# enable bbs synchronization with other nodes
bbslink_enabled = False
# list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all
bbslink_whitelist =
bbslink_whitelist =
# enable API script access (increases disk i/o)
bbsAPI_enabled = False
# location module
[location]
@@ -143,7 +158,7 @@ lon = -123.0
# Default to metric units rather than imperial
useMetric = False
# repeaterList lookup location (rbook / artsci)
# repeaterList lookup location (rbook / artsci / False)
repeaterLookup = rbook
# NOAA weather forecast days
@@ -262,14 +277,22 @@ signalCycleLimit = 5
[fileMon]
filemon_enabled = False
# text file to monitor for changes
file_path = alert.txt
# channel to send the message to can be 2,3 multiple channels comma separated
broadcastCh = 2
# news command will return the contents of a text file
enable_read_news = False
news_file_path = news.txt
# only return a single random line from the news file
news_random_line = False
# enable the use of exernal shell commands
# enable the use of exernal shell commands, this enables some data in `sysinfo`
enable_runShellCmd = False
# if runShellCmd and you think it is safe to allow the x: command to run
# direct shell command handler the x: command in DMs
allowXcmd = False
[smtp]
# enable or disable the SMTP module
@@ -309,6 +332,7 @@ mastermind = True
golfsim = True
hangman = True
hamtest = True
tictactoe = True
[messagingSettings]
# delay in seconds for response to avoid message collision /throttling
@@ -323,5 +347,11 @@ wantAck = False
maxBuffer = 200
#Enable Extra logging of Hop count data
enableHopLogs = False
# Noisy Node Telemetry Logging and packet threshold
noisyNodeLogging = False
noisyTelemetryLimit = 5
# Enable detailed packet logging all packets
DEBUGpacket = False
# metaPacket detailed logging, the filter negates the port ID
debugMetadata = False
metadataFilter = TELEMETRY_APP,POSITION_APP

View File

@@ -248,10 +248,9 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
echo "Emoji font installed!, reboot to load the font"
fi
printf "\nOptionally if you want to install the multi gig LLM Ollama compnents we will execute the following commands\n"
printf "\nOptionally if you want to install the LLM Ollama compnents we will execute the following commands\n"
printf "\ncurl -fsSL https://ollama.com/install.sh | sh\n"
printf "ollama pull gemma3:latest\n"
printf "Total download is multi GB, recomend pi5/8GB or better for this\n"
printf "ollama pull gemma3:270m\n"
# ask if the user wants to install the LLM Ollama components
printf "\nDo you want to install the LLM Ollama components? (y/n)"
read ollama
@@ -259,11 +258,30 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
curl -fsSL https://ollama.com/install.sh | sh
# ask if want to install gemma3:latest
printf "\n Ollama install done now we can install the gemma3:latest components\n"
echo "Do you want to install the gemma3:latest components? (y/n)"
printf "\n Ollama install done now we can install the gemma3:270m components\n"
echo "Do you want to install the gemma3:270m components? (y/n)"
read gemma
if [[ $(echo "${gemma}" | grep -i "^y") ]]; then
ollama pull gemma3:latest
ollama pull gemma3:270m
fi
fi
# ask if the user wants to edit the ollama service for API access
if [[ -f /etc/systemd/system/ollama.service ]]; then
printf "\nEdit /etc/systemd/system/ollama.service and add Environment=OLLAMA_HOST=0.0.0.0 for API? (y/n)"
read editollama
if [[ $(echo "${editollama}" | grep -i "^y") ]]; then
replace="s|\[Service\]|\[Service\]\nEnvironment=\"OLLAMA_HOST=0.0.0.0\"|g"
sudo sed -i "$replace" /etc/systemd/system/ollama.service
sudo systemctl daemon-reload
sudo systemctl restart ollama.service
printf "\nOllama service updated and restarted\n"
fi
# assume we want to enable ollama in config.ini
if [[ -f config.ini ]]; then
replace="s|ollama = False|ollama = True|g"
sed -i "$replace" config.ini
printf "\nOllama enabled in config.ini\n"
fi
fi

View File

@@ -26,8 +26,10 @@ elif [ "$1" == "html" ]; then
python3 etc/report_generator.py
elif [ "$1" == "html5" ]; then
python3 etc/report_generator5.py
elif [[ "$1" == add* ]]; then
python3 script/addFav.py
else
echo "Please provide a bot to launch (pong/mesh) or a report to generate (html/html5)"
echo "Please provide a bot to launch (pong/mesh) or a report to generate (html/html5) or addFav"
exit 1
fi

View File

@@ -15,14 +15,13 @@ from modules.log import *
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"]
restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe"]
restrictedResponse = "🤖only available in a Direct Message📵" # "" for none
# Global Variables
DEBUGpacket = False # Debug print the packet rx
cmdHistory = [] # list to hold the command history for lheard and history commands
msg_history = [] # list to hold the message history for the messages command
def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM):
global cmdHistory
global cmdHistory, msg_history
#Auto response to messages
message_lower = message.lower()
bot_response = "🤖I'm sorry, I'm afraid I can't do that."
@@ -51,7 +50,9 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"dopewars": lambda: handleDopeWars(message, message_from_id, deviceID),
"ea": lambda: handle_emergency_alerts(message, message_from_id, deviceID),
"echo": lambda: handle_echo(message, message_from_id, deviceID, isDM, channel_number),
"ealert": lambda: handle_emergency_alerts(message, message_from_id, deviceID),
"earthquake": lambda: handleEarthquake(message, message_from_id, deviceID),
"email:": lambda: handle_email(message_from_id, message),
"games": lambda: gamesCmdList,
"globalthermonuclearwar": lambda: handle_gTnW(),
@@ -60,6 +61,8 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"hangman": lambda: handleHangman(message, message_from_id, deviceID),
"hfcond": hf_band_conditions,
"history": lambda: handle_history(message, message_from_id, deviceID, isDM),
"howfar": lambda: handle_howfar(message, message_from_id, deviceID, isDM),
"howtall": lambda: handle_howtall(message, message_from_id, deviceID, isDM),
"joke": lambda: tell_joke(message_from_id),
"lemonstand": lambda: handleLemonade(message, message_from_id, deviceID),
"lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM),
@@ -84,6 +87,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"sysinfo": lambda: sysinfo(message, message_from_id, deviceID),
"test": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"testing": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"tictactoe": lambda: handleTicTacToe(message, message_from_id, deviceID),
"tide": lambda: handle_tide(message_from_id, deviceID, channel_number),
"valert": lambda: get_volcano_usgs(),
"videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID),
@@ -91,10 +95,10 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"whoami": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus),
"whois": lambda: handle_whois(message, deviceID, channel_number, message_from_id),
"wiki:": lambda: handle_wiki(message, isDM),
"wiki?": lambda: handle_wiki(message, isDM),
"wx": lambda: handle_wxc(message_from_id, deviceID, 'wx'),
"wxa": lambda: handle_wxalert(message_from_id, deviceID, message),
"wxalert": lambda: handle_wxalert(message_from_id, deviceID, message),
"x:": lambda: handleShellCmd(message, message_from_id, channel_number, isDM, deviceID),
"wxc": lambda: handle_wxc(message_from_id, deviceID, 'wxc'),
"📍": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus),
"🔔": lambda: handle_alertBell(message_from_id, deviceID, message),
@@ -295,6 +299,21 @@ def handle_motd(message, message_from_id, isDM):
msg = "MOTD: " + MOTD
return msg
def handle_echo(message, message_from_id, deviceID, isDM, channel_number):
if "?" in message.lower():
return "echo command returns your message back to you. Example:echo Hello World"
elif "echo " in message.lower():
parts = message.lower().split("echo ", 1)
if len(parts) > 1 and parts[1].strip() != "":
echo_msg = parts[1]
if channel_number != echoChannel:
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"
def handle_wxalert(message_from_id, deviceID, message):
if use_meteo_wxApi:
return "wxalert is not supported"
@@ -310,15 +329,77 @@ def handle_wxalert(message_from_id, deviceID, message):
weatherAlert = weatherAlert[0]
return weatherAlert
def handle_howfar(message, message_from_id, deviceID, isDM):
msg = ''
location = get_node_location(message_from_id, deviceID)
lat = location[0]
lon = location[1]
# if ? in message
if "?" in message.lower():
return "command returns the distance you have traveled since your last HowFar-command. Add 'reset' to reset your starting point."
# if no GPS location return
if lat == latitudeValue and lon == longitudeValue:
logger.debug(f"System: HowFar: No GPS location for {message_from_id}")
return "No GPS location available"
if "reset" in message.lower():
msg = distance(lat,lon,message_from_id, reset=True)
else:
msg = distance(lat,lon,message_from_id)
# if not a DM add the username to the beginning of msg
if not useDMForResponse and not isDM:
msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + msg
return msg
def handle_howtall(message, message_from_id, deviceID, isDM):
msg = ''
location = get_node_location(message_from_id, deviceID)
lat = location[0]
lon = location[1]
if lat == latitudeValue and lon == longitudeValue:
logger.debug(f"System: HowTall: No GPS location for {message_from_id}")
return "No GPS location available"
if use_metric:
measure = "meters"
else:
measure = "feet"
# if ? in message
if "?" in message.lower():
return f"command estimates your height based on the shadow length you provide in {measure}. Example: howtall 5.5"
# get the shadow length from the message split after howtall
try:
shadow_length = float(message.lower().split("howtall ")[1].split(" ")[0])
except:
return f"Please provide a shadow length in {measure} example: howtall 5.5"
# get data
msg = measureHeight(lat, lon, shadow_length)
# if data has NO_ALERTS return help
if NO_ALERTS in msg:
return f"Please provide a shadow length in {measure} example: howtall 5.5"
return msg
def handle_wiki(message, isDM):
# location = get_node_location(message_from_id, deviceID)
msg = "Wikipedia search function. \nUsage example:📲wiki: travelling gnome"
if "wiki:" in message.lower():
search = message.split(":")[1]
search = search.strip()
if search:
return get_wikipedia_summary(search)
return "Please add a search term example:📲wiki: travelling gnome"
try:
if "wiki:?" in message.lower() or "wiki: ?" in message.lower() or "wiki?" in message.lower() or "wiki ?" in message.lower():
return msg
if "wiki" in message.lower():
search = message.split(":")[1]
search = search.strip()
if search:
return get_wikipedia_summary(search)
return "Please add a search term example:📲wiki: travelling gnome"
except Exception as e:
logger.error(f"System: Wiki Exception {e}")
msg = "Error processing your request"
return msg
# Runtime Variables for LLM
@@ -728,27 +809,51 @@ def handleHamtest(message, nodeID, deviceID):
time.sleep(responseDelay + 1)
return msg
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
if message.lower().startswith('e'):
if index:
tictactoe.end(nodeID)
tictactoeTracker.pop(index-1)
return "Thanks for playing! 🎯"
if not index:
tictactoeTracker.append({
"nodeID": nodeID,
"last_played": time.time()
})
msg = "🎯Tic-Tac-Toe🤖 '(e)nd' to Quit\n"
msg += tictactoe.play(nodeID, message)
time.sleep(responseDelay + 1)
return msg
def handle_riverFlow(message, message_from_id, deviceID):
location = get_node_location(message_from_id, deviceID)
userRiver = message.lower()
if "riverflow " in userRiver:
userRiver = userRiver.split("riverflow ")[1] if "riverflow " in userRiver else riverListDefault
if "riverflow " in message.lower() and "," in message:
userRiver = message.lower().split("riverflow ", 1)[1].strip()
userRiver = [r.strip() for r in userRiver.split(",") if r.strip()]
else:
userRiver = userRiver.split(",") if "," in userRiver else riverListDefault
userRiver = riverListDefault
# return river flow data
if use_meteo_wxApi:
return get_flood_openmeteo(location[0], location[1])
else:
# if userRiver a list
if type(userRiver) == list:
msg = ""
for river in userRiver:
msg += get_flood_noaa(location[0], location[1], river)
return msg
# if single river
msg = get_flood_noaa(location[0], location[1], userRiver)
msg = ""
for river in userRiver:
msg += get_flood_noaa(location[0], location[1], river)
return msg
def handle_mwx(message_from_id, deviceID, cmd):
@@ -785,6 +890,11 @@ def handle_emergency_alerts(message, message_from_id, deviceID):
else:
# Headlines only FEMA
return getIpawsAlert(str(location[0]), str(location[1]), shortAlerts=True)
def handleEarthquake(message, message_from_id, deviceID):
location = get_node_location(message_from_id, deviceID)
if "earthquake" in message.lower():
return checkUSGSEarthQuake(str(location[0]), str(location[1]))
def handle_checklist(message, message_from_id, deviceID):
name = get_name_from_number(message_from_id, 'short', deviceID)
@@ -969,7 +1079,6 @@ def handle_moon(message_from_id, deviceID, channel_number):
location = get_node_location(message_from_id, deviceID, channel_number)
return get_moon(str(location[0]), str(location[1]))
def handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus):
try:
loc = []
@@ -1066,6 +1175,7 @@ def checkPlayingGame(message_from_id, message_string, rxNode, channel_number):
(golfTracker, "GolfSim", handleGolf) if 'golfTracker' in globals() else None,
(hangmanTracker, "Hangman", handleHangman) if 'hangmanTracker' in globals() else None,
(hamtestTracker, "HamTest", handleHamtest) if 'hamtestTracker' in globals() else None,
(tictactoeTracker, "TicTacToe", handleTicTacToe) if 'tictactoeTracker' in globals() else None,
]
trackers = [tracker for tracker in trackers if tracker is not None]
@@ -1077,7 +1187,7 @@ def checkPlayingGame(message_from_id, message_string, rxNode, channel_number):
return playingGame
def onReceive(packet, interface):
global seenNodes
global seenNodes, msg_history, cmdHistory
# Priocess the incoming packet, handles the responses to the packet with auto_response()
# Sends the packet to the correct handler for processing
@@ -1152,11 +1262,11 @@ def onReceive(packet, interface):
if msg:
# wait a responseDelay to avoid message collision from lora-ack.
time.sleep(responseDelay)
logger.info(f"System: BBS DM Found: {msg[1]} For: {get_name_from_number(message_from_id, 'long', rxNode)}")
logger.info(f"System: BBS DM Delivery: {msg[1]} For: {get_name_from_number(message_from_id, 'long', rxNode)}")
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)
# handle TEXT_MESSAGE_APP
try:
if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
@@ -1322,12 +1432,14 @@ def onReceive(packet, interface):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
else:
timestamp = datetime.now().strftime("%Y-%m-%d %I:%M:%S%p")
if len(msg_history) < storeFlimit:
msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode))
else:
msg_history.pop(0)
msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode))
# trim the history list if it exceeds max_history
if len(msg_history) >= MAX_MSG_HISTORY:
# Remove oldest entries by cutting in half
msg_history = msg_history[len(msg_history)//2:]
# add the message to the history list
msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode))
# print the message to the log and sdout
logger.info(f"Device:{rxNode} Channel:{channel_number} " + CustomFormatter.green + "Ignoring Message:" + CustomFormatter.white +\
@@ -1365,7 +1477,7 @@ def onReceive(packet, interface):
time.sleep(responseDelay)
else:
# Evaluate non TEXT_MESSAGE_APP packets
consumeMetadata(packet, rxNode)
consumeMetadata(packet, rxNode, channel_number)
except KeyError as e:
logger.critical(f"System: Error processing packet: {e} Device:{rxNode}")
logger.debug(f"System: Error Packet = {packet}")
@@ -1422,9 +1534,11 @@ async def start_rx():
if highfly_enabled:
logger.debug(f"System: HighFly Enabled using {highfly_altitude}m limit reporting to channel:{highfly_channel}")
if store_forward_enabled:
logger.debug(f"System: Store and Forward Enabled using limit: {storeFlimit}")
logger.debug(f"System: S&F(messages command) Enabled using limit: {storeFlimit}")
if useDMForResponse:
logger.debug(f"System: Respond by DM only")
if enableEcho:
logger.debug(f"System: Echo command Enabled")
if repeater_enabled and multiple_interface:
logger.debug(f"System: Repeater Enabled for Channels: {repeater_channels}")
if radio_detection_enabled:
@@ -1433,6 +1547,8 @@ async def start_rx():
logger.debug(f"System: File Monitor Enabled for {file_monitor_file_path}, broadcasting to channels: {file_monitor_broadcastCh}")
if enable_runShellCmd:
logger.debug(f"System: Shell Command monitor enabled")
if allowXcmd and enable_runShellCmd:
logger.warning(f"System: File Monitor shell XCMD Enabled")
if read_news_enabled:
logger.debug(f"System: File Monitor News Reader Enabled for {news_file_path}")
if bee_enabled:
@@ -1456,6 +1572,8 @@ async def start_rx():
logger.debug(f"System: CheckList Module Enabled")
if ignoreChannels != []:
logger.debug(f"System: Ignoring Channels: {ignoreChannels}")
if noisyNodeLogging:
logger.debug(f"System: Noisy Node Logging Enabled")
if enableSMTP:
if enableImap:
logger.debug(f"System: SMTP Email Alerting Enabled using IMAP")
@@ -1549,18 +1667,44 @@ async def start_rx():
# Hello World
async def main():
meshRxTask = asyncio.create_task(start_rx())
watchdogTask = asyncio.create_task(watchdog())
if file_monitor_enabled:
fileMonTask: asyncio.Task = asyncio.create_task(handleFileWatcher())
if radio_detection_enabled:
hamlibTask = asyncio.create_task(handleSignalWatcher())
await asyncio.gather(meshRxTask, watchdogTask)
if radio_detection_enabled:
await asyncio.gather(hamlibTask)
if file_monitor_enabled:
await asyncio.gather(fileMonTask)
tasks = []
try:
# 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 file_monitor_enabled:
tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor"))
if radio_detection_enabled:
tasks.append(asyncio.create_task(handleSignalWatcher(), name="hamlib"))
logger.info(f"System: Starting {len(tasks)} async tasks")
# Wait for all tasks with proper exception handling
results = await asyncio.gather(*tasks, return_exceptions=True)
# Check for exceptions in results
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f"Task {tasks[i].get_name()} failed with: {result}")
except Exception as e:
logger.error(f"Main loop error: {e}")
finally:
# Cleanup tasks
logger.info("System: Cleaning up async tasks")
for task in tasks:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
logger.debug(f"Task {task.get_name()} cancelled successfully")
except Exception as e:
logger.warning(f"Error cancelling task {task.get_name()}: {e}")
await asyncio.sleep(0.01)

View File

@@ -16,19 +16,37 @@ def load_bbsdb():
# load the bbs messages from the database file
try:
with open('data/bbsdb.pkl', 'rb') as f:
bbs_messages = pickle.load(f)
except Exception as e:
new_bbs_messages = pickle.load(f)
if isinstance(new_bbs_messages, list):
for msg in new_bbs_messages:
#example [1, 'Welcome to meshBBS', 'Welcome to the BBS, please post a message!', 0]
msgHash = hash(tuple(msg[1:3])) # Create a hash of the message content (subject and body)
# Check if the message already exists in bbs_messages
if all(hash(tuple(existing_msg[1:3])) != msgHash for existing_msg in bbs_messages):
# if the message is not a duplicate, add it to bbs_messages Maintain the message ID sequence
new_id = len(bbs_messages) + 1
bbs_messages.append([new_id, msg[1], msg[2], msg[3]])
except FileNotFoundError:
logger.debug("System: bbsdb.pkl not found, creating new one")
bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]]
try:
with open('data/bbsdb.pkl', 'wb') as f:
pickle.dump(bbs_messages, f)
except Exception as e:
logger.error(f"System: Error creating bbsdb.pkl: {e}")
except Exception as e:
logger.error(f"System: Error loading bbsdb.pkl: {e}")
bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]]
logger.debug("System: Creating new data/bbsdb.pkl")
with open('data/bbsdb.pkl', 'wb') as f:
pickle.dump(bbs_messages, f)
def save_bbsdb():
global bbs_messages
# save the bbs messages to the database file
logger.debug("System: Saving data/bbsdb.pkl")
with open('data/bbsdb.pkl', 'wb') as f:
pickle.dump(bbs_messages, f)
try:
logger.debug("System: Saving data/bbsdb.pkl")
with open('data/bbsdb.pkl', 'wb') as f:
pickle.dump(bbs_messages, f)
except Exception as e:
logger.error(f"System: Error saving bbsdb: {e}")
def bbs_help():
# help message
@@ -40,7 +58,7 @@ def bbs_list_messages():
message_list = ""
for message in bbs_messages:
# message[0] is the messageID, message[1] is the subject
message_list += "Msg #" + str(message[0]) + " " + message[1] + "\n"
message_list += "[#" + str(message[0]) + "] " + message[1] + "\n"
# last newline removed
message_list = message_list[:-1]
@@ -99,8 +117,10 @@ def bbs_read_message(messageID = 0):
if (messageID - 1) >= len(bbs_messages):
return "Message not found."
if messageID > 0:
fromNode = bbs_messages[messageID - 1][3]
fromNodeHex = hex(fromNode)[-4:]
message = bbs_messages[messageID - 1]
return f"Msg #{message[0]}\nMsg Body: {message[2]}"
return f"Msg #{message[0]}\nFrom:{fromNodeHex}\n{message[2]}"
else:
return "Please specify a message number to read."
@@ -116,7 +136,11 @@ def load_bbsdm():
# load the bbs messages from the database file
try:
with open('data/bbsdm.pkl', 'rb') as f:
bbs_dm = pickle.load(f)
new_bbs_dm = pickle.load(f)
if isinstance(new_bbs_dm, list):
for msg in new_bbs_dm:
if msg not in bbs_dm:
bbs_dm.append(msg)
except:
bbs_dm = [[1234567890, "Message", 1234567890]]
logger.debug("System: Creating new data/bbsdm.pkl")
@@ -180,7 +204,12 @@ def bbs_sync_posts(input, peerNode, RxNode):
#store the message
subject = input.split("$")[1].split("#")[0]
body = input.split("#")[1]
bbs_post_message(subject, body, peerNode)
fromNodeHex = input.split("@")[1]
try:
bbs_post_message(subject, body, int(fromNodeHex, 16))
except:
logger.error(f"System: Error parsing bbslink from node {peerNode}: {input}")
fromNodeHex = hex(peerNode)
messageID = input.split(" ")[1]
return f"bbsack {messageID}"
elif "bbsack" in input.lower():
@@ -195,12 +224,14 @@ def bbs_sync_posts(input, peerNode, RxNode):
# send message with delay to keep chutil happy
if messageID < len(bbs_messages):
logger.debug(f"System: Sending bbslink message {messageID} to peer " + str(peerNode))
logger.debug(f"System: wait to bbslink with peer " + str(peerNode))
fromNodeHex = hex(bbs_messages[messageID][3])
time.sleep(5 + responseDelay)
# every 5 messages add extra delay
if messageID % 5 == 0:
time.sleep(10 + responseDelay)
return f"bbslink {messageID} ${bbs_messages[messageID][1]} #{bbs_messages[messageID][2]}"
logger.debug(f"System: Sending bbslink message {messageID} of {len(bbs_messages)} to peer " + str(peerNode))
return f"bbslink {messageID} ${bbs_messages[messageID][1]} #{bbs_messages[messageID][2]} @{fromNodeHex}"
else:
logger.debug("System: bbslink sync complete with peer " + str(peerNode))

View File

@@ -122,8 +122,16 @@ def list_checkin():
timeCheckedIn = ""
checkin_list = ""
for row in rows:
#calculate length of time checked in
timeCheckedIn = time.strftime("%H:%M:%S", time.gmtime(time.time() - time.mktime(time.strptime(row[2] + " " + row[3], "%Y-%m-%d %H:%M:%S"))))
# Calculate length of time checked in, including days
total_seconds = time.time() - time.mktime(time.strptime(row[2] + " " + row[3], "%Y-%m-%d %H:%M:%S"))
days = int(total_seconds // 86400)
hours = int((total_seconds % 86400) // 3600)
minutes = int((total_seconds % 3600) // 60)
seconds = int(total_seconds % 60)
if days > 0:
timeCheckedIn = f"{days}d {hours:02}:{minutes:02}:{seconds:02}"
else:
timeCheckedIn = f"{hours:02}:{minutes:02}:{seconds:02}"
checkin_list += "ID: " + row[1] + " checked-In for " + timeCheckedIn
if row[5] != "":
checkin_list += "📝" + row[5]
@@ -154,6 +162,17 @@ def process_checklist_command(nodeID, message, name="none", location="none"):
return delete_checkin(nodeID)
elif "purgeout" in message.lower():
return delete_checkout(nodeID)
elif "?" in message.lower():
if not reverse_in_out:
return ("Command: checklist followed by\n"
"checkout to check out\n"
"purgeout to delete your checkout record\n"
"Example: checkin Arrived at park")
else:
return ("Command: checklist followed by\n"
"checkin to check out\n"
"purgeout to delete your checkin record\n"
"Example: checkout Leaving park")
elif "checklist" in message.lower():
return list_checkin()
else:

View File

@@ -5,6 +5,7 @@ from modules.log import *
import asyncio
import random
import os
import subprocess
trap_list_filemon = ("readnews",)
@@ -46,7 +47,7 @@ def write_news(content, append=False):
return False
async def watch_file():
# Watch the file for changes and return the new content when it changes
if not os.path.exists(file_monitor_file_path):
return None
else:
@@ -64,6 +65,7 @@ async def watch_file():
await asyncio.sleep(1) # Check every
def call_external_script(message, script="script/runShell.sh"):
# Call an external script with the message as an argument this is a example only
try:
# Debugging: Print the current working directory and resolved script path
current_working_directory = os.getcwd()
@@ -81,4 +83,39 @@ def call_external_script(message, script="script/runShell.sh"):
except Exception as e:
logger.warning(f"FileMon: Error calling external script: {e}")
return None
def handleShellCmd(message, message_from_id, channel_number, isDM, deviceID):
if not allowXcmd:
return "x: command is disabled"
if str(message_from_id) not in bbs_admin_list:
logger.warning(f"FileMon: Unauthorized x: command attempt from {message_from_id}")
return "x: command not authorized"
if not isDM:
return "x: command not authorized in group chat"
if enable_runShellCmd:
# clean up the command input
if message.lower().startswith("x:"):
command = message[2:]
if command.startswith(" "):
command = command[1:]
command = command.strip()
else:
return "x: invalid command format"
# Run the shell command as a subprocess
try:
logger.info(f"FileMon: Running shell command from {message_from_id}: {command}")
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10, start_new_session=True)
output = result.stdout.strip()
if output:
return output
except Exception as e:
logger.warning(f"FileMon: Error running shell command: {e}")
logger.debug(f"FileMon: This command is not good for use over the mesh network")
else:
logger.debug("FileMon: x: command is disabled by no enable_runShellCmd")
return "x: command is disabled"
return "x: command executed with no output"

View File

@@ -2,8 +2,59 @@
# The emoji table of contents is used to replace words in the joke with emojis
# As a Ham, is this obsecuring the meaning of the joke? Or is it enhancing it?
from dadjokes import Dadjoke # pip install dadjokes
import random
from modules.log import *
lameJokes = [
"Why don't scientists trust atoms? Because they make up everything!",
"Why did the scarecrow win an award? Because he was outstanding in his field!",
"Why don't skeletons fight each other? They don't have the guts.",
"What do you call fake spaghetti? An impasta!",
"Why did the bicycle fall over? Because it was two-tired!",
"Why did the math book look sad? Because it had too many problems.",
"Why did the golfer bring two pairs of pants? In case he got a hole in one.",
"Why did the coffee file a police report? It got mugged.",
"Why did the tomato turn red? Because it saw the salad dressing!",
"Why did the cookie go to the doctor? Because it felt crummy.",
"Why did the computer go to the doctor? Because it had a virus!",
"Why did the chicken join a band? Because it had the drumsticks!",
"Why did the banana go to the doctor? Because it wasn't peeling well.",
"Why did the cow go to space? To see the moooon!",
"Why did the fish blush? Because it saw the ocean's bottom!",
"Why did the elephant bring a suitcase to the zoo? Because it wanted to pack its trunk!",
"Why did the meshtastic node go to therapy? It had too many connections to handle!",
"Why did the meshtastic user bring a ladder to the meeting? To reach new heights in communication!",
"Why did the meshtastic device break up with Wi-Fi? It found a better connection!",
"Why did the meshtastic network throw a party? Because it wanted to mesh well with everyone!",
"Why did the meshtastic node get promoted? Because it was outstanding in its field!",
"Why did the meshtastic user bring a map? To navigate the mesh of possibilities!",
"Why did the meshtastic device go to school? To improve its signal strength!",
"How did the meshtastic node become a comedian? It uses mesh-bots to deliver punchlines!",
"Chuck Norris doesn't read books. He stares them down until he gets the information he wants.",
"When Chuck Norris enters a room, he doesn't turn the lights on. He turns the dark off.",
"Chuck Norris can divide by zero.",
"Chuck Norris counted to infinity. Twice.",
"Chuck Norris can slam a revolving door.",
"When Chuck Norris does a push-up, he isn't lifting himself up; he's pushing the Earth down.",
"Chuck Norris can hear sign language.",
"Death once had a near-Chuck Norris experience.",
"Chuck Norris can unscramble an egg.",
"Chuck Norris can win a game of Connect Four in only three moves.",
"Chuck Norris can make a snowman out of rain.",
"Chuck Norris can strangle you with a cordless phone.",
"Chuck Norris can do a wheelie on a unicycle.",
"Chuck Norris can kill two stones with one bird.",
"Chuck Norris can speak braille.",
"Chuck Norris can build a snowman out of rain.",
"Chuck Norris can hear sign language.",
"Death once had a near-Chuck Norris experience.",
"Chuck Norris can unscramble an egg.",
"Chuck Norris can win a game of Connect Four in only three moves.",
"Chuck Norris can make a snowman out of rain.",
"Chuck Norris can strangle you with a cordless phone.",
"Chuck Norris can do a wheelie on a unicycle.",
"Chuck Norris can kill two stones with one bird."]
def tableOfContents():
wordToEmojiMap = {
'love': '❤️', 'heart': '❤️', 'happy': '😊', 'smile': '😊', 'sad': '😢', 'angry': '😠', 'mad': '😠', 'cry': '😢', 'laugh': '😂', 'funny': '😂', 'cool': '😎',
@@ -117,10 +168,12 @@ def sendWithEmoji(message):
def tell_joke(nodeID=0):
dadjoke = Dadjoke()
if dad_jokes_emojiJokes:
renderedLaugh = sendWithEmoji(dadjoke.joke)
else:
renderedLaugh = dadjoke.joke
return renderedLaugh
try:
if dad_jokes_emojiJokes:
renderedLaugh = sendWithEmoji(dadjoke.joke)
else:
renderedLaugh = dadjoke.joke
return renderedLaugh
except Exception as e:
return random.choice(lameJokes)

215
modules/games/tictactoe.py Normal file
View File

@@ -0,0 +1,215 @@
# Tic-Tac-Toe game for Meshtastic mesh-bot
# Board positions chosen by numbers 1-9
# 2025
import random
# to molly and jake, I miss you both so much.
class TicTacToe:
def __init__(self):
self.game = {}
def new_game(self, id):
"""Start a new game"""
games = won = 0
ret = ""
if id in self.game:
games = self.game[id]["games"]
won = self.game[id]["won"]
ret += f"Games:{games} Won:{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
}
ret += self.show_board(id)
ret += "Pick 1-9:"
return ret
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
cell = b[pos] if b[pos] != " " else str(pos + 1)
row += cell
if j < 2:
row += "|"
board_str += row
if i < 2:
board_str += "\n-+-+-\n"
return board_str + "\n"
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 bot_move(self, id):
"""AI makes a move"""
g = self.game[id]
# Simple AI: Try to win, block, or pick random
move = self.find_winning_move(id, "O") # Try to win
if move == -1:
move = self.find_winning_move(id, "X") # Block player
if move == -1:
move = self.find_random_move(id) # Random move
if move != -1:
g["board"][move] = "O"
return move
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):
"""Find a random empty position"""
g = self.game[id]
empty = [i for i in range(9) if g["board"][i] == " "]
return random.choice(empty) if empty else -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]]
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 is_board_full(self, id):
"""Check if board is full"""
g = self.game[id]
return " " not in g["board"]
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 game! (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":
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:
# Remove game but we'll create new one on next play
del self.game[id]
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()

View File

@@ -8,8 +8,10 @@ import requests # pip install requests
import bs4 as bs # pip install beautifulsoup4
import xml.dom.minidom
from modules.log import *
import math
trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert", "riverflow", "valert")
trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert", "riverflow", "valert", "earthquake", "howfar")
def where_am_i(lat=0, lon=0, short=False, zip=False):
whereIam = ""
@@ -83,9 +85,12 @@ def getRepeaterBook(lat=0, lon=0):
try:
msg = ''
response = requests.get(repeater_url)
user_agent = {'User-agent': 'Mozilla/5.0'}
response = requests.get(repeater_url, headers=user_agent, timeout=urlTimeoutSeconds)
if response.status_code!=200:
logger.error(f"Location:Error fetching repeater data from {repeater_url} with status code {response.status_code}")
soup = bs.BeautifulSoup(response.text, 'html.parser')
table = soup.find('table', attrs={'class': 'w3-table w3-striped w3-responsive w3-mobile w3-auto sortable'})
table = soup.find('table', attrs={'class': 'table table-striped table-hover align-middle sortable'})
if table is not None:
cells = table.find_all('td')
data = []
@@ -127,6 +132,8 @@ def getArtSciRepeaters(lat=0, lon=0):
try:
artsci_url = f"http://www.artscipub.com/mobile/showstate.asp?zip={zipCode}"
response = requests.get(artsci_url)
if response.status_code!=200:
logger.error(f"Location:Error fetching data from {artsci_url} with status code {response.status_code}")
soup = bs.BeautifulSoup(response.text, 'html.parser')
# results needed xpath is /html/body/table[2]/tbody/tr/td/table/tbody/tr[2]/td/table
table = soup.find_all('table')[1]
@@ -761,3 +768,247 @@ def get_nws_marine(zone, days=3):
return NO_DATA_NOGPS
return marine_pz_report
def checkUSGSEarthQuake(lat=0, lon=0):
if lat == 0 and lon == 0:
lat = latitudeValue
lon = longitudeValue
radius = 100 # km
magnitude = 1.5
history = 7 # days
startDate = datetime.fromtimestamp(datetime.now().timestamp() - history*24*60*60).strftime("%Y-%m-%d")
USGSquake_url = f"https://earthquake.usgs.gov/fdsnws/event/1/query?&format=xml&latitude={lat}&longitude={lon}&maxradiuskm={radius}&minmagnitude={magnitude}&starttime={startDate}"
description_text = ""
quake_count = 0
# fetch the earthquake data from USGS
try:
quake_data = requests.get(USGSquake_url, timeout=urlTimeoutSeconds)
if not quake_data.ok:
logger.warning("Location:Error fetching earthquake data from USGS")
return NO_ALERTS
if not quake_data.text.strip():
return NO_ALERTS
try:
quake_xml = xml.dom.minidom.parseString(quake_data.text)
except Exception as e:
logger.warning(f"Location: USGS earthquake API returned invalid XML: {e}")
return NO_ALERTS
except (requests.exceptions.RequestException):
logger.warning("Location:Error fetching earthquake data from USGS")
return NO_ALERTS
quake_xml = xml.dom.minidom.parseString(quake_data.text)
quake_count = len(quake_xml.getElementsByTagName("event"))
#get largest mag in magnitude of the set of quakes
largest_mag = 0.0
for event in quake_xml.getElementsByTagName("event"):
mag = event.getElementsByTagName("magnitude")[0]
mag_value = float(mag.getElementsByTagName("value")[0].childNodes[0].nodeValue)
if mag_value > largest_mag:
largest_mag = mag_value
# set description text
description_text = event.getElementsByTagName("description")[0].getElementsByTagName("text")[0].childNodes[0].nodeValue
largest_mag = round(largest_mag, 1)
if quake_count == 0:
return NO_ALERTS
else:
return f"{quake_count} 🫨quakes in last {history} days within {radius} km. Largest: {largest_mag}M\n{description_text}"
howfarDB = {}
def distance(lat=0,lon=0,nodeID=0, reset=False):
# part of the howfar function, calculates the distance between two lat/lon points
msg = ""
dupe = False
r = 6371 # Radius of earth in kilometers # haversine formula
if lat == 0 and lon == 0:
return NO_DATA_NOGPS
if nodeID == 0:
return "No NodeID provided"
if reset:
if nodeID in howfarDB:
del howfarDB[nodeID]
if nodeID not in howfarDB:
#register first point NodeID, lat, lon, time, point
howfarDB[nodeID] = [{'lat': lat, 'lon': lon, 'time': datetime.now()}]
if reset:
return "Tracking reset, new starting point registered🗺"
else:
return "Starting point registered🗺"
else:
#de-dupe points if same as last point
if howfarDB[nodeID][-1]['lat'] == lat and howfarDB[nodeID][-1]['lon'] == lon:
dupe = True
msg = "No New GPS📍 "
# calculate distance from last point in howfarDB
last_point = howfarDB[nodeID][-1]
lat1 = math.radians(last_point['lat'])
lon1 = math.radians(last_point['lon'])
lat2 = math.radians(lat)
lon2 = math.radians(lon)
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2)**2
c = 2 * math.asin(math.sqrt(a))
distance_km = c * r
if use_metric:
msg += f"{distance_km:.2f} km"
else:
distance_miles = distance_km * 0.621371
msg += f"{distance_miles:.2f} miles"
#calculate bearing
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - (math.sin(lat1) * math.cos(lat2) * math.cos(dlon))
initial_bearing = math.atan2(x, y)
initial_bearing = math.degrees(initial_bearing)
compass_bearing = (initial_bearing + 360) % 360
msg += f" 🧭{compass_bearing:.2f}° Bearing from last📍"
# calculate the speed if time difference is more than 1 minute
time_diff = datetime.now() - last_point['time']
if time_diff.total_seconds() > 60:
hours = time_diff.total_seconds() / 3600
if use_metric:
speed = distance_km / hours
speed_str = f"{speed:.2f} km/h"
else:
speed_mph = (distance_km * 0.621371) / hours
speed_str = f"{speed_mph:.2f} mph"
msg += f", travel time: {int(time_diff.total_seconds()//60)} min, Speed: {speed_str}"
# calculate total distance traveled including this point computed in distance_km from calculate distance from last point in howfarDB
total_distance_km = 0.0
for i in range(1, len(howfarDB[nodeID])):
point1 = howfarDB[nodeID][i-1]
point2 = howfarDB[nodeID][i]
lat1 = math.radians(point1['lat'])
lon1 = math.radians(point1['lon'])
lat2 = math.radians(point2['lat'])
lon2 = math.radians(point2['lon'])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2)**2
c = 2 * math.asin(math.sqrt(a))
total_distance_km += c * r
# add the distance from last point to current point
total_distance_km += distance_km
if use_metric:
msg += f", Total: {total_distance_km:.2f} km"
else:
total_distance_miles = total_distance_km * 0.621371
msg += f", Total: {total_distance_miles:.2f} miles"
# update the last point in howfarDB
if not dupe:
howfarDB[nodeID].append({'lat': lat, 'lon': lon, 'time': datetime.now()})
# if points 3+ are within 30 meters of the first point add the area of the polygon
if len(howfarDB[nodeID]) >= 3:
points = []
# loop the howfarDB to get all the points except the current nodeID
for key in howfarDB:
if key != nodeID:
points.append((howfarDB[key][-1]['lat'], howfarDB[key][-1]['lon']))
# loop the howfarDB[nodeID] to get the points
for point in howfarDB[nodeID]:
points.append((point['lat'], point['lon']))
# close the polygon by adding the first point to the end
points.append((howfarDB[nodeID][0]['lat'], howfarDB[nodeID][0]['lon']))
# calculate the area of the polygon
area = 0.0
for i in range(len(points)-1):
lat1 = math.radians(points[i][0])
lon1 = math.radians(points[i][1])
lat2 = math.radians(points[i+1][0])
lon2 = math.radians(points[i+1][1])
area += (lon2 - lon1) * (2 + math.sin(lat1) + math.sin(lat2))
area = area * (6378137 ** 2) / 2.0
area = abs(area) / 1e6 # convert to square kilometers
if use_metric:
msg += f", Area: {area:.2f} sq.km (approx)"
else:
area_miles = area * 0.386102
msg += f", Area: {area_miles:.2f} sq.mi (approx)"
#calculate the centroid of the polygon
x = 0.0
y = 0.0
z = 0.0
for point in points[:-1]:
lat_rad = math.radians(point[0])
lon_rad = math.radians(point[1])
x += math.cos(lat_rad) * math.cos(lon_rad)
y += math.cos(lat_rad) * math.sin(lon_rad)
z += math.sin(lat_rad)
total_points = len(points) - 1
x /= total_points
y /= total_points
z /= total_points
lon_centroid = math.atan2(y, x)
hyp = math.sqrt(x * x + y * y)
lat_centroid = math.atan2(z, hyp)
lat_centroid = math.degrees(lat_centroid)
lon_centroid = math.degrees(lon_centroid)
msg += f", Centroid: {lat_centroid:.5f}, {lon_centroid:.5f}"
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 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=urlTimeoutSeconds)
if not aircraft_data.ok:
logger.warning("Location:Error fetching aircraft data from OpenSky Network")
return ERROR_FETCHING_DATA
except (requests.exceptions.RequestException):
logger.warning("Location:Error fetching aircraft data from OpenSky Network")
return ERROR_FETCHING_DATA
aircraft_json = aircraft_data.json()
if 'states' not in aircraft_json or not aircraft_json['states']:
return NO_ALERTS
aircraft_list = aircraft_json['states']
aircraft_report = ""
for aircraft in aircraft_list:
if len(aircraft_report.split("\n")) >= search_limit:
break
# extract values from JSON
try:
callsign = aircraft[1].strip() if aircraft[1] else "N/A"
origin_country = aircraft[2]
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 NO_ALERTS

View File

@@ -37,6 +37,10 @@ try:
config.read(config_file, encoding='utf-8')
except Exception as e:
print(f"System: Error reading config file: {e}")
# exit if we can't read the config file
print(f"System: Check the config.ini against config.template file for missing sections or values.")
print(f"System: Exiting...")
exit(1)
if config.sections() == []:
print(f"System: Error reading config file: {config_file} is empty or does not exist.")
@@ -223,8 +227,12 @@ try:
ollamaHostName = config['general'].get('ollamaHostName', 'http://localhost:11434') # default localhost
llmModel = config['general'].get('ollamaModel', 'gemma3:270m') # default gemma3:270m
rawLLMQuery = config['general'].getboolean('rawLLMQuery', True) #default True
llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True)
llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True) # default True
dont_retry_disconnect = config['general'].getboolean('dont_retry_disconnect', False) # default False, retry on disconnect
favoriteNodeList = config['general'].get('favoriteNodeList', '').split(',')
enableEcho = config['general'].getboolean('enableEcho', False) # default False
echoChannel = config['general'].getint('echoChannel', '9') # default 9, empty string to ignore
# emergency response
emergency_responder_enabled = config['emergencyHandler'].getboolean('enabled', False)
emergency_responder_alert_channel = config['emergencyHandler'].getint('alert_channel', 2) # default 2
@@ -234,6 +242,7 @@ try:
# sentry
sentry_enabled = config['sentry'].getboolean('SentryEnabled', False) # default False
secure_channel = config['sentry'].getint('SentryChannel', 2) # default 2
secure_interface = config['sentry'].getint('SentryInterface', 1) # default 1
sentry_holdoff = config['sentry'].getint('SentryHoldoff', 9) # default 9
sentryIgnoreList = config['sentry'].get('sentryIgnoreList', '').split(',')
sentry_radius = config['sentry'].getint('SentryRadius', 100) # default 100 meters
@@ -241,7 +250,10 @@ try:
highfly_enabled = config['sentry'].getboolean('highFlyingAlert', True) # default True
highfly_altitude = config['sentry'].getint('highFlyingAlertAltitude', 2000) # default 2000 meters
highfly_channel = config['sentry'].getint('highFlyingAlertChannel', 2) # default 2
highfly_interface = config['sentry'].getint('highFlyingAlertInterface', 1) # default 1
highfly_ignoreList = config['sentry'].get('highFlyingIgnoreList', '').split(',') # default empty
highfly_check_openskynetwork = config['sentry'].getboolean('highflyOpenskynetwork', True) # default True check with OpenSkyNetwork if highfly detected
detctionSensorAlert = config['sentry'].getboolean('detectionSensorAlert', False) # default False
# location
location_enabled = config['location'].getboolean('enabled', True)
@@ -287,6 +299,7 @@ try:
bbs_admin_list = config['bbs'].get('bbs_admin_list', '').split(',')
bbs_link_enabled = config['bbs'].getboolean('bbslink_enabled', False)
bbs_link_whitelist = config['bbs'].get('bbslink_whitelist', '').split(',')
bbsAPI_enabled = config['bbs'].getboolean('bbsAPI_enabled', False)
# checklist
checklist_enabled = config['checklist'].getboolean('enabled', False)
@@ -346,6 +359,7 @@ try:
news_file_path = config['fileMon'].get('news_file_path', 'news.txt') # default news.txt
news_random_line_only = config['fileMon'].getboolean('news_random_line', False) # default False
enable_runShellCmd = config['fileMon'].getboolean('enable_runShellCmd', False) # default False
allowXcmd = config['fileMon'].getboolean('allowXcmd', False) # default False
# games
game_hop_limit = config['messagingSettings'].getint('game_hop_limit', 5) # default 3 hops
@@ -357,6 +371,7 @@ try:
golfSim_enabled = config['games'].getboolean('golfSim', True)
hangman_enabled = config['games'].getboolean('hangman', True)
hamtest_enabled = config['games'].getboolean('hamtest', True)
tictactoe_enabled = config['games'].getboolean('tictactoe', True)
# messaging settings
responseDelay = config['messagingSettings'].getfloat('responseDelay', 0.7) # default 0.7
@@ -365,8 +380,12 @@ try:
wantAck = config['messagingSettings'].getboolean('wantAck', False) # default False
maxBuffer = config['messagingSettings'].getint('maxBuffer', 200) # default 200
enableHopLogs = config['messagingSettings'].getboolean('enableHopLogs', False) # default False
except KeyError as e:
debugMetadata = config['messagingSettings'].getboolean('debugMetadata', False) # default False
metadataFilter = config['messagingSettings'].get('metadataFilter', '').split(',') # default empty
DEBUGpacket = config['messagingSettings'].getboolean('DEBUGpacket', False) # default False
noisyNodeLogging = config['messagingSettings'].getboolean('noisyNodeLogging', False) # default False
noisyTelemetryLimit = config['messagingSettings'].getint('noisyTelemetryLimit', 5) # default 5 packets
except Exception as e:
print(f"System: Error reading config file: {e}")
print(f"System: Check the config.ini against config.template file for missing sections or values.")
print(f"System: Exiting...")

View File

@@ -152,6 +152,12 @@ def store_sms(nodeID, sms):
global sms_db
try:
logger.debug("System: Setting SMS for " + str(nodeID))
# if the nodeID has over 5 sms addresses warn and return
for item in sms_db:
if item['nodeID'] == nodeID:
if len(item['sms']) >= 5:
logger.warning("System: 📵SMS limit reached for " + str(nodeID))
return False
# if not in db, add it
if nodeID not in sms_db:
sms_db.append({'nodeID': nodeID, 'sms': sms})

View File

@@ -8,18 +8,23 @@ from datetime import datetime
import ephem # pip install pyephem
from datetime import timezone
from modules.log import *
import math
trap_list_solarconditions = ("sun", "moon", "solar", "hfcond", "satpass")
trap_list_solarconditions = ("sun", "moon", "solar", "hfcond", "satpass", "howtall")
def hf_band_conditions():
# ham radio HF band conditions
hf_cond = ""
signalnoise = ""
band_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
if(band_cond.ok):
solarxml = xml.dom.minidom.parseString(band_cond.text)
for i in solarxml.getElementsByTagName("band"):
hf_cond += i.getAttribute("time")[0]+i.getAttribute("name") +"="+str(i.childNodes[0].data)+"\n"
hf_cond = hf_cond[:-1] # remove the last newline
for i in solarxml.getElementsByTagName("solardata"):
signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data
hf_cond += "\nQRN:" + signalnoise
else:
logger.error("Solar: Error fetching HF band conditions")
hf_cond = ERROR_FETCHING_DATA
@@ -130,19 +135,19 @@ def get_moon(lat=0, lon=0):
if illum < 1.0:
moon_phase = 'New Moon🌑'
elif illum < 49:
moon_phase = 'Waxing Crescent🌒'
moon_phase = 'Waxing Crescent 🌒'
elif 49 <= illum < 51:
moon_phase = 'First Quarter🌓'
moon_phase = 'First Quarter 🌓'
elif illum < 99:
moon_phase = 'Waxing Gibbous🌔'
moon_phase = 'Waxing Gibbous 🌔'
elif illum >= 99:
moon_phase = 'Full Moon🌕'
elif illum > 51:
moon_phase = 'Waning Gibbous🌖'
moon_phase = 'Waning Gibbous 🌖'
elif 51 >= illum > 49:
moon_phase = 'Last Quarter🌗'
moon_phase = 'Last Quarter 🌗'
else:
moon_phase = 'Waning Crescent🌘'
moon_phase = 'Waning Crescent 🌘'
moon_table['phase'] = moon_phase
moon_table['illumination'] = moon.phase
@@ -167,9 +172,9 @@ def get_moon(lat=0, lon=0):
moon_table['next_full_moon'] = local_next_full_moon.strftime('%a %b %d %I:%M%p')
moon_table['next_new_moon'] = local_next_new_moon.strftime('%a %b %d %I:%M%p')
moon_data = "MoonRise:" + moon_table['rise_time'] + "\nSet:" + moon_table['set_time'] + \
"\nPhase:" + moon_table['phase'] + " @:" + str('{0:.2f}'.format(moon_table['illumination'])) + "%" \
+ "\nFullMoon:" + moon_table['next_full_moon'] + "\nNewMoon:" + moon_table['next_new_moon']
moon_data = "MoonRise: " + moon_table['rise_time'] + "\nSet: " + moon_table['set_time'] + \
"\nPhase: " + moon_table['phase'] + " @: " + str('{0:.2f}'.format(moon_table['illumination'])) + "%" \
+ "\nFullMoon: " + moon_table['next_full_moon'] + "\nNewMoon: " + moon_table['next_new_moon']
# if moon is in the sky, add azimuth and altitude
if moon_table['altitude'] > 0:
@@ -206,7 +211,7 @@ def getNextSatellitePass(satellite, lat=0, lon=0):
pass_startAzCompass = pass_json['passes'][0]['startAzCompass']
pass_set_time = datetime.fromtimestamp(pass_time + pass_duration).strftime('%a %d %I:%M%p')
pass__endAzCompass = pass_json['passes'][0]['endAzCompass']
pass_data = f"{satname} @{pass_rise_time} Az:{pass_startAzCompass} for{getPrettyTime(pass_duration)}, MaxEl:{pass_maxEl}° Set@{pass_set_time} Az:{pass__endAzCompass}"
pass_data = f"{satname} @{pass_rise_time} Az: {pass_startAzCompass} for{getPrettyTime(pass_duration)}, MaxEl: {pass_maxEl}° Set @{pass_set_time} Az: {pass__endAzCompass}"
elif pass_json['info']['passescount'] == 0:
satname = pass_json['info']['satname']
pass_data = f"{satname} has no upcoming passes"
@@ -215,5 +220,33 @@ def getNextSatellitePass(satellite, lat=0, lon=0):
pass_data = ERROR_FETCHING_DATA
except Exception as e:
logger.warning(f"System: User supplied value {satellite} unknown or invalid")
pass_data = "Provide NORAD# example use:🛰satpass 25544,33591"
pass_data = "Provide NORAD# example use: 🛰satpass 25544,33591"
return pass_data
def measureHeight(lat=0, lon=0, shadow=0):
# measure height of a given location using sun angle and shadow length
if lat == 0 and lon == 0:
return NO_DATA_NOGPS
if shadow == 0:
return NO_ALERTS
obs = ephem.Observer()
obs.lat = str(lat)
obs.lon = str(lon)
obs.date = datetime.now(timezone.utc)
sun = ephem.Sun()
sun.compute(obs)
sun_altitude = sun.alt * 180 / ephem.pi
if sun_altitude <= 0:
return "Sun is below horizon, I dont belive your shadow measurement"
try:
if use_metric:
height = float(shadow) * math.tan(sun.alt)
return f"📏Object Height: {height:.2f} m (Shadow: {shadow} m, 📐Sun Alt: {sun_altitude:.2f}°)"
else:
# Assume shadow is in feet if imperial, otherwise convert from meters to feet
shadow_ft = float(shadow)
height_ft = shadow_ft * math.tan(sun.alt)
return f"📏Object Height: {height_ft:.2f} ft (Shadow: {shadow_ft} ft, 📐Sun Alt: {sun_altitude:.2f}°)"
except Exception as e:
logger.error(f"Space: Error calculating height: {e}")
return NO_ALERTS

View File

@@ -7,12 +7,13 @@ import meshtastic.ble_interface
import time
import asyncio
import random
# not ideal but needed?
import contextlib # for suppressing output on watchdog
import io # for suppressing output on watchdog
# homebrew 'modules'
from modules.log import *
# Global Variables
debugMetadata = False # packet debug for non text messages
trap_list = ("cmd","cmd?") # default trap list
help_message = "Bot CMD?:"
asyncLoop = asyncio.new_event_loop()
@@ -20,6 +21,71 @@ games_enabled = False
multiPingList = [{'message_from_id': 0, 'count': 0, 'type': '', 'deviceID': 0, 'channel_number': 0, 'startCount': 0}]
interface_retry_count = 3
# Memory Management Constants
MAX_MSG_HISTORY = 100
MAX_CMD_HISTORY = 200
MAX_SEEN_NODES = 200
CLEANUP_INTERVAL = 86400 # 24 hours in seconds
GAMEDELAY = CLEANUP_INTERVAL # the age of game entries in seconds before they are cleaned up
def cleanup_memory():
"""Clean up memory by limiting list sizes and removing stale entries"""
global cmdHistory, seenNodes, multiPingList
current_time = time.time()
try:
# Limit cmdHistory size
if 'cmdHistory' in globals() and len(cmdHistory) > MAX_CMD_HISTORY:
cmdHistory = cmdHistory[-(MAX_CMD_HISTORY - 50):] # keep the most recent 50 entries
logger.debug(f"System: Trimmed cmdHistory to {len(cmdHistory)} entries")
# Clean up old seenNodes entries (older than 24 hours)
if 'seenNodes' in globals():
initial_count = len(seenNodes)
seenNodes = [node for node in seenNodes
if current_time - node.get('lastSeen', 0) < 86400]
if len(seenNodes) < initial_count:
logger.debug(f"System: Cleaned up {initial_count - len(seenNodes)} old seenNodes entries")
# Clean up stale game tracker entries
cleanup_game_trackers(current_time)
# Clean up multiPingList of completed or stale entries
if 'multiPingList' in globals():
multiPingList[:] = [ping for ping in multiPingList
if ping.get('message_from_id', 0) != 0 and
ping.get('count', 0) > 0]
except Exception as e:
logger.error(f"System: Error during memory cleanup: {e}")
def cleanup_game_trackers(current_time):
"""Clean up all game tracker lists of stale entries"""
try:
# List of game tracker global variable names
tracker_names = [
'dwPlayerTracker', 'lemonadeTracker', 'jackTracker',
'vpTracker', 'mindTracker', 'golfTracker',
'hangmanTracker', 'hamtestTracker', 'tictactoeTracker'
]
for tracker_name in tracker_names:
if tracker_name in globals():
tracker = globals()[tracker_name]
if isinstance(tracker, list):
initial_count = len(tracker)
# Remove entries older than GAMEDELAY
globals()[tracker_name] = [
entry for entry in tracker
if current_time - entry.get('last_played', entry.get('time', 0)) < GAMEDELAY
]
cleaned_count = initial_count - len(globals()[tracker_name])
if cleaned_count > 0:
logger.debug(f"System: Cleaned up {cleaned_count} stale entries from {tracker_name}")
except Exception as e:
logger.error(f"System: Error cleaning up game trackers: {e}")
# Ping Configuration
if ping_enabled:
# ping, pinging, ack, testing, test, pong
@@ -27,6 +93,12 @@ if ping_enabled:
trap_list = trap_list + trap_list_ping
help_message = help_message + "ping"
# Echo Configuration
if enableEcho:
trap_list_echo = ("echo",)
trap_list = trap_list + trap_list_echo
help_message = help_message + ", echo"
# Sitrep Configuration
if sitrep_enabled:
trap_list_sitrep = ("sitrep", "lheard", "sysinfo")
@@ -75,7 +147,7 @@ if enableCmdHistory:
if location_enabled:
from modules.locationdata import * # from the spudgunman/meshing-around repo
trap_list = trap_list + trap_list_location
help_message = help_message + ", whereami, wx"
help_message = help_message + ", whereami, wx, howfar"
if enableGBalerts and not enableDEalerts:
from modules.globalalert import * # from the spudgunman/meshing-around repo
logger.warning(f"System: GB Alerts not functional at this time need to find a source API")
@@ -92,18 +164,24 @@ if location_enabled:
from modules.wx_meteo import * # from the spudgunman/meshing-around repo
else:
# NOAA only features
help_message = help_message + ", wxa"
help_message = help_message + ", wxa, wxalert"
# USGS riverFlow Configuration
if riverListDefault != ['']:
help_message = help_message + ", riverflow"
if repeater_lookup != False:
help_message = help_message + ", rlist"
if solar_conditions_enabled:
help_message = help_message + ", howtall"
# NOAA alerts needs location module
if wxAlertBroadcastEnabled or emergencyAlertBrodcastEnabled or volcanoAlertBroadcastEnabled:
from modules.locationdata import * # from the spudgunman/meshing-around repo
# limited subset, this should be done better but eh..
trap_list = trap_list + ("wx", "wxa", "wxalert", "ea", "ealert", "valert")
help_message = help_message + ", wxalert, ealert, valert"
help_message = help_message + ", ealert, valert"
# NOAA Coastal Waters Forecasts
if coastalEnabled:
@@ -182,6 +260,11 @@ if hamtest_enabled:
trap_list = trap_list + ("hamtest",)
games_enabled = True
if tictactoe_enabled:
from modules.games.tictactoe import * # from the spudgunman/meshing-around repo
trap_list = trap_list + ("tictactoe",)
games_enabled = True
# Games Configuration
if games_enabled is True:
help_message = help_message + ", games"
@@ -206,6 +289,8 @@ if games_enabled is True:
gamesCmdList += "hangman, "
if hamtest_enabled:
gamesCmdList += "hamTest, "
if tictactoe_enabled:
gamesCmdList += "ticTacToe, "
gamesCmdList = gamesCmdList[:-2] # remove the last comma
else:
gamesCmdList = ""
@@ -249,6 +334,9 @@ if file_monitor_enabled or read_news_enabled or bee_enabled:
# Bee Configuration uses file monitor module
if bee_enabled:
trap_list = trap_list + ("🐝",)
# x: command for shell access
if enable_runShellCmd and allowXcmd:
trap_list = trap_list + ("x:",)
# clean up the help message
help_message = help_message.split(", ")
@@ -481,11 +569,11 @@ def handleFavoritNode(nodeInt=1, nodeID=0, aor=False):
interface = globals()[f'interface{nodeInt}']
myNodeNumber = globals().get(f'myNodeNum{nodeInt}')
if aor:
interface.getNode(myNodeNumber).addFavorite(nodeID)
logger.info(f"System: Added {nodeID} to favorites")
interface.getNode(myNodeNumber).setFavorite(nodeID)
logger.info(f"System: Added {nodeID} to favorites for device {nodeInt}")
else:
interface.getNode(myNodeNumber).removeFavorite(nodeID)
logger.info(f"System: Removed {nodeID} from favorites")
logger.info(f"System: Removed {nodeID} from favorites for device {nodeInt}")
def getFavoritNodes(nodeInt=1):
interface = globals()[f'interface{nodeInt}']
@@ -685,25 +773,23 @@ def messageTrap(msg):
# Split Message on assumed words spaces m for m = msg.split(" ")
# t in trap_list, built by the config and system.py not the user
message_list=msg.split(" ")
if cmdBang:
# check for ! at the start of the message to force a command
if not message_list[0].startswith('!'):
return False
else:
message_list[0] = message_list[0][1:]
for m in message_list:
for t in trap_list:
if not explicitCmd:
# if word in message is in the trap list, return True
if t.lower() == m.lower():
if cmdBang:
if m.startswith('!'):
return True
else:
continue
return True
else:
# if the index 0 of the message is a word in the trap list, return True
if t.lower() == m.lower() and message_list.index(m) == 0:
if cmdBang:
if m.startswith('!'):
return True
else:
continue
return True
# if no trap words found, run a search for near misses like ping? or cmd?
for m in message_list:
@@ -761,8 +847,10 @@ def handleMultiPing(nodeID=0, deviceID=1):
break
priorVolcanoAlert = ""
priorEmergencyAlert = ""
priorWxAlert = ""
def handleAlertBroadcast(deviceID=1):
global priorVolcanoAlert
global priorVolcanoAlert, priorEmergencyAlert, priorWxAlert
alertUk = NO_ALERTS
alertDe = NO_ALERTS
alertFema = NO_ALERTS
@@ -802,6 +890,10 @@ def handleAlertBroadcast(deviceID=1):
if emergencyAlertBrodcastEnabled:
if NO_ALERTS not in femaAlert and ERROR_FETCHING_DATA not in femaAlert:
if femaAlert != priorEmergencyAlert:
priorEmergencyAlert = femaAlert
else:
return False
if isinstance(emergencyAlertBroadcastCh, list):
for channel in emergencyAlertBroadcastCh:
send_message(femaAlert, int(channel), 0, deviceID)
@@ -809,6 +901,10 @@ def handleAlertBroadcast(deviceID=1):
send_message(femaAlert, emergencyAlertBroadcastCh, 0, deviceID)
return True
if NO_ALERTS not in ukAlert:
if ukAlert != priorEmergencyAlert:
priorEmergencyAlert = ukAlert
else:
return False
if isinstance(emergencyAlertBroadcastCh, list):
for channel in emergencyAlertBroadcastCh:
send_message(ukAlert, int(channel), 0, deviceID)
@@ -817,11 +913,15 @@ def handleAlertBroadcast(deviceID=1):
return True
if NO_ALERTS not in alertDe:
if deAlert != priorEmergencyAlert:
priorEmergencyAlert = deAlert
else:
return False
if isinstance(emergencyAlertBroadcastCh, list):
for channel in emergencyAlertBroadcastCh:
send_message(ukAlert, int(channel), 0, deviceID)
send_message(deAlert, int(channel), 0, deviceID)
else:
send_message(ukAlert, emergencyAlertBroadcastCh, 0, deviceID)
send_message(deAlert, emergencyAlertBroadcastCh, 0, deviceID)
return True
# pause for traffic
@@ -829,6 +929,10 @@ def handleAlertBroadcast(deviceID=1):
if wxAlertBroadcastEnabled:
if wxAlert:
if wxAlert != priorWxAlert:
priorWxAlert = wxAlert
else:
return False
if isinstance(wxAlertBroadcastChannel, list):
for channel in wxAlertBroadcastChannel:
send_message(wxAlert, int(channel), 0, deviceID)
@@ -882,6 +986,23 @@ def getNodeFirmware(nodeID=0, nodeInt=1):
return fwVer
return -1
def compileFavoriteList():
# build a list of favorite nodes to add to the device
fav_list = []
if (bbs_admin_list != [0] or favoriteNodeList != ['']) or bbs_link_whitelist != [0]:
logger.debug(f"System: Collecting Favorite Nodes to add to device(s)")
# loop through each interface and add the favorite nodes
for i in range(1, 10):
if globals().get(f'interface{i}') and globals().get(f'interface{i}_enabled'):
for fav in bbs_admin_list + favoriteNodeList + bbs_link_whitelist:
if fav != 0 and fav != '' and fav is not None:
object = {'nodeID': fav, 'deviceID': i}
# check object not already in the list
if object not in fav_list:
fav_list.append(object)
logger.debug(f"System: Adding Favorite Node {fav} to Device {i}")
return fav_list
def displayNodeTelemetry(nodeID=0, rxNode=0, userRequested=False):
interface = globals()[f'interface{rxNode}']
myNodeNum = globals().get(f'myNodeNum{rxNode}')
@@ -939,26 +1060,39 @@ def displayNodeTelemetry(nodeID=0, rxNode=0, userRequested=False):
if batteryLevel < 25:
logger.warning(f"System: Low Battery Level: {batteryLevel}{emji} on Device: {rxNode}")
send_message(f"Low Battery Level: {batteryLevel}{emji} on Device: {rxNode}", {secure_channel}, 0, {secure_interface})
elif batteryLevel < 10:
logger.critical(f"System: Critical Battery Level: {batteryLevel}{emji} on Device: {rxNode}")
return dataResponse
positionMetadata = {}
def consumeMetadata(packet, rxNode=0):
def consumeMetadata(packet, rxNode=0, channel=-1):
global positionMetadata, telemetryData
# check type of packet
try:
# keep records of recent telemetry data
packet_type = ''
if packet.get('decoded'):
packet_type = packet['decoded']['portnum']
nodeID = packet['from']
except Exception as e:
logger.debug(f"System: Metadata decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# TELEMETRY packets
if packet_type == 'TELEMETRY_APP':
if debugMetadata: print(f"DEBUG TELEMETRY_APP: {packet}\n\n")
# get the telemetry data
# TELEMETRY packets
if packet_type == 'TELEMETRY_APP':
if debugMetadata and 'TELEMETRY_APP' not in metadataFilter:
print(f"DEBUG TELEMETRY_APP: {packet}\n\n")
# get the telemetry data
try:
telemetry_packet = packet['decoded']['telemetry']
if telemetry_packet.get('deviceMetrics'):
deviceMetrics = telemetry_packet['deviceMetrics']
#if uptime is in deviceMetrics and uptime is not 0 set uptime
# if deviceMetrics.get('uptimeSeconds') is not None and deviceMetrics['uptimeSeconds'] != 0:
# if highestUptime < deviceMetrics['uptimeSeconds']:
# highestUptime = deviceMetrics['uptimeSeconds']
# highestUptimeNode = nodeID
if telemetry_packet.get('localStats'):
localStats = telemetry_packet['localStats']
# Check if 'numPacketsTx' and 'numPacketsRx' exist and are not zero
@@ -971,73 +1105,147 @@ def consumeMetadata(packet, rxNode=0):
for key in keys:
if localStats.get(key) is not None:
telemetryData[rxNode][key] = localStats.get(key)
# POSITION_APP packets
if packet_type == 'POSITION_APP':
if debugMetadata: print(f"DEBUG POSITION_APP: {packet}\n\n")
# get the position data
keys = ['altitude', 'groundSpeed', 'precisionBits']
position_data = packet['decoded']['position']
try:
if nodeID not in positionMetadata:
positionMetadata[nodeID] = {}
for key in keys:
positionMetadata[nodeID][key] = position_data.get(key, 0)
except Exception as e:
logger.debug(f"System: TELEMETRY_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# if altitude is over 2000 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:
logger.info(f"System: High Altitude {position_data['altitude']}m on Device: {rxNode} NodeID: {nodeID}")
altFeet = round(position_data['altitude'] * 3.28084, 2)
send_message(f"High Altitude {altFeet}ft ({position_data['altitude']}m) on Device:{rxNode} Node:{get_name_from_number(nodeID,'short',rxNode)}", highfly_channel, 0, rxNode)
# POSITION_APP packets
if packet_type == 'POSITION_APP':
if debugMetadata and 'POSITION_APP' not in metadataFilter:
print(f"DEBUG POSITION_APP: {packet}\n\n")
# get the position data
keys = ['altitude', 'groundSpeed', 'precisionBits']
position_data = packet['decoded']['position']
try:
if nodeID not in positionMetadata:
positionMetadata[nodeID] = {}
for key in keys:
positionMetadata[nodeID][key] = position_data.get(key, 0)
# 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:
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)}")
altFeet = round(position_data['altitude'] * 3.28084, 2)
msg = f"🚀 High Altitude Detected! NodeID:{nodeID} Alt:{altFeet:,.0f}ft/{position_data['altitude']:,.0f}m"
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))
if flight_info and NO_ALERTS not in flight_info and ERROR_FETCHING_DATA not in flight_info:
msg += f"\nDetected near:\n{flight_info}"
send_message(msg, highfly_channel, 0, highfly_interface)
time.sleep(responseDelay)
# Keep the positionMetadata dictionary at a maximum size of 20
if len(positionMetadata) > 20:
# Remove the oldest entry
oldest_nodeID = next(iter(positionMetadata))
del positionMetadata[oldest_nodeID]
# add a packet count to the positionMetadata for the node
if 'packetCount' in positionMetadata[nodeID]:
positionMetadata[nodeID]['packetCount'] += 1
else:
positionMetadata[nodeID]['packetCount'] = 1
except Exception as e:
logger.debug(f"System: POSITION_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# WAYPOINT_APP packets
if packet_type == 'WAYPOINT_APP':
if debugMetadata and 'WAYPOINT_APP' not in metadataFilter:
print(f"DEBUG WAYPOINT_APP: {packet}\n\n")
# get the waypoint data
waypoint_data = packet['decoded']['waypoint']
try:
id = waypoint_data.get('id', 0)
latitudeI = waypoint_data.get('latitudeI', 0)
longitudeI = waypoint_data.get('longitudeI', 0)
expire = waypoint_data.get('expire', 0)
if expire == 1:
expire = "Now"
elif expire == 0:
expire = "Never"
else:
expire = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expire))
description = waypoint_data.get('description', '')
name = waypoint_data.get('name', '')
logger.info(f"System: Waypoint from Device: {rxNode} Channel: {channel} NodeID:{nodeID} ID:{id} Lat:{latitudeI/1e7} Lon:{longitudeI/1e7} Expire:{expire} Name:{name} Desc:{description}")
except Exception as e:
logger.debug(f"System: WAYPOINT_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# NEIGHBORINFO_APP
if packet_type == 'NEIGHBORINFO_APP':
if debugMetadata and 'NEIGHBORINFO_APP' not in metadataFilter:
print(f"DEBUG NEIGHBORINFO_APP: {packet}\n\n")
# get the neighbor info data
neighbor_data = packet['decoded']
neighbor_list = neighbor_data.get('neighbors', [])
logger.info(f"System: Neighbor Info from Device: {rxNode} Channel: {channel} NodeID:{nodeID} Neighbors:{len(neighbor_list)}")
# TRACEROUTE_APP
if packet_type == 'TRACEROUTE_APP':
if debugMetadata and 'TRACEROUTE_APP' not in metadataFilter:
print(f"DEBUG TRACEROUTE_APP: {packet}\n\n")
# get the traceroute data
traceroute_data = packet['decoded']
# DETECTION_SENSOR_APP
if packet_type == 'DETECTION_SENSOR_APP':
if debugMetadata and 'DETECTION_SENSOR_APP' not in metadataFilter:
print(f"DEBUG DETECTION_SENSOR_APP: {packet}\n\n")
# get the detection sensor data
detection_data = packet['decoded']
detction_text = detection_data.get('text', '')
try:
if detction_text != '':
logger.info(f"System: Detection Sensor Data from Device: {rxNode} Channel: {channel} NodeID:{nodeID} Text:{detction_text}")
if detctionSensorAlert:
send_message(f"🚨Detection Sensor from Device: {rxNode} Channel: {channel} NodeID:{get_name_from_number(nodeID,'long',rxNode)} Alert:{detction_text}", secure_channel, 0, secure_interface)
time.sleep(responseDelay)
# Keep the positionMetadata dictionary at a maximum size of 20
if len(positionMetadata) > 20:
# Remove the oldest entry
oldest_nodeID = next(iter(positionMetadata))
del positionMetadata[oldest_nodeID]
except Exception as e:
logger.debug(f"System: POSITION_APP decode error: {e} packet {packet}")
except Exception as e:
logger.debug(f"System: DETECTION_SENSOR_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# WAYPOINT_APP packets
if packet_type == 'WAYPOINT_APP':
if debugMetadata: print(f"DEBUG WAYPOINT_APP: {packet['decoded']['waypoint']}\n\n")
# get the waypoint data
waypoint_data = packet['decoded']
# PAXCOUNTER_APP
if packet_type == 'PAXCOUNTER_APP':
if debugMetadata and 'PAXCOUNTER_APP' not in metadataFilter:
print(f"DEBUG PAXCOUNTER_APP: {packet}\n\n")
# get the paxcounter data
paxcounter_data = packet['decoded']['paxcounter']
try:
wifi_count = paxcounter_data.get('wifi', 0)
ble_count = paxcounter_data.get('ble', 0)
uptime = paxcounter_data.get('uptime', 0)
logger.info(f"System: Paxcounter Data from Device: {rxNode} Channel: {channel} NodeID:{nodeID} WiFi:{wifi_count} BLE:{ble_count} Uptime:{getPrettyTime(uptime)}")
except Exception as e:
logger.debug(f"System: PAXCOUNTER_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# NEIGHBORINFO_APP
if packet_type == 'NEIGHBORINFO_APP':
if debugMetadata: print(f"DEBUG NEIGHBORINFO_APP: {packet}\n\n")
# get the neighbor info data
neighbor_data = packet['decoded']
# TRACEROUTE_APP
if packet_type == 'TRACEROUTE_APP':
if debugMetadata: print(f"DEBUG TRACEROUTE_APP: {packet}\n\n")
# get the traceroute data
traceroute_data = packet['decoded']
# REMOTE_HARDWARE_APP
if packet_type == 'REMOTE_HARDWARE_APP':
if debugMetadata and 'REMOTE_HARDWARE_APP' not in metadataFilter:
print(f"DEBUG REMOTE_HARDWARE_APP: {packet}\n\n")
# get the remote hardware data
remote_hardware_data = packet['decoded']
try:
hardware_info = remote_hardware_data.get('hardware_info', '')
logger.info(f"System: Remote Hardware Data from Device: {rxNode} Channel: {channel} NodeID:{nodeID} Info:{hardware_info}")
except Exception as e:
logger.debug(f"System: REMOTE_HARDWARE_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# DETECTION_SENSOR_APP
if packet_type == 'DETECTION_SENSOR_APP':
if debugMetadata: print(f"DEBUG DETECTION_SENSOR_APP: {packet}\n\n")
# get the detection sensor data
detection_data = packet['decoded']
# PAXCOUNTER_APP
if packet_type == 'PAXCOUNTER_APP':
if debugMetadata: print(f"DEBUG PAXCOUNTER_APP: {packet}\n\n")
# get the paxcounter data
paxcounter_data = packet['decoded']
# REMOTE_HARDWARE_APP
if packet_type == 'REMOTE_HARDWARE_APP':
if debugMetadata: print(f"DEBUG REMOTE_HARDWARE_APP: {packet}\n\n")
# get the remote hardware data
remote_hardware_data = packet['decoded']
except KeyError as e:
logger.critical(f"System: Error consuming metadata: {e} Device:{rxNode}")
logger.debug(f"System: Error Packet = {packet}")
def noisyTelemetryCheck():
global positionMetadata
if len(positionMetadata) == 0:
return
# sort the positionMetadata by packetCount
sorted_positionMetadata = dict(sorted(positionMetadata.items(), key=lambda item: item[1].get('packetCount', 0), reverse=True))
top_three = list(sorted_positionMetadata.items())[:3]
for nodeID, data in top_three:
if data.get('packetCount', 0) > noisyTelemetryLimit:
logger.warning(f"System: Noisy Telemetry Detected from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', 1)} Packets:{data.get('packetCount', 0)}")
# reset the packet count for the node
positionMetadata[nodeID]['packetCount'] = 0
def get_sysinfo(nodeID=0, deviceID=1):
# Get the system telemetry data for return on the sysinfo command
@@ -1216,10 +1424,8 @@ async def handleSentinel(deviceID):
resolution = metadata.get('precisionBits')
logger.warning(f"System: {detectedNearby} is close to your location on Interface{deviceID} Accuracy is {resolution}bits")
for i in range(1, 10):
if globals().get(f'interface{i}_enabled'):
send_message(f"Sentry{deviceID}: {detectedNearby}", secure_channel, 0, i)
time.sleep(responseDelay + 1)
send_message(f"Sentry{deviceID}: {detectedNearby}", secure_channel, 0, secure_interface)
time.sleep(responseDelay + 1)
if enableSMTP and email_sentry_alerts:
for email in sysopEmails:
send_email(email, f"Sentry{deviceID}: {detectedNearby}")
@@ -1263,6 +1469,19 @@ async def watchdog():
await retry_interface(i)
except Exception as e:
logger.error(f"System: retrying interface{i}: {e}")
# check for noisy telemetry
if noisyNodeLogging:
noisyTelemetryCheck()
# check the load_bbsdm flag to reload the BBS messages from disk
if bbs_enabled and bbsAPI_enabled:
load_bbsdm()
load_bbsdb()
# perform memory cleanup every 10 minutes
if datetime.now().minute % 10 == 0:
cleanup_memory()
def exit_handler():
# Close the interface and save the BBS messages

View File

@@ -29,6 +29,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"cqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"echo": lambda: handle_echo(message, message_from_id, deviceID, isDM, channel_number),
"lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM),
"motd": lambda: handle_motd(message, MOTD),
"ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
@@ -146,14 +147,48 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
return msg
def handle_motd(message):
def handle_motd(message, message_from_id, isDM):
global MOTD
if "$" in message:
isAdmin = False
msg = ""
# check if the message_from_id is in the bbs_admin_list
if bbs_admin_list != ['']:
for admin in bbs_admin_list:
if str(message_from_id) == admin:
isAdmin = True
break
else:
isAdmin = True
# admin help via DM
if "?" in message and isDM and isAdmin:
msg = "Message of the day, set with 'motd $ HelloWorld!'"
elif "?" in message and isDM and not isAdmin:
# non-admin help via DM
msg = "Message of the day"
elif "$" in message and isAdmin:
motd = message.split("$")[1]
MOTD = motd.rstrip()
return "MOTD Set to: " + MOTD
logger.debug(f"System: {message_from_id} changed MOTD: {MOTD}")
msg = "MOTD changed to: " + MOTD
else:
return MOTD
msg = "MOTD: " + MOTD
return msg
def handle_echo(message, message_from_id, deviceID, isDM, channel_number):
if "?" in message.lower():
return "echo command returns your message back to you. Example:echo Hello World"
elif "echo " in message.lower():
parts = message.lower().split("echo ", 1)
if len(parts) > 1 and parts[1].strip() != "":
echo_msg = parts[1]
if channel_number != echoChannel:
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"
def sysinfo(message, message_from_id, deviceID):
if "?" in message:
@@ -169,14 +204,6 @@ def handle_lheard(message, nodeid, deviceID, isDM):
bot_response = "Last Heard\n"
bot_response += str(get_node_list(1))
# show last users of the bot with the cmdHistory list
history = handle_history(message, nodeid, deviceID, isDM, lheard=True)
if history:
bot_response += f'LastSeen\n{history}'
else:
# trim the last \n
bot_response = bot_response[:-1]
# bot_response += getNodeTelemetry(deviceID)
return bot_response
@@ -389,7 +416,7 @@ def onReceive(packet, interface):
time.sleep(responseDelay)
else:
# Evaluate non TEXT_MESSAGE_APP packets
consumeMetadata(packet, rxNode)
consumeMetadata(packet, rxNode, channel_number)
except KeyError as e:
logger.critical(f"System: Error processing packet: {e} Device:{rxNode}")
logger.debug(f"System: Error Packet = {packet}")
@@ -413,10 +440,12 @@ async def start_rx():
logger.debug("System: Celestial Telemetry Enabled")
if motd_enabled:
logger.debug(f"System: MOTD Enabled using {MOTD}")
if enableEcho:
logger.debug(f"System: Echo command Enabled")
if sentry_enabled:
logger.debug(f"System: Sentry Mode Enabled {sentry_radius}m radius reporting to channel:{secure_channel}")
if store_forward_enabled:
logger.debug(f"System: Store and Forward Enabled using limit: {storeFlimit}")
logger.debug(f"System: S&F(messages command) Enabled using limit: {storeFlimit}")
if useDMForResponse:
logger.debug(f"System: Respond by DM only")
if repeater_enabled and multiple_interface:
@@ -441,14 +470,41 @@ async def start_rx():
# Hello World
async def main():
meshRxTask = asyncio.create_task(start_rx())
watchdogTask = asyncio.create_task(watchdog())
if file_monitor_enabled:
fileMonTask: asyncio.Task = asyncio.create_task(handleFileWatcher())
await asyncio.gather(meshRxTask, watchdogTask)
if file_monitor_enabled:
await asyncio.gather(fileMonTask)
tasks = []
try:
# Create core tasks
tasks.append(asyncio.create_task(start_rx(), name="pong_rx"))
tasks.append(asyncio.create_task(watchdog(), name="watchdog"))
# Add optional tasks
if file_monitor_enabled:
tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor"))
logger.info(f"System: Starting {len(tasks)} async tasks")
# Wait for all tasks with proper exception handling
results = await asyncio.gather(*tasks, return_exceptions=True)
# Check for exceptions in results
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f"Task {tasks[i].get_name()} failed with: {result}")
except Exception as e:
logger.error(f"Main loop error: {e}")
finally:
# Cleanup tasks
logger.info("System: Cleaning up async tasks")
for task in tasks:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
logger.debug(f"Task {task.get_name()} cancelled successfully")
except Exception as e:
logger.warning(f"Error cancelling task {task.get_name()}: {e}")
await asyncio.sleep(0.01)

43
script/addFav.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
# Add a favorite node to all interfaces from config.ini data
# meshing-around - helper script
import sys
import os
# welcome header
print("meshing-around: addFav - Auto-Add favorite nodes to all interfaces from config.ini data")
print("---------------------------------------------------------------")
try:
# set the path to import the modules and config.ini
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from modules.log import *
from modules.system import *
except Exception as e:
print(f"Error importing modules run this program from the main program directory 'python3 script/addFav.py'")
exit(1)
try:
# compile the favorite list wich returns node,interface tuples
favList = compileFavoriteList()
logger.debug(f"addFav: Compiled favorite list:\n {favList}")
except Exception as e:
logger.error(f"addFav: Error compiling favorite list: {e} - run this program from the main program directory 'python3 script/addFav.py'")
exit(1)
if favList:
# for each node,interface tuple add the favorite node
for fav in favList:
try:
handleFavoritNode(fav['deviceID'], fav['nodeID'], True)
time.sleep(1)
except Exception as e:
logger.error(f"addFav: Error adding favorite node {fav['nodeID']} to device {fav['deviceID']}: {e}")
else:
logger.info("addFav: No favorite nodes to add to device(s)")
exit(0)
count_devices = set([fav['deviceID'] for fav in favList])
count_nodes = set([fav['nodeID'] for fav in favList])
logger.info(f"addFav: Finished adding {len(count_nodes)} favorite nodes to {len(count_devices)} device(s)")
exit(0)

53
script/injectDM.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# Usage: python3 script/injectDM.py -s NODEID -d NODEID -m "message"
# meshing-around - helper script
import sys
import os
import argparse
# welcome header
print("meshing-around: injectDM.py -s NODEID -d NODEID -m 'Hello World'")
print("Auto-Inject DM messages to data/bbsdm.pkl")
print(" needs config.ini [bbs] bbsAPI_enabled = True ")
print("---------------------------------------------------------------")
try:
# set the path to import the modules and config.ini
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from modules.log import *
from modules.bbstools import *
except Exception as e:
print(f"Error importing modules run this program from the main program directory 'python3 script/injectDM.py'")
exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Inject DM messages to data/bbsdm.pkl')
parser.add_argument('-s', '--src', type=str, required=True, help='Source NODEID')
parser.add_argument('-d', '--dst', type=str, required=True, help='Destination NODEID')
parser.add_argument('-m', '--msg', type=str, required=True, help="'Message to send'")
args = parser.parse_args()
dst = args.dst
src = args.src
message = args.msg
if not message:
logger.error("Message cannot be empty")
exit(1)
if dst == src:
logger.error("Source and Destination cannot be the same")
exit(1)
if not isinstance(bbs_dm, list):
logger.error("bbs_dm is corrupt, something is wrong")
exit(1)
# inject the message
if bbs_post_dm(dst, message, src):
logger.info(f"Injected message from {src} to {dst}: {message}")
else:
logger.error("Failed to inject message")
exit(1)
# show stats get_bbs_stats
stats = get_bbs_stats()
stats = stats.replace("\n", " | ")
logger.info(f"BBS Stats: {stats}")

View File

@@ -6,40 +6,65 @@
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
# Update the local repository
echo "Updating local repository..."
#git fetch --all
#git reset --hard origin/main # Replace 'main' with your branch name if different
git pull origin main --rebase # Fetch and rebase to keep local changes if any
echo "Local repository updated."
# 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
if [[ "$choice" == "y" || "$choice" == "Y" ]]; then
git fetch --all
git reset --hard origin/main
echo "Local repository updated."
else
echo "Update aborted due to git conflicts."
fi
fi
# Install or update dependencies
echo "Installing or updating dependencies..."
pip install -r requirements.txt --upgrade
if pip install -r requirements.txt --upgrade 2>&1 | grep -q "externally-managed-environment"; then
# if venv is found ask to run with launch.sh
if [ -d "venv" ]; then
echo "A virtual environment (venv) was found. run from inside venv"
else
read -p "Warning: You are in an externally managed environment. Do you want to continue with --break-system-packages? (y/n): " choice
if [[ "$choice" == "y" || "$choice" == "Y" ]]; then
pip install --break-system-packages -r requirements.txt --upgrade
else
echo "Update aborted due to dependency installation issue."
fi
fi
else
echo "Dependencies installed or updated."
fi
echo "Dependencies installed or updated."
# if service was stopped earlier, restart it
if [ "$service_stopped" = true ]; then
echo "Restarting services..."
systemctl start mesh_bot.service
systemctl start pong_bot.service
systemctl start mesh_bot_reporting.service
systemctl start mesh_bot_w3.service
echo "Services restarted."
fi
# Restart the services
echo "Restarting services..."
systemctl start mesh_bot.service
systemctl start pong_bot.service
systemctl start mesh_bot_reporting.service
systemctl start mesh_bot_w3.service
echo "Services restarted."
# Print completion message
echo "Update completed successfully?"
exit 0