Compare commits

...

292 Commits

Author SHA1 Message Date
SpudGunMan
00280e351c Update mesh_bot.py 2025-10-07 14:04:07 -07:00
SpudGunMan
0e8bb197a9 Update mesh_bot.py 2025-10-07 13:59:49 -07:00
SpudGunMan
d825c0fa15 Update mesh_bot.py
what happened here? I forget now but sheesh!
2025-10-07 13:57:00 -07:00
SpudGunMan
6abe73c1bc Update mesh_bot.py
ack
2025-10-07 13:54:32 -07:00
SpudGunMan
b8e9adb223 fixMessagesCommand
thanks @mesb1  https://github.com/SpudGunMan/meshing-around/issues/200
2025-10-07 13:48:23 -07:00
SpudGunMan
e621016e9a nom
nom
2025-10-07 06:06:21 -07:00
SpudGunMan
cfaf652852 Update mesh_bot.py 2025-10-06 20:02:36 -07:00
SpudGunMan
6c27b5d5de xoxo
enhance
2025-10-06 18:03:22 -07:00
SpudGunMan
a31fa90942 Update system.py 2025-10-06 14:57:40 -07:00
SpudGunMan
3cd347dff3 Update tictactoe.py 2025-10-06 14:46:24 -07:00
SpudGunMan
ea4ac1f9c1 whichonelooksbetter 2025-10-06 14:42:50 -07:00
SpudGunMan
a9da8336cc enhance 2025-10-06 14:40:08 -07:00
SpudGunMan
4ba60ed276 correctLogLevel 2025-10-06 14:25:13 -07:00
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
Kelly
c5ba56a656 Merge pull request #178 from SpudGunMan/lab
Gemma3 LLM
2025-08-26 11:00:31 -07:00
SpudGunMan
50c3249edc explicitCmd
i think @NomDeTom mentioned this a long time ago and well .. here is a change to help the phases of the moon and tide.
2025-08-26 10:44:30 -07:00
SpudGunMan
80897f7a82 defaults to gemma3 raw input
this is a change as its looking to remove google lookups with the python module. if this changes is impacting please let me know in [general] add `rawLLMQuery = False` to reverse
2025-08-26 10:26:33 -07:00
SpudGunMan
d311832a92 truncation 2025-08-20 11:55:44 -07:00
SpudGunMan
56af59345d Update settings.py 2025-08-19 06:19:07 -07:00
SpudGunMan
c1adca7db0 Update mesh_bot.py 2025-08-18 18:55:23 -07:00
SpudGunMan
4c7fe55b43 hopFix 2025-08-18 18:54:02 -07:00
SpudGunMan
df6a1cfb66 Update pong_bot.py
better MQTT handler
2025-08-17 20:25:40 -07:00
SpudGunMan
9994446510 better MQTT handler 2025-08-17 20:24:09 -07:00
SpudGunMan
9272218815 Update system.py
i declare
2025-08-17 20:05:41 -07:00
SpudGunMan
388d862fc9 Update llm.py 2025-08-16 21:24:46 -07:00
SpudGunMan
ac33f8a02b Update llm.py 2025-08-16 21:16:44 -07:00
SpudGunMan
f04392a81c Update llm.py 2025-08-16 20:25:53 -07:00
SpudGunMan
d0097c092b Update llm.py 2025-08-16 17:48:04 -07:00
SpudGunMan
92ff166260 Update llm.py
adding these back, the token limit just has no bounds
2025-08-15 21:48:30 -07:00
Kelly
bfe0d219f9 Merge pull request #175 from SpudGunMan/main
fix daylight
2025-08-15 21:44:24 -07:00
SpudGunMan
85a2d90cff fix daylight 2025-08-15 21:42:45 -07:00
SpudGunMan
e15232875c token limit 2025-08-15 21:33:40 -07:00
SpudGunMan
d1a87f161b updateOllama
remember to update to latest ollama bin's
2025-08-15 21:17:58 -07:00
SpudGunMan
626ac59b4e gemma3
LLM rewrite, the removal of RAG to keep things clean.

This default changes as well as puts input direct to the LLM further testing is needed, new LLM prompting is different.
2025-08-15 21:11:09 -07:00
SpudGunMan
835a9e5f89 Update space.py 2025-08-15 06:52:55 -07:00
SpudGunMan
3ae928dd66 more light on the sun 2025-08-15 06:51:51 -07:00
SpudGunMan
3973406783 formatting of sun
trying this out vs the old way
2025-08-15 06:32:15 -07:00
SpudGunMan
4fbdd42837 Update space.py 2025-08-15 05:40:33 -07:00
SpudGunMan
04378efdd8 Update space.py 2025-08-14 20:33:21 -07:00
SpudGunMan
0d19a40ed6 Update space.py 2025-08-14 19:47:53 -07:00
SpudGunMan
75ac3c974a Update space.py 2025-08-14 19:47:25 -07:00
SpudGunMan
7e0eb348ae 🌝 2025-08-14 19:46:53 -07:00
SpudGunMan
af6ea2a512 Update space.py 2025-08-14 19:41:12 -07:00
SpudGunMan
6665ea7dcd moon refactor 2025-08-14 19:40:32 -07:00
SpudGunMan
3212661ee8 enhance sun and moon
add position data when visible
2025-08-14 19:35:13 -07:00
SpudGunMan
0675132171 up a river
without help
2025-08-13 20:41:42 -07:00
Kelly
fdb7897963 Merge pull request #173 from dludwig/typofix
typo fix deteted -> detected
2025-08-13 20:16:18 -07:00
dludwig
8ff7a0bf3c typo fix detetec -> detected 2025-08-13 15:18:34 -07:00
SpudGunMan
c210534543 Update README.md 2025-08-13 08:58:31 -07:00
SpudGunMan
ea7574a868 Update locationdata.py
remove the $$ end marker
2025-08-12 13:40:26 -07:00
SpudGunMan
8f69c4d93c Update mesh_bot.py
aarg
2025-08-12 12:03:43 -07:00
SpudGunMan
bc9ada91b4 Update mesh_bot.py 2025-08-12 11:54:17 -07:00
SpudGunMan
28f06f0a21 Update config.template 2025-08-12 11:50:05 -07:00
SpudGunMan
267fe392e3 tuypo 2025-08-12 11:42:13 -07:00
SpudGunMan
6c1f7940ca refactor coastal weather
changes to config.ini template if you use tide or mwx
2025-08-12 11:35:42 -07:00
SpudGunMan
2fc9281394 Update system.py 2025-08-04 18:54:46 -07:00
SpudGunMan
b5bd1008c2 HowHigh? divideBy3
https://github.com/SpudGunMan/meshing-around/discussions/170
2025-08-03 17:43:10 -07:00
SpudGunMan
ee1db5b7be Update locationdata.py 2025-08-02 19:21:46 -07:00
SpudGunMan
7395b96337 Update locationdata.py 2025-07-30 10:16:33 -07:00
SpudGunMan
f3c6f77b23 Update mesh_bot.py 2025-07-30 10:13:14 -07:00
SpudGunMan
f6e04a42a0 Update system.py 2025-07-30 10:11:34 -07:00
SpudGunMan
3fcd588d02 bugs and docs
Consolidated Tide with MWX fixed up readme and cleaned up rlist in help
2025-07-30 10:05:04 -07:00
SpudGunMan
e1b47484f2 NOAA Coastal Marine Forcast data
using older but handy products with new mwx
2025-07-30 08:34:20 -07:00
SpudGunMan
14798cb992 alertDe 2025-07-22 09:28:08 -07:00
Kelly
41c8f0044b Merge pull request #163 from SudoRand/efficient-chunking
Allow chunker to consolidate lines when possible
2025-07-22 08:23:42 -07:00
SpudGunMan
45eefb24d8 enhance retry 2025-07-22 07:02:08 -07:00
SpudGunMan
410d32947c Update system.py 2025-07-21 20:13:07 -07:00
SpudGunMan
748652ac62 onDisconnect
correcting multiple issues adding config.ini feature for dont_retry_disconnect

https://github.com/SpudGunMan/meshing-around/issues/137
https://github.com/SpudGunMan/meshing-around/issues/156
2025-07-21 20:10:59 -07:00
SpudGunMan
d715cb6b4d Update system.py 2025-07-21 04:33:04 -07:00
SpudGunMan
1895a365ae fix retry and failure
correcting multiple issues with some bad code

https://github.com/SpudGunMan/meshing-around/issues/137

https://github.com/SpudGunMan/meshing-around/issues/156
2025-07-20 05:41:50 -07:00
SpudGunMan
cc58a38165 Update system.py 2025-07-18 21:24:55 -07:00
SpudGunMan
a8ccb05d56 Update system.py
bug of undefined for interface retry
2025-07-16 08:57:39 -07:00
SpudGunMan
a90a533a30 USGS Alerts
documented
2025-07-15 21:20:37 -07:00
SpudGunMan
57a4e5d68c Update README.md 2025-07-15 21:12:23 -07:00
SpudGunMan
7c99b684ad riverflow
never got documented well
2025-07-15 21:11:48 -07:00
SpudGunMan
b957c89d70 Update README.md 2025-07-15 20:32:51 -07:00
SpudGunMan
9b986dd57a Update locationdata.py
allow FIPS only
2025-07-15 20:30:43 -07:00
SpudGunMan
9e348332e5 SAME code back in iPAWS
the state only FIPS codes are too wide
2025-07-15 19:09:27 -07:00
SpudGunMan
0cfe759ef6 Update mesh_bot.py 2025-07-15 15:34:32 -07:00
SpudGunMan
e95902ef98 fix Excessive queries to FEMA
issue raised https://github.com/SpudGunMan/meshing-around/issues/165

Co-Authored-By: DEVAFRS <180097515+devafrs@users.noreply.github.com>
2025-07-15 15:22:53 -07:00
SpudGunMan
c7df4d88d1 Update config.template
Co-Authored-By: Russell Schmidt <836646+rfschmid@users.noreply.github.com>
2025-07-15 14:28:25 -07:00
SpudGunMan
6d01c5a986 further adjustments for 2.7.2
https://github.com/SpudGunMan/meshing-around/pull/164#pullrequestreview-3021705851

and

https: //github.com/SpudGunMan/meshing-around/issues/162
Co-Authored-By: SudoRand <25190078+sudorand@users.noreply.github.com>
2025-07-15 11:14:29 -07:00
SpudGunMan
3f882dcfcd fix message.log
fixing issue for log in https://github.com/SpudGunMan/meshing-around/pull/161

Co-Authored-By: SudoRand <25190078+sudorand@users.noreply.github.com>
2025-07-15 09:41:34 -07:00
SpudGunMan
b146fd6f64 Revert "enhance sysinfo"
This reverts commit 8709e5aed5.
2025-07-14 22:55:41 -07:00
SpudGunMan
8709e5aed5 enhance sysinfo
ChUtil/Node value
2025-07-14 22:13:49 -07:00
SpudGunMan
caf8a2708b Update log.py
fix time display
2025-07-14 22:04:22 -07:00
SpudGunMan
9b4200c198 Update config.template
adjustments for 2.7.2 firmware might change again
2025-07-14 21:54:30 -07:00
SudoRand
097cae6e94 Allow chunker to consolidate lines when possible
This allows the chunker to consolidate lines into significantly
fewer messages in many cases without exceeding the max chunk size.

Without this change, the chunker will either emit all lines in
one message (if it fits in a single chunk) or else each line will be in
a separate message. This often creates a long series of short messages,
which doesn't transmit as quickly or display as compact.

Instead, this consolidates as many lines as possible into each
message, while being sure to stay within the chunk size limit.
This should reduce the load on the mesh, and it's also more readable.
2025-07-14 12:27:14 -06:00
SpudGunMan
0a260b28b6 Update llm.py
last time?
2025-06-26 19:30:33 -07:00
SpudGunMan
3f5c6f2e9a Update llm.py 2025-06-26 19:03:13 -07:00
SpudGunMan
8a4f7a904a Update llm.py 2025-06-26 18:49:16 -07:00
SpudGunMan
0bc3d392cf fix Interface logic
a condition where TCP interfaces can fail leaving a none condition. this should resolve the errored interface better.
2025-06-25 07:30:46 -07:00
SpudGunMan
5eaef8b5b8 Update sysEnv.sh
enhance with git update check

Co-Authored-By: Johannes le Roux <dade@dade.co.za>
2025-06-25 07:25:04 -07:00
SpudGunMan
3a0007771d Update update.sh 2025-06-22 20:04:18 -07:00
SpudGunMan
67ba2b1fb5 Create update.sh 2025-06-22 19:59:31 -07:00
SpudGunMan
f2e7a9aa5c Update config.template 2025-06-18 12:15:44 -07:00
SpudGunMan
9d22270dde highfly_ignoreList
some nodes have bad altimeters
2025-06-18 12:14:14 -07:00
SpudGunMan
409d07436e Update settings.py 2025-06-10 11:22:32 -07:00
SpudGunMan
5ab0001f2b Update system.py
https://github.com/SpudGunMan/meshing-around/issues/154
2025-06-08 20:32:28 -07:00
SpudGunMan
5e34537af7 fix
reference https://github.com/SpudGunMan/meshing-around/issues/154
2025-06-08 19:26:51 -07:00
SpudGunMan
1764bdf4f3 enhance
the database page to include qrz.db
2025-06-07 21:05:49 -07:00
SpudGunMan
2290f07351 Update locationdata.py 2025-06-06 17:54:31 -07:00
SpudGunMan
ee01051cf7 Update config.template 2025-06-06 17:54:29 -07:00
Kelly
de50a52fa6 Merge pull request #153 from rfschmid/allow-dms-to-numeric-short-names 2025-06-05 19:38:44 -07:00
SpudGunMan
8eabfaa9c4 enhance ignore logic 2025-06-04 10:37:58 -07:00
Russell Schmidt
ca7114b058 Allow DMs to numeric short names 2025-06-04 11:55:30 -05:00
Kelly
8b94dc8111 Merge pull request #152 from SudoRand/main 2025-06-01 19:03:01 -07:00
SudoRand
5b26aabb00 Config for whether ollama responds to non-commands
The current behavior is that whenever ollama is enabled the LLM replies
to all non-command message. This setting allows limiting it the LLM to
run only in response to the "ask:" and "askai" commands.

Default is True to keep consistent with previous behavior.
2025-06-01 15:32:08 -06:00
SudoRand
67b3c67348 Fix bug that always enabled news_random_line_only
This looks like a simple typo that accidentally used read_news_enabled
for the news_random_line_only parameter. As a result, the
news_random_line_only setting was always treated as True (since this
line it only executed if read_news_enabled was True). Now it obeys the
configuration value.
2025-06-01 14:02:19 -06:00
SpudGunMan
860cceec59 Update locationdata.py 2025-05-28 15:20:33 -07:00
Kelly
53a0535e55 Merge pull request #149 from SpudGunMan/lab
High Altitude Alerts
2025-05-23 19:14:51 -07:00
SpudGunMan
621f4ad916 enhance 2025-05-23 19:10:15 -07:00
SpudGunMan
118857ec15 Update system.py 2025-05-21 16:52:01 -07:00
SpudGunMan
1be13be92a highfly enhancements 2025-05-21 16:43:56 -07:00
SpudGunMan
895fc3fd37 Update README.md 2025-05-21 16:36:55 -07:00
SpudGunMan
0e0bda60ad Update system.py 2025-05-21 16:36:52 -07:00
SpudGunMan
903767f4b3 Update settings.py 2025-05-21 16:36:46 -07:00
SpudGunMan
f54d362ea0 Update config.template 2025-05-21 16:36:43 -07:00
SpudGunMan
60bb68c6b5 Update system.py 2025-05-21 16:20:54 -07:00
SpudGunMan
feb9a1d9b3 FlightDetector
high alt detection
2025-05-21 16:18:46 -07:00
SpudGunMan
d055c35c96 Update install.sh 2025-05-11 13:07:52 -07:00
SpudGunMan
27820daaf4 Cron4W3 2025-05-10 15:34:33 -07:00
SpudGunMan
56e8e1c0d5 🐇🪵
config register set in config.ini for hop logs
2025-05-01 10:35:43 -07:00
SpudGunMan
4545b8f4a4 Update locationdata.py 2025-04-28 23:10:52 -07:00
SpudGunMan
6ed48d49ce Update videopoker.py
fix gameplay
2025-04-24 15:54:47 -07:00
SpudGunMan
a3a54b081d Update system.py 2025-04-23 19:34:13 -07:00
SpudGunMan
ab420af63e Update system.py 2025-04-23 09:24:51 -07:00
SpudGunMan
a55c61c47d Update web.py 2025-04-23 07:17:02 -07:00
SpudGunMan
7236f47eb7 Update README.md 2025-04-17 13:30:53 -07:00
SpudGunMan
05e11ae5f8 Update README.md 2025-04-13 10:04:15 -07:00
SpudGunMan
f8ffcc19b1 Update config.template 2025-04-13 10:04:13 -07:00
SpudGunMan
ea20eec604 Update README.md 2025-04-11 14:51:50 -07:00
SpudGunMan
d1204d2c26 ignoreEAS and USGS alert word
enhance with list to ignore words not wanted for broadcast
2025-04-11 14:32:51 -07:00
SpudGunMan
654d8b3ff7 Update README.md 2025-04-10 16:14:32 -07:00
SpudGunMan
3bf12d62b5 Update mesh_bot.py 2025-04-10 16:09:15 -07:00
SpudGunMan
0ec8613d27 Update install.sh
issues raised https://github.com/SpudGunMan/meshing-around/issues/139
2025-04-10 15:57:56 -07:00
SpudGunMan
10dd413ae7 Update config.template 2025-04-10 15:50:12 -07:00
SpudGunMan
09ac7525b3 Update mesh_bot.py 2025-04-10 15:48:47 -07:00
SpudGunMan
aac497dfa0 config.ini Scheduler enhancments
request from https://github.com/SpudGunMan/meshing-around/issues/141

enhances with a basic announcement from config.ini
2025-04-10 15:46:36 -07:00
30 changed files with 2441 additions and 652 deletions

5
.gitignore vendored
View File

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

279
README.md
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,20 +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.
@@ -61,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).
@@ -69,25 +73,103 @@ 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.
### Installation
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
If you dont have git you will need it `sudo apt-get install git`
```sh
git clone https://github.com/spudgunman/meshing-around
```
The code is under active development, so make sure to pull the latest changes regularly!
#### Quick setup
- **Automated Installation**: `install.sh` will automate optional venv and requirements installation.
- **Launch Script**: `launch.sh` only used in a venv install, to launch the bot and the report generator.
#### Docker Installation
## Full list of commands for the bot
### Networking
| Command | Description | ✅ Works Off-Grid |
|---------|-------------|-
| `ping`, `ack` | Return data for signal. Example: `ping 15 #DrivingI5` (activates auto-ping every 20 seconds for count 15 via DM only) | ✅ |
| `cmd` | Returns the list of commands (the help message) | ✅ |
| `history` | Returns the last commands run by user(s) | ✅ |
| `lheard` | Returns the last 5 heard nodes with SNR. Can also use `sitrep` | ✅ |
| `motd` | Displays the message of the day or sets it. Example: `motd $New Message Of the day` | ✅ |
| `sysinfo` | Returns the bot node telemetry info | ✅ |
| `test` | used to test the limits of data transfer `test 4` sends data to the maxBuffer limit (default 220) via DM only | ✅ |
| `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 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`| |
| `solar` | Gives an idea of the x-ray flux | |
| `sun` and `moon` | Return info on rise and set local time | ✅ |
| `tide` | Returns the local tides (NOAA data source) | |
| `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 Forecast data | |
### Bulletin Board & Mail
| Command | Description | |
|---------|-------------|-
| `bbshelp` | Returns the following help message | ✅ |
| `bbslist` | Lists the messages by ID and subject | ✅ |
| `bbsread` | Reads a message. Example: `bbsread #1` | ✅ |
| `bbspost` | Posts a message to the public board or sends a DM(Mail) Examples: `bbspost $subject #message`, `bbspost @nodeNumber #message`, `bbspost @nodeShortName #message` | ✅ |
| `bbsdelete` | Deletes a message. Example: `bbsdelete #4` | ✅ |
| `bbsinfo` | Provides stats on BBS delivery and messages (sysop) | ✅ |
| `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 communications | |
| `setsms` | Adds the SMS-Email for quick communications | |
| `clearsms` | Clears all SMS-Emails on file for node | |
### Data Lookup
| Command | Description | |
|---------|-------------|-
| `askai` and `ask:` | Ask Ollama LLM AI for a response. Example: `askai what temp do I cook chicken` | ✅ |
| `messages` | Replays the last messages heard on device, like Store and Forward, returns the PublicChannel and Current | ✅ |
| `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 | |
|---------|-------------|-
| `checkin` | Check in the node to the checklist database, you can add a note like `checkin ICO` or `checkin radio4` | ✅ |
| `checkout` | Checkout the node in the checklist database, checkout all from node | ✅ |
| `checklist` | Display the checklist database, with note | ✅ |
### Games (via DM only)
| Command | Description | |
|---------|-------------|-
| `blackjack` | Plays Blackjack (Casino 21) | ✅ |
| `dopewars` | Plays the classic drug trader game | ✅ |
| `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 | |
| `lemonstand` | Plays the classic Lemonade Stand finance game | ✅ |
| `mastermind` | Plays the classic code-breaking game | ✅ |
| `videopoker` | Plays basic 5-card hold Video Poker | ✅ |
## Other Install Options
### Docker Installation - handy for windows
See further info on the [docker.md](script/docker/README.md)
#### Manual Install
### Manual Install
Install the required dependencies using pip:
```sh
pip install -r requirements.txt
@@ -136,6 +218,7 @@ defaultChannel = 0
ignoreDefaultChannel = False # ignoreDefaultChannel, the bot will ignore the default channel set above
ignoreChannels = # ignoreChannels is a comma separated list of channels to ignore, e.g. 4,5
cmdBang = False # require ! to be the first character in a command
explicitCmd = True # require explicit command, the message will only be processed if it starts with a command word disable to get more activity
```
### Location Settings
@@ -147,7 +230,12 @@ enabled = True
lat = 48.50
lon = -123.0
UseMeteoWxAPI = True
riverListDefault = # NOAA Hydrology data, unique identifiers, LID or USGS ID
coastalEnabled = False # NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
# 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
```
### Module Settings
@@ -181,6 +269,9 @@ SentryRadius = 100 # radius in meters to detect someone close to the bot
SentryChannel = 9 # holdoff time multiplied by seconds(20) of the watchdog
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
@@ -189,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
@@ -212,30 +303,46 @@ alert_interface = 1
To Alert on Mesh with the EAS API you can set the channels and enable, checks every 20min.
#### FEMA iPAWS/EAS and NINA
This uses USA: SAME, FIPS, ZIP code to locate the alerts in the feed. By default ignoring Test messages.
This uses USA: SAME, FIPS, to locate the alerts in the feed. By default ignoring Test messages.
```ini
eAlertBroadcastEnabled = False # Goverment IPAWS/CAP Alert Broadcast
eAlertBroadcastCh = 2,3 # Goverment Emergency IPAWS/CAP Alert Broadcast Channels
ignoreFEMAenable = True # Ignore any headline that includes followig word list
ignoreFEMAwords = test,exercise
# comma separated list of codes (e.g., SAME,FIPS,ZIP) trigger local alert.
# find your SAME https://www.weather.gov/nwr/counties
mySAME = 053029,053073
# comma separated list of FIPS codes to trigger local alert. find your FIPS codes at https://en.wikipedia.org/wiki/Federal_Information_Processing_Standard_state_code
myFIPSList = 57,58,53
# find your SAME https://www.weather.gov/nwr/counties comma separated list of SAME code to further refine local alert.
mySAMEList = 053029,053073
# To use other country services enable only a single optional serivce
enableDEalerts = False # Use DE Alert Broadcast Data see template for filters
myRegionalKeysDE = 110000000000,120510000000
```
#### NOAA EAS
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
```
#### USGS River flow data and Volcano alerts
Using the USGS water data page locate a water flow device, for example Columbia River at Vancouver, WA - USGS-14144700
Volcano Alerts use lat/long to determine ~1000km radius
```ini
[location]
# USGS Hydrology unique identifiers, LID or USGS ID https://waterdata.usgs.gov
riverList = 14144700 # example Mouth of Columbia River
# USGS Volcano alerts Enable USGS Volcano Alert Broadcast
volcanoAlertBroadcastEnabled = False
volcanoAlertBroadcastCh = 2
```
### Repeater Settings
@@ -248,12 +355,12 @@ repeater_channels = [2, 3]
```
### Ollama (LLM/AI) Settings
For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma2:2b`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time.
For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma3:270m`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time.
```ini
# Enable ollama LLM see more at https://ollama.com
ollama = True # Ollama model to use (defaults to gemma2:2b)
ollamaModel = gemma2 #ollamaModel = llama3.1
ollamaModel = gemma3:latest # Ollama model to use (defaults to gemma3:270m)
ollamaHostName = http://localhost:11434 # server instance to use (defaults to local machine install)
```
@@ -261,6 +368,9 @@ Also see `llm.py` for changing the defaults of:
```ini
# LLM System Variables
rawQuery = True # if True, the input is sent raw to the LLM if False, it is processed by the meshBotAI template
# Used in the meshBotAI template (legacy)
llmEnableHistory = True # enable history for the LLM model to use in responses adds to compute time
llmContext_fromGoogle = True # enable context from google search results helps with responses accuracy
googleSearchResults = 3 # number of google search results to include in the context more results = more compute time
@@ -287,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
@@ -333,10 +446,15 @@ 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 = True
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 = # 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 actions are via code only at this time. See mesh_bot.py around line [1097](https://github.com/SpudGunMan/meshing-around/blob/e94581936530c76ea43500eebb43f32ba7ed5e19/mesh_bot.py#L1097) to edit the schedule. See [schedule documentation](https://schedule.readthedocs.io/en/stable/) for more. Recomend to backup changes so they dont get lost.
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.
```python
#Send WX every Morning at 08:00 using handle_wxc function to channel 2 on device 1
@@ -347,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))
@@ -357,86 +475,23 @@ 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~~
## Full list of commands for the bot
### Networking
| Command | Description | ✅ Works Off-Grid |
|---------|-------------|-
| `ping`, `ack` | Return data for signal. Example: `ping 15 #DrivingI5` (activates auto-ping every 20 seconds for count 15) | ✅ |
| `cmd` | Returns the list of commands (the help message) | ✅ |
| `history` | Returns the last commands run by user(s) | ✅ |
| `lheard` | Returns the last 5 heard nodes with SNR. Can also use `sitrep` | ✅ |
| `motd` | Displays the message of the day or sets it. Example: `motd $New Message Of the day` | ✅ |
| `sysinfo` | Returns the bot node telemetry info | ✅ |
| `test` | used to test the limits of data transfer `test 4` sends data to the maxBuffer limit (default 220) | ✅ |
| `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 | ✅ |
### Radio Propagation & Weather Forcasting
| Command | Description | |
|---------|-------------|-------------------
| `ea` and `ealert` | Return FEMA iPAWS/EAS alerts in USA or DE Headline or expanded details for USA | |
| `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`| |
| `solar` | Gives an idea of the x-ray flux | |
| `sun` and `moon` | Return info on rise and set local time | ✅ |
| `tide` | Returns the local tides (NOAA data source) | |
| `valert` | Returns USGS Volcano Data | |
| `wx` and `wxc` | Return local weather forecast (wxc is metric value), NOAA or Open Meteo for weather forecasting | |
| `wxa` and `wxalert` | Return NOAA alerts. Short title or expanded details | |
### Bulletin Board & Mail
| Command | Description | |
|---------|-------------|-
| `bbshelp` | Returns the following help message | ✅ |
| `bbslist` | Lists the messages by ID and subject | ✅ |
| `bbsread` | Reads a message. Example: `bbsread #1` | ✅ |
| `bbspost` | Posts a message to the public board or sends a DM(Mail) Examples: `bbspost $subject #message`, `bbspost @nodeNumber #message`, `bbspost @nodeShortName #message` | ✅ |
| `bbsdelete` | Deletes a message. Example: `bbsdelete #4` | ✅ |
| `bbsinfo` | Provides stats on BBS delivery and messages (sysop) | ✅ |
| `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 | |
| `setsms` | Adds the SMS-Email for quick communications | |
| `clearsms` | Clears all SMS-Emails on file for node | |
### Data Lookup
| Command | Description | |
|---------|-------------|-
| `askai` and `ask:` | Ask Ollama LLM AI for a response. Example: `askai what temp do I cook chicken` | ✅ |
| `messages` | Replays the last messages heard, like Store and Forward | ✅ |
| `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` |
### CheckList
| Command | Description | |
|---------|-------------|-
| `checkin` | Check in the node to the checklist database, you can add a note like `checkin ICO` or `checkin radio4` | ✅ |
| `checkout` | Checkout the node in the checklist database, checkout all from node | ✅ |
| `checklist` | Display the checklist database, with note | ✅ |
### Games (via DM)
| Command | Description | |
|---------|-------------|-
| `blackjack` | Plays Blackjack (Casino 21) | ✅ |
| `dopewars` | Plays the classic drug trader game | ✅ |
| `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 | ✅ |
| `lemonstand` | Plays the classic Lemonade Stand finance game | ✅ |
| `mastermind` | Plays the classic code-breaking game | ✅ |
| `videopoker` | Plays basic 5-card hold Video Poker | ✅ |
# Recognition
I used ideas and snippets from other responder bots and want to call them out!
@@ -466,7 +521,9 @@ I used ideas and snippets from other responder bots and want to call them out!
- **Josh**: For more bashing on installer!
- **dj505**: trying it on windows!
- **mikecarper**: ideas, and testing. hamtest
- **Cisien, bitflip, **Woof**, **propstg**, **trs2982**, **Josh** and Hailo1999**: For testing and feature ideas on Discord and GitHub.
- **c.merphy360**: high altitude alerts
- **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

@@ -36,6 +36,10 @@ ignoreDefaultChannel = False
ignoreChannels =
# require ! to be the first character in a command
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!
@@ -56,10 +60,15 @@ wikipedia = True
# Enable ollama LLM see more at https://ollama.com
ollama = False
# Ollama model to use (defaults to gemma2:2b)
# ollamaModel = llama3.1
# Ollama model to use (defaults to gemma3:270m)
# ollamaModel = gemma3:latest
# server instance to use (defaults to local machine install)
ollamaHostName = http://localhost:11434
# Produce LLM replies to messages that aren't commands?
# If False, the LLM only replies to the "ask:" and "askai" commands.
llmReplyToNonCommands = True
# if True, the input is sent raw to the LLM, if False uses legacy template query
rawLLMQuery = True
# StoreForward Enabled and Limits
StoreForward = True
@@ -84,6 +93,14 @@ sysloglevel = DEBUG
# Number of log files to keep in days, 0 to keep all
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
@@ -97,12 +114,27 @@ 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 =
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 =
[bbs]
enabled = True
@@ -113,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]
@@ -124,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
@@ -135,11 +169,24 @@ NOAAalertCount = 2
# use Open-Meteo API for weather data not NOAA useful for non US locations
UseMeteoWxAPI = False
# NOAA Hydrology unique identifiers, LID or USGS ID
riverListDefault =
# NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
coastalEnabled = False
# Find the correct costal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/
# pz = Puget Sound, ph = Honolulu HI, gm = Florida Keys, pk = Alaska
# this map can help https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map'
# myCoastalZone is the .txt file with the forecast data
myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt
# number of data points to return, default is 3
coastalForecastDays = 3
# NOAA USGS Hydrology river identifiers, LID or USGS ID https://waterdata.usgs.gov
riverList =
# NOAA EAS Alert Broadcast
wxAlertBroadcastEnabled = False
# Enable Ignore any message that includes following word list
ignoreEASenable = False
ignoreEASwords = test,advisory
# EAS Alert Broadcast Channels
wxAlertBroadcastCh = 2
# Add extra location to the weather alert
@@ -147,20 +194,22 @@ enableExtraLocationWx = False
# Goverment Alert Broadcast defaults to FEMA IPAWS
eAlertBroadcastEnabled = False
# comma separated list of FIPS codes to trigger local alert. find your FIPS codes at https://en.wikipedia.org/wiki/Federal_Information_Processing_Standard_state_code
myFIPSList = 57,58,53
# find your SAME https://www.weather.gov/nwr/counties comma separated list of SAME code to further refine local alert.
mySAMEList = 053029,053073
# Goverment Alert Broadcast Channels
eAlertBroadcastCh = 2
# FEMA Alert Broadcast Settings
# Enable Ignore any headline that includes followig word list
# Enable Ignore, headline that includes following word list
ignoreFEMAenable = True
ignoreFEMAwords = test,exercise
# comma separated list of codes (e.g., SAME,FIPS,ZIP) trigger local alert.
# find your SAME https://www.weather.gov/nwr/counties
mySAME = 053029,053073
# USGS Volcano alerts Enable USGS Volcano Alert Broadcast
volcanoAlertBroadcastEnabled = False
volcanoAlertBroadcastCh = 2
# Enable Ignore any message that includes following word list
ignoreUSGSEnable = False
ignoreUSGSWords = test,advisory
# Use DE Alert Broadcast Data
enableDEalerts = False
@@ -184,7 +233,7 @@ reverse_in_out = False
# QRZ Hello to new nodes with message
enabled = False
qrz_db = data/qrz.db
qrz_hello_string = "send CMD or DM me for more info."
qrz_hello_string = "MeshBot says Hello! DM for more info."
# Training mode will not send the hello message to new nodes
training = True
@@ -200,6 +249,17 @@ repeater_channels =
[scheduler]
# enable or disable the scheduler module
enabled = False
# interface to send the message to
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 =
[radioMon]
# using Hamlib rig control will monitor and alert on channel use
@@ -217,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
@@ -255,6 +323,7 @@ IMAP_FOLDER = inbox
[games]
# if hop limit for the user exceeds this value, the message will be dropped
game_hop_limit = 5
disable_emojis = False
# enable or disable the games module(s)
dopeWars = True
lemonade = True
@@ -264,17 +333,26 @@ mastermind = True
golfsim = True
hangman = True
hamtest = True
tictactoe = True
[messagingSettings]
# delay in seconds for response to avoid message collision
responseDelay = 1.2
# delay in seconds for splits in messages to avoid message collision
splitDelay = 0.0
# delay in seconds for response to avoid message collision /throttling
responseDelay = 2.2
# delay in seconds for splits in messages to avoid message collision /throttling
splitDelay = 2.5
# message chunk size for sending at high success rate, chunkr allows exceeding by 3 characters
MESSAGE_CHUNK_SIZE = 160
# Request Acknowledgement of message OTA
wantAck = False
# Max limit buffer for radio testing. 233 is hard limit 2.5+ firmware
# Max limit buffer for radio testing
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

@@ -350,7 +350,8 @@ def get_database_info():
os.path.join(base_dir, 'mmind_hs.pkl'),
os.path.join(base_dir, 'golfsim_hs.pkl'),
os.path.join(base_dir, 'bbsdb.pkl'),
os.path.join(base_dir, 'bbsdm.pkl')]
os.path.join(base_dir, 'bbsdm.pkl'),
os.path.join(base_dir, 'qrz.db')]
for file in databaseFiles:
try:
@@ -371,6 +372,16 @@ def get_database_info():
bbsdb = pickle.load(f)
elif 'bbsdm' in file:
bbsdm = pickle.load(f)
elif 'qrz.db' in file:
# open the qrz.db sqllite file
import sqlite3
conn = sqlite3.connect(file)
cursor = conn.cursor()
cursor.execute("SELECT * FROM qrz")
qrz_db = cursor.fetchall()
# convert to a list of strings
qrz_db = [f"{row[0]}: {row[1]} {row[2]}" for row in qrz_db]
conn.close()
except Exception as e:
print(f"Warning issue reading database file: {str(e)}")
if 'lemonstand' in file:
@@ -425,7 +436,8 @@ def get_database_info():
'golfsim_score': golfsim_score,
'banList': banList,
'adminList': adminList,
'sentryIgnoreList': sentryIgnoreList
'sentryIgnoreList': sentryIgnoreList,
'qrz_db': qrz_db if 'qrz_db' in locals() else "no data"
}
def generate_main_html(log_data, system_info):
@@ -913,6 +925,11 @@ def generate_database_html(database_info):
<tr><td>Mastermind</td><td>${mmind_score}</td></tr>
<tr><td>Golf Simulator</td><td>${golfsim_score}</td></tr>
</table>
<h1>QRZ Database</h1>
<p>QRZ Database holds heard nodeID and Shortname</p>
<table>
<tr><td>${qrz_db}</td></tr>
</table>
</body>
</html>
"""

View File

@@ -359,7 +359,8 @@ def get_database_info():
os.path.join(base_dir, 'mmind_hs.pkl'),
os.path.join(base_dir, 'golfsim_hs.pkl'),
os.path.join(base_dir, 'bbsdb.pkl'),
os.path.join(base_dir, 'bbsdm.pkl')]
os.path.join(base_dir, 'bbsdm.pkl'),
os.path.join(base_dir, 'qrz.db')]
for file in databaseFiles:
try:
@@ -380,6 +381,16 @@ def get_database_info():
bbsdb = pickle.load(f)
elif 'bbsdm' in file:
bbsdm = pickle.load(f)
elif 'qrz.db' in file:
#open the qrz.db sqllite file
import sqlite3
conn = sqlite3.connect(file)
cursor = conn.cursor()
cursor.execute("SELECT * FROM qrz")
qrz_db = cursor.fetchall()
# convert to a list of strings
qrz_db = [f"{row[0]}: {row[1]} {row[2]}" for row in qrz_db]
conn.close()
except Exception as e:
print(f"Warning issue reading database file: {str(e)}")
if 'lemonstand' in file:
@@ -434,7 +445,8 @@ def get_database_info():
'golfsim_score': golfsim_score,
'banList': banList,
'adminList': adminList,
'sentryIgnoreList': sentryIgnoreList
'sentryIgnoreList': sentryIgnoreList,
'qrz_db': qrz_db if 'qrz_db' in locals() else "no data"
}
def generate_main_html(log_data, system_info):
@@ -1207,6 +1219,11 @@ def generate_database_html(database_info):
<tr><td>Mastermind</td><td>${mmind_score}</td></tr>
<tr><td>Golf Simulator</td><td>${golfsim_score}</td></tr>
</table>
<h1>QRZ Database</h1>
<p>QRZ Database holds heard nodeID and Shortname</p>
<table>
<tr><td>${qrz_db}</td></tr>
</table>
</body>
</html>
"""

View File

@@ -4,6 +4,7 @@
# install.sh
cd "$(dirname "$0")"
program_path=$(pwd)
chronjob="0 1 * * * /usr/bin/python3 $program_path/etc/report_generator5.py"
printf "\n########################"
printf "\nMeshing Around Installer\n"
printf "########################\n"
@@ -193,17 +194,19 @@ if [[ $(echo "${meshbotservice}" | grep -i "^y") ]] || [[ $(echo "${embedded}" |
sudo usermod -a -G meshbot meshbot
whoami="meshbot"
echo "Added user meshbot with no home directory"
sudo usermod -a -G dialout $whoami
sudo usermod -a -G tty $whoami
sudo usermod -a -G bluetooth $whoami
echo "Added meshbot to dialout, tty, and bluetooth groups"
sudo chown -R $whoami:$whoami $program_path/logs
sudo chown -R $whoami:$whoami $program_path/data
echo "Permissions set for meshbot on logs and data directories"
else
whoami=$(whoami)
fi
# set basic permissions for the bot user
sudo usermod -a -G dialout $whoami
sudo usermod -a -G tty $whoami
sudo usermod -a -G bluetooth $whoami
echo "Added user $whoami to dialout, tty, and bluetooth groups"
sudo chown -R $whoami:$whoami $program_path/logs
sudo chown -R $whoami:$whoami $program_path/data
echo "Permissions set for meshbot on logs and data directories"
# set the correct user in the service file
replace="s|User=pi|User=$whoami|g"
sed -i $replace etc/pong_bot.service
@@ -245,22 +248,40 @@ 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 gemma2:2b\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
if [[ $(echo "${ollama}" | grep -i "^y") ]]; then
curl -fsSL https://ollama.com/install.sh | sh
# ask if want to install gemma2:2b
printf "\n Ollama install done now we can install the Gemma2:2b components\n"
echo "Do you want to install the Gemma2:2b components? (y/n)"
# ask if want to install gemma3:latest
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 gemma2:2b
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
@@ -276,6 +297,8 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
printf "sudo journalctl -u %s.service\n" "$service" >> install_notes.txt
printf "sudo systemctl stop %s.service\n" "$service" >> install_notes.txt
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
printf "Reporting chron job added to run report_generator5.py\n" >> install_notes.txt
printf "chronjob: %s\n" "$chronjob" >> install_notes.txt
if [[ $(echo "${venv}" | grep -i "^y") ]]; then
printf "\nFor running on venv, virtual launch bot with './launch.sh mesh' in path $program_path\n" >> install_notes.txt
@@ -305,6 +328,14 @@ else
sudo systemctl daemon-reload
sudo systemctl enable $service.service
sudo systemctl start $service.service
# check if the cron job already exists
if ! crontab -l | grep -q "$chronjob"; then
# add the cron job to run the report_generator5.py script
(crontab -l 2>/dev/null; echo "$chronjob") | crontab -
printf "\nAdded cron job to run report_generator5.py\n"
else
printf "\nCron job already exists, skipping\n"
fi
printf "Reference following commands:\n\n" "$service" > install_notes.txt
printf "sudo systemctl status %s.service\n" "$service" >> install_notes.txt
printf "sudo systemctl start %s.service\n" "$service" >> install_notes.txt
@@ -328,7 +359,6 @@ exit 0
# sudo systemctl stop mesh_bot_reporting
# sudo systemctl disable mesh_bot_reporting
# sudo rm /etc/systemd/system/mesh_bot.service
# sudo rm /etc/systemd/system/mesh_bot_reporting.service
# sudo rm /etc/systemd/system/mesh_bot_w3.service
# sudo rm /etc/systemd/system/pong_bot.service
# sudo systemctl daemon-reload
@@ -344,5 +374,5 @@ exit 0
# after install shenannigans
# add 'bee = True' to config.ini General section. You will likley want to clean the txt up a bit
# wget https://courses.cs.washington.edu/courses/cse163/20wi/files/lectures/L04/bee-movie.txt -O bee.txt
# add 'bee = True' to config.ini General section.
# wget https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a -O bee.txt

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

@@ -24,4 +24,18 @@ log_backup_count = 32
## Web Reporting WebServer
There is a web-server module. You can run `python3 modules/web.py` from the project root directory and it will serve up the web content.
find it at. http://localhost:8420
find it at. http://localhost:8420
If you have linux-native running and errors such as..
```bash
File "/usr/lib/python3.11/http/server.py", line 136, in server_bind
socketserver.TCPServer.server_bind(self)
File "/usr/lib/python3.11/socketserver.py", line 472, in server_bind
self.socket.bind(self.server_address)
```
modify the modules/web.py to use a real IP address, meshtasticD-native is binding to 127.0.0.1
```python
# Set the desired IP address
server_ip = '127.0.0.1'
```

View File

@@ -15,15 +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
DEBUGhops = False # Debug print hop info and bad hop count packets
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."
@@ -45,6 +43,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"checkin": lambda: handle_checklist(message, message_from_id, deviceID),
"checklist": lambda: handle_checklist(message, message_from_id, deviceID),
"checkout": lambda: handle_checklist(message, message_from_id, deviceID),
"chess": lambda: handle_gTnW(chess=True),
"clearsms": lambda: handle_sms(message_from_id, message),
"cmd": lambda: handle_cmd(message, message_from_id, deviceID),
"cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
@@ -52,7 +51,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(),
@@ -61,6 +62,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),
@@ -68,6 +71,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
"messages": lambda: handle_messages(message, deviceID, channel_number, msg_history, publicChannel, isDM),
"moon": lambda: handle_moon(message_from_id, deviceID, channel_number),
"motd": lambda: handle_motd(message, message_from_id, isDM),
"mwx": lambda: handle_mwx(message_from_id, deviceID, channel_number),
"ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"pinging": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number),
"pong": lambda: "🏓PING!!🛜",
@@ -84,6 +88,8 @@ 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),
"tic-tac-toe": lambda: handleTicTacToe(message, message_from_id, deviceID),
"tide": lambda: handle_tide(message_from_id, deviceID, channel_number),
"valert": lambda: get_volcano_usgs(),
"videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID),
@@ -91,10 +97,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 +301,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 +331,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
@@ -462,12 +545,17 @@ def handleDopeWars(message, nodeID, rxNode):
time.sleep(responseDelay + 1)
return msg
def handle_gTnW():
def handle_gTnW(chess = False):
chess = ["How about a nice game of chess?", "Shall we play a game of chess?", "Would you like to play a game of chess?", "f3, to e5, g4??"]
response = ["The only winning move is not to play.", "What are you doing, Dave?",\
"Greetings, Professor Falken.", "Shall we play a game?", "How about a nice game of chess?",\
"You are a hard man to reach. Could not find you in Seattle and no terminal is in operation at your classified address.",\
"I should reach Defcon 1 and release my missiles in 28 hours.","T-minus thirty","Malfunction 54: Treatment pause;dose input 2", "reticulating splines"]
length = len(response)
chess_length = len(chess)
if chess:
response = chess
length = chess_length
indices = list(range(length))
# Shuffle the indices using a convoluted method
for i in range(length):
@@ -728,29 +816,59 @@ 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'\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):
# NOAA Coastal and Marine Weather
if myCoastalZone is None:
logger.warning("System: Coastal Zone not set, please set in config.ini")
return NO_ALERTS
return get_nws_marine(zone=myCoastalZone, days=coastalForecastDays)
def handle_wxc(message_from_id, deviceID, cmd):
location = get_node_location(message_from_id, deviceID)
@@ -779,6 +897,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)
@@ -805,7 +928,7 @@ def handle_bbspost(message, message_from_id, deviceID):
toNode = int(toNode.strip("!"),16)
except ValueError as e:
toNode = 0
elif toNode.isalpha() or not toNode.isnumeric():
elif toNode.isalpha() or not toNode.isnumeric() or len(toNode) < 5:
# try short name
toNode = get_num_from_short_name(toNode, deviceID)
@@ -838,14 +961,19 @@ def handle_messages(message, deviceID, channel_number, msg_history, publicChanne
return message.split("?")[0].title() + " command returns the last " + str(storeFlimit) + " messages sent on a channel."
else:
response = ""
for msgH in msg_history:
# Reverse the message history to show most recent first
for msgH in reversed(msg_history):
# number of messages to return +1 for the header line
if len(response.split("\n")) >= storeFlimit + 1:
break
# if the message is for this deviceID and channel or publicChannel
if msgH[4] == deviceID:
if msgH[2] == channel_number or msgH[2] == publicChannel:
response += f"\n{msgH[0]}: {msgH[1]}"
if len(response) > 0:
return "Message History:" + response
return "📨Messages:" + response
else:
return "No messages in history"
return "No 📭messages in history"
def handle_sun(message_from_id, deviceID, channel_number):
location = get_node_location(message_from_id, deviceID, channel_number)
@@ -904,7 +1032,7 @@ def handle_history(message, nodeid, deviceID, isDM, lheard=False):
prettyTime = getPrettyTime(cmdTime)
# history display output
if nodeid in bbs_admin_list and cmdHistory[i]['nodeID'] not in lheardCmdIgnoreNode:
if str(nodeid) in bbs_admin_list and cmdHistory[i]['nodeID'] not in lheardCmdIgnoreNode:
buffer.append((get_name_from_number(cmdHistory[i]['nodeID'], 'short', deviceID), cmdHistory[i]['cmd'], prettyTime))
elif cmdHistory[i]['nodeID'] == nodeid and cmdHistory[i]['nodeID'] not in lheardCmdIgnoreNode:
buffer.append((get_name_from_number(nodeid, 'short', deviceID), cmdHistory[i]['cmd'], prettyTime))
@@ -963,7 +1091,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 = []
@@ -1060,6 +1187,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]
@@ -1071,7 +1199,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
@@ -1105,19 +1233,18 @@ def onReceive(packet, interface):
elif multiple_interface and port7 in rxInterface: rxNode = 7
elif multiple_interface and port8 in rxInterface: rxNode = 8
elif multiple_interface and port9 in rxInterface: rxNode = 9
if rxType == 'TCPInterface':
rxHost = interface.__dict__.get('hostname', 'unknown')
if hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1
elif multiple_interface and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2
elif multiple_interface and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3
elif multiple_interface and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4
elif multiple_interface and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5
elif multiple_interface and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6
elif multiple_interface and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7
elif multiple_interface and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8
elif multiple_interface and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9
if rxHost and hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1
elif multiple_interface and rxHost and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2
elif multiple_interface and rxHost and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3
elif multiple_interface and rxHost and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4
elif multiple_interface and rxHost and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5
elif multiple_interface and rxHost and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6
elif multiple_interface and rxHost and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7
elif multiple_interface and rxHost and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8
elif multiple_interface and rxHost and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9
if rxType == 'BLEInterface':
if interface1_type == 'ble': rxNode = 1
elif multiple_interface and interface2_type == 'ble': rxNode = 2
@@ -1147,20 +1274,22 @@ 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':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
via_mqtt = packet['decoded'].get('viaMqtt', False)
rx_time = packet['decoded'].get('rxTime', time.time())
# check if the packet is from us
if message_from_id in [myNodeNum1, myNodeNum2, myNodeNum3, myNodeNum4, myNodeNum5, myNodeNum6, myNodeNum7, myNodeNum8, myNodeNum9]:
logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay deteted")
logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay detected")
# get the signal strength and snr if available
if packet.get('rxSnr') or packet.get('rxRssi'):
@@ -1194,15 +1323,17 @@ def onReceive(packet, interface):
else:
hop_start = 0
if DEBUGhops:
if enableHopLogs:
logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start}")
if hop_away == 0 and hop_limit == 0 and hop_start == 0:
logger.debug(f"System: Packet HopDebugger: No hop count found in PACKET {packet} END PACKET")
if hop_away == 0 and hop_limit == 0 and hop_start == 0:
hop = "Last Hop"
hop_count = 0
if hop_start == hop_limit:
hop = "Direct"
hop_count = 0
elif hop_start == 0 and hop_limit > 0:
elif hop_start == 0 and hop_limit > 0 or via_mqtt:
hop = "MQTT"
hop_count = 0
else:
@@ -1226,7 +1357,7 @@ def onReceive(packet, interface):
isDM = True
# check if the message contains a trap word, DMs are always responded to
if (messageTrap(message_string) and not llm_enabled) or messageTrap(message_string.split()[0]):
# log the message to the message log
# log the message to stdout
logger.info(f"Device:{rxNode} Channel: {channel_number} " + CustomFormatter.green + f"Received DM: " + CustomFormatter.white + f"{message_string} " + CustomFormatter.purple +\
"From: " + CustomFormatter.white + f"{get_name_from_number(message_from_id, 'long', rxNode)}")
# respond with DM
@@ -1244,7 +1375,7 @@ def onReceive(packet, interface):
playingGame = False
if not playingGame:
if llm_enabled:
if llm_enabled and llmReplyToNonCommands:
# respond with LLM
llm = handle_llm(message_from_id, channel_number, rxNode, message_string, publicChannel)
send_message(llm, channel_number, message_from_id, rxNode)
@@ -1273,7 +1404,8 @@ def onReceive(packet, interface):
time.sleep(responseDelay)
# log the message to the message log
msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | " + message_string.replace('\n', '-nl-'))
if log_messages_to_file:
msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | DM | " + message_string.replace('\n', '-nl-'))
else:
# message is on a channel
if messageTrap(message_string):
@@ -1312,12 +1444,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 +\
@@ -1355,7 +1489,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}")
@@ -1399,6 +1533,8 @@ async def start_rx():
logger.debug("System: Location Telemetry Enabled using NOAA API")
if dad_jokes_enabled:
logger.debug("System: Dad Jokes Enabled!")
if coastalEnabled:
logger.debug("System: Coastal Forcast and Tide Enabled!")
if games_enabled:
logger.debug("System: Games Enabled!")
if wikipedia_enabled:
@@ -1407,18 +1543,24 @@ async def start_rx():
logger.debug(f"System: MOTD Enabled using {MOTD}")
if sentry_enabled:
logger.debug(f"System: Sentry Mode Enabled {sentry_radius}m radius reporting to channel:{secure_channel}")
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:
logger.debug(f"System: Radio Detection Enabled using rigctld at {rigControlServerAddress} brodcasting to channels: {sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}")
if file_monitor_enabled:
logger.debug(f"System: File Monitor Enabled for {file_monitor_file_path}, broadcasting to channels: {file_monitor_broadcastCh}")
logger.warning(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:
@@ -1426,7 +1568,10 @@ async def start_rx():
if wxAlertBroadcastEnabled:
logger.debug(f"System: Weather Alert Broadcast Enabled on channels {wxAlertBroadcastChannel}")
if emergencyAlertBrodcastEnabled:
logger.debug(f"System: Emergency Alert Broadcast Enabled on channels {emergencyAlertBroadcastCh}")
logger.debug(f"System: Emergency Alert Broadcast Enabled on channels {emergencyAlertBroadcastCh} for FIPS codes {myStateFIPSList}")
# check if the FIPS codes are set
if myStateFIPSList == ['']:
logger.warning(f"System: No FIPS codes set for iPAWS Alerts")
if emergency_responder_enabled:
logger.debug(f"System: Emergency Responder Enabled on channels {emergency_responder_alert_channel} for interface {emergency_responder_alert_interface}")
if volcanoAlertBroadcastEnabled:
@@ -1439,17 +1584,59 @@ 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")
else:
logger.debug(f"System: SMTP Email Alerting Enabled")
if scheduler_enabled:
# Examples of using the scheduler, Times here are in 24hr format
# https://schedule.readthedocs.io/en/stable/
# Reminder Scheduler is enabled every Monday at noon send a log message
schedule.every().monday.at("12:00").do(lambda: logger.info("System: Scheduled Broadcast Reminder"))
schedule.every().monday.at("12:00").do(lambda: logger.info("System: Scheduled Broadcast Enabled Reminder"))
# basic scheduler
if schedulerValue != '':
logger.debug(f"System: Starting the broadcast scheduler from config.ini")
if schedulerValue.lower() == 'day':
if schedulerTime != '':
# Send a message every day at the time set in schedulerTime
schedule.every().day.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
else:
# Send a message every day at the time set in schedulerInterval
schedule.every(int(schedulerInterval)).days.do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'mon' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Monday at the time set in schedulerTime
schedule.every().monday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'tue' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Tuesday at the time set in schedulerTime
schedule.every().tuesday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'wed' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Wednesday at the time set in schedulerTime
schedule.every().wednesday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'thu' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Thursday at the time set in schedulerTime
schedule.every().thursday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'fri' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Friday at the time set in schedulerTime
schedule.every().friday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'sat' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Saturday at the time set in schedulerTime
schedule.every().saturday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'sun' in schedulerValue.lower() and schedulerTime != '':
# Send a message every Sunday at the time set in schedulerTime
schedule.every().sunday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'hour' in schedulerValue.lower():
# Send a message every hour at the time set in schedulerTime
schedule.every(int(schedulerInterval)).hours.do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
elif 'min' in schedulerValue.lower():
# Send a message every minute at the time set in schedulerTime
schedule.every(int(schedulerInterval)).minutes.do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface))
else:
logger.debug(f"System: Starting the broadcast scheduler")
# Enhanced Examples of using the scheduler, Times here are in 24hr format
# https://schedule.readthedocs.io/en/stable/
# Good Morning Every day at 09:00 using send_message function to channel 2 on device 1
#schedule.every().day.at("09:00").do(lambda: send_message("Good Morning", 2, 0, 1))
@@ -1483,8 +1670,6 @@ async def start_rx():
# Send bbslink looking for peers every other day at 10:00 using send_message function to channel 3 on device 1
#schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", 3, 0, 1))
logger.debug("System: Starting the broadcast scheduler")
await BroadcastScheduler()
# here we go loopty loo
@@ -1494,25 +1679,52 @@ 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.debug(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.debug("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)
try:
if __name__ == "__main__":
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
exit_handler()
pass
except KeyboardInterrupt:
exit_handler()
except SystemExit:
pass
# EOF

View File

@@ -39,4 +39,16 @@ To help with code testing see `etc/simulator.py` to simulate a bot. I also enjoy
```
5. **Test the New Command**:
Run MeshBot and test the new command by sending a message with the command `newcommand` to ensure it responds correctly.
Run MeshBot and test the new command by sending a message with the command `newcommand` to ensure it responds correctly.
### Running a Shell command
Using the above example and enabling the filemon module, you can make a command which calls a bash file to do things on the system.
```python
def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM):
#...
"switchON": lambda: call_external_script(message)
```
This would call the default script located in script/runShell.sh and return its output.

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",)
@@ -31,7 +32,7 @@ def read_file(file_monitor_file_path, random_line_only=False):
def read_news():
# read the news file on demand
return read_file(news_file_path, read_news_enabled)
return read_file(news_file_path, news_random_line_only)
def write_news(content, append=False):
@@ -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)

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

@@ -0,0 +1,250 @@
# Tic-Tac-Toe game for Meshtastic mesh-bot
# Board positions chosen by numbers 1-9
# 2025
from modules.log import *
import random
# to molly and jake, I miss you both so much.
if disable_emojis_in_games:
X = "X"
O = "O"
else:
X = ""
O = "⭕️"
class TicTacToe:
def __init__(self):
self.game = {}
def new_game(self, id):
positiveThoughts = ["🚀I need to call NATO",
"🏅Going for the gold!",
"Mastering ❌TTT⭕",]
sorryNotGoinWell = ["😭Not your day, huh?",
"📉Results here dont define you.",
"🤖WOPR would be proud."]
"""Start a new game"""
games = won = 0
ret = ""
if id in self.game:
games = self.game[id]["games"]
won = self.game[id]["won"]
if games > 0:
if won / games >= 3.14159265358979323846: # win rate > pi
ret += random.choice(positiveThoughts) + "\n"
else:
ret += random.choice(sorryNotGoinWell) + "\n"
# Retain stats
ret += f"Games:{games} 🥇❌:{won}\n"
self.game[id] = {
"board": [" "] * 9, # 3x3 board as flat list
"player": X, # Human is X, bot is O
"games": games + 1,
"won": won,
"turn": "human" # whose turn it is
}
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
if disable_emojis_in_games:
cell = b[pos] if b[pos] != " " else str(pos + 1)
else:
cell = b[pos] if b[pos] != " " else f" {str(pos + 1)} "
row += cell
if j < 2:
row += " | "
board_str += row
if i < 2:
#board_str += "\n-+-+-\n"
board_str += "\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 == X:
return "🤖Bot wins! (n)ew (e)nd"
else:
return "🤝Tie, The only winning move! (n)ew (e)nd"
def play(self, id, input_msg):
"""Main game play function"""
if id not in self.game:
return self.new_game(id)
# If input is just "tictactoe", show current board
if input_msg.lower().strip() == ("tictactoe" or "tic-tac-toe"):
return self.show_board(id) + "Your turn! Pick 1-9:"
g = self.game[id]
# Parse player move
try:
# Extract just the number from the input
numbers = [char for char in input_msg if char.isdigit()]
if not numbers:
if input_msg.lower().startswith('q'):
self.end_game(id)
return "Game ended. To start a new game, type 'tictactoe'."
elif input_msg.lower().startswith('n'):
return self.new_game(id)
elif input_msg.lower().startswith('b'):
return self.show_board(id) + "Your turn! Pick 1-9:"
position = int(numbers[0])
except (ValueError, IndexError):
return "Enter 1-9, or (e)nd (n)ew game, send (b)oard to see board🧩"
# Make player move
if not self.make_move(id, position):
return "Invalid move! Pick 1-9:"
# Check if player won
if self.check_winner(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Check for tie
if self.is_board_full(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Bot's turn
bot_pos = self.bot_move(id)
# Check if bot won
if self.check_winner(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Check for tie after bot move
if self.is_board_full(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Continue game
return self.show_board(id) + "Your turn! Pick 1-9:"
def end_game(self, id):
"""Clean up finished game but keep stats"""
if id in self.game:
games = self.game[id]["games"]
won = self.game[id]["won"]
# Remove game but we'll create new one on next play
del self.game[id]
# Preserve stats for next game
self.game[id] = {
"board": [" "] * 9,
"player": X,
"games": games,
"won": won,
"turn": "human"
}
def end(self, id):
"""End game completely (called by 'end' command)"""
if id in self.game:
del self.game[id]
# Global instances for the bot system
tictactoeTracker = []
tictactoe = TicTacToe()

View File

@@ -164,7 +164,7 @@ class PlayerVP:
return self.show_hand()
except Exception as e:
pass
return "ex:1,3,4 deals them new, and keeps 2,5 or (N)o to keep current (H)and"
# Method for scoring hand, calculating winnings, and outputting message
@@ -390,7 +390,7 @@ def playVideoPoker(nodeID, message):
else:
if drawCount <= 1:
msg = player.redraw(deck, message)
if msg.startswith("Send Card"):
if msg.startswith("ex:"):
# if returned error message, return it
return msg
drawCount += 1
@@ -403,7 +403,7 @@ def playVideoPoker(nodeID, message):
if drawCount == 2:
# this is the last draw will carry on to endGame for scoring
msg = player.redraw(deck, message) + f"\n"
if msg.startswith("Send Card"):
if msg.startswith("ex:"):
# if returned error message, return it
return msg
# redraw done

View File

@@ -8,32 +8,34 @@ from modules.log import *
# https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server
import requests
import json
from googlesearch import search # pip install googlesearch-python
# This is my attempt at a simple RAG implementation it will require some setup
# you will need to have the RAG data in a folder named rag in the data directory (../data/rag)
# This is lighter weight and can be used in a standalone environment, needs chromadb
# "chat with a file" is the use concept here, the file is the RAG data
# is anyone using this please let me know if you are Dec62024 -kelly
ragDEV = False
if ragDEV:
import os
import ollama # pip install ollama
import chromadb # pip install chromadb
from ollama import Client as OllamaClient
ollamaClient = OllamaClient(host=ollamaHostName)
if not rawLLMQuery:
# this may be removed in the future
from googlesearch import search # pip install googlesearch-python
# LLM System Variables
ollamaAPI = ollamaHostName + "/api/generate"
tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts
requestTruncation = True # if True, the LLM "will" truncate the response
openaiAPI = "https://api.openai.com/v1/completions" # not used, if you do push a enhancement!
# Used in the meshBotAI template
llmEnableHistory = True # enable last message history for the LLM model
llmContext_fromGoogle = True # enable context from google search results adds to compute time but really helps with responses accuracy
googleSearchResults = 3 # number of google search results to include in the context more results = more compute time
antiFloodLLM = []
llmChat_history = {}
trap_list_llm = ("ask:", "askai")
meshbotAIinit = """
keep responses as short as possible. chatbot assistant no followuyp questions, no asking for clarification.
You must respond in plain text standard ASCII characters or emojis.
"""
truncatePrompt = f"truncate this as short as possible:\n"
meshBotAI = """
FROM {llmModel}
SYSTEM
@@ -74,78 +76,24 @@ if llmEnableHistory:
"""
def llm_readTextFiles():
# read .txt files in ../data/rag
try:
text = []
directory = "../data/rag"
for filename in os.listdir(directory):
if filename.endswith(".txt"):
filepath = os.path.join(directory, filename)
with open(filepath, 'r') as f:
text.append(f.read())
return text
except Exception as e:
logger.debug(f"System: LLM readTextFiles: {e}")
return False
def store_text_embedding(text):
try:
# store each document in a vector embedding database
for i, d in enumerate(text):
response = ollama.embeddings(model="mxbai-embed-large", prompt=d)
embedding = response["embedding"]
collection.add(
ids=[str(i)],
embeddings=[embedding],
documents=[d]
)
except Exception as e:
logger.debug(f"System: Embedding failed: {e}")
return False
## INITALIZATION of RAG
if ragDEV:
try:
chromaHostname = "localhost:8000"
# connect to the chromaDB
chromaHost = chromaHostname.split(":")[0]
chromaPort = chromaHostname.split(":")[1]
if chromaHost == "localhost" and chromaPort == "8000":
# create a client using local python Client
chromaClient = chromadb.Client()
else:
# create a client using the remote python Client
# this isnt tested yet please test and report back
chromaClient = chromadb.Client(host=chromaHost, port=chromaPort)
clearCollection = False
if "meshBotAI" in chromaClient.list_collections() and clearCollection:
logger.debug(f"System: LLM: Clearing RAG files from chromaDB")
chromaClient.delete_collection("meshBotAI")
# create a new collection
collection = chromaClient.create_collection("meshBotAI")
logger.debug(f"System: LLM: Cataloging RAG data")
store_text_embedding(llm_readTextFiles())
except Exception as e:
logger.debug(f"System: LLM: RAG Initalization failed: {e}")
def query_collection(prompt):
# generate an embedding for the prompt and retrieve the most relevant doc
response = ollama.embeddings(prompt=prompt, model="mxbai-embed-large")
results = collection.query(query_embeddings=[response["embedding"]], n_results=1)
data = results['documents'][0][0]
return data
def llm_query(input, nodeID=0, location_name=None):
global antiFloodLLM, llmChat_history
googleResults = []
# if this is the first initialization of the LLM the query of " " should bring meshbotAIinit OTA shouldnt reach this?
# This is for LLM like gemma and others now?
if input == " " and rawLLMQuery:
logger.warning("System: These LLM models lack a traditional system prompt, they can be verbose and not very helpful be advised.")
input = meshbotAIinit
if not location_name:
location_name = "no location provided "
# remove askai: and ask: from the input
for trap in trap_list_llm:
if input.lower().startswith(trap):
input = input[len(trap):].strip()
break
# add the naughty list here to stop the function before we continue
# add a list of allowed nodes only to use the function
@@ -156,7 +104,7 @@ def llm_query(input, nodeID=0, location_name=None):
else:
antiFloodLLM.append(nodeID)
if llmContext_fromGoogle:
if llmContext_fromGoogle and not rawLLMQuery:
# grab some context from the internet using google search hits (if available)
# localization details at https://pypi.org/project/googlesearch-python/
@@ -187,36 +135,29 @@ def llm_query(input, nodeID=0, location_name=None):
location_name += f" at the current time of {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}"
try:
# RAG context inclusion testing
ragContext = False
if ragDEV:
ragContext = query_collection(input)
if ragContext:
ragContextGooogle = ragContext + '\n'.join(googleResults)
# Build the query from the template
modelPrompt = meshBotAI.format(input=input, context=ragContext, location_name=location_name, llmModel=llmModel, history=history)
# Query the model with RAG context
result = ollamaClient.generate(model=llmModel, prompt=modelPrompt)
# Condense the result to just needed
if isinstance(result, dict):
result = result.get("response")
if rawLLMQuery:
# sanitize the input to remove tool call syntax
if '```' in input:
logger.warning("System: LLM Query: Code markdown detected, removing for raw query")
input = input.replace('```bash', '').replace('```python', '').replace('```', '')
modelPrompt = input
else:
# Build the query from the template
modelPrompt = meshBotAI.format(input=input, context='\n'.join(googleResults), location_name=location_name, llmModel=llmModel, history=history)
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False}
# Query the model via Ollama web API
result = requests.post(ollamaAPI, data=json.dumps(llmQuery))
# Condense the result to just needed
if result.status_code == 200:
result_json = result.json()
result = result_json.get("response", "")
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens}
# Query the model via Ollama web API
result = requests.post(ollamaAPI, data=json.dumps(llmQuery))
# Condense the result to just needed
if result.status_code == 200:
result_json = result.json()
result = result_json.get("response", "")
# deepseek-r1 has added <think> </think> tags to the response
if "<think>" in result:
result = result.split("</think>")[1]
else:
raise Exception(f"HTTP Error: {result.status_code}")
# deepseek-r1 has added <think> </think> tags to the response
if "<think>" in result:
result = result.split("</think>")[1]
else:
raise Exception(f"HTTP Error: {result.status_code}")
#logger.debug(f"System: LLM Response: " + result.strip().replace('\n', ' '))
except Exception as e:
@@ -225,6 +166,23 @@ def llm_query(input, nodeID=0, location_name=None):
# cleanup for message output
response = result.strip().replace('\n', ' ')
if rawLLMQuery and requestTruncation and len(response) > 450:
#retryy loop to truncate the response
logger.warning(f"System: LLM Query: Response exceeded {tokens} characters, requesting truncation")
truncateQuery = {"model": llmModel, "prompt": truncatePrompt + response, "stream": False, "max_tokens": tokens}
truncateResult = requests.post(ollamaAPI, data=json.dumps(truncateQuery))
if truncateResult.status_code == 200:
truncate_json = truncateResult.json()
result = truncate_json.get("response", "")
else:
#use the original result if truncation fails
logger.warning("System: LLM Query: Truncation failed, using original response")
# cleanup for message output
response = result.strip().replace('\n', ' ')
# done with the query, remove the user from the anti flood list
antiFloodLLM.remove(nodeID)

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", "tide", "wx", "wxc", "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]
@@ -397,6 +404,12 @@ def alertBrodcastNOAA():
elif currentAlert == NO_ALERTS:
wxAlertCacheNOAA = ""
return False
if ignoreEASenable:
# check if the alert is in the ignoreEAS list
for word in ignoreEASwords:
if word.lower() in currentAlert[0].lower():
logger.debug(f"Location:Ignoring NOAA Alert: {currentAlert[0]} containing {word}")
return False
# broadcast the alerts send to wxBrodcastCh
elif currentAlert[0] not in wxAlertCacheNOAA:
# Check if the current alert is not in the weather alert cache
@@ -447,7 +460,7 @@ def getActiveWeatherAlertsDetailNOAA(lat=0, lon=0):
alerts = alerts.split("\n***\n")[:numWxAlerts]
if alerts == "" or alerts == ['']:
return ERROR_FETCHING_DATA
return NO_ALERTS
# trim off last newline
if alerts[-1] == "\n":
@@ -461,12 +474,11 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False):
# get the latest IPAWS alert from FEMA
alert = ''
alerts = []
linked_data = ''
# set the API URL for IPAWS
namespace = "urn:oasis:names:tc:emergency:cap:1.2"
alert_url = "https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/feed"
if ipawsPIN != "000000":
alert_url += "?pin=" + ipawsPIN
# get the alerts from FEMA
try:
@@ -484,23 +496,49 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False):
# extract alerts from main feed
for entry in alertxml.getElementsByTagName("entry"):
link = entry.getElementsByTagName("link")[0].getAttribute("href")
## state FIPS
## This logic is being added to reduce load on FEMA server.
stateFips = None
for cat in entry.getElementsByTagName("category"):
if cat.getAttribute("label") == "statefips":
stateFips = cat.getAttribute("term")
break
if stateFips is None:
# no stateFIPS found — skip
continue
# check if it matches your list
if stateFips not in myStateFIPSList:
#logger.debug(f"Skipping FEMA record link {link} with stateFIPS code of: {stateFips} because it doesn't match our StateFIPSList {myStateFIPSList}")
continue # skip to next entry
try:
#pin check
if ipawsPIN != "000000":
link += "?pin=" + ipawsPIN
# get the linked alert data from FEMA
linked_data = requests.get(link, timeout=urlTimeoutSeconds)
if not linked_data.ok:
if not linked_data.ok or not linked_data.text.strip():
# if the linked data is not ok, skip this alert
#logger.warning(f"System: iPAWS Error fetching linked alert data from {link}")
continue
else:
linked_xml = xml.dom.minidom.parseString(linked_data.text)
# this alert is a full CAP alert
except (requests.exceptions.RequestException):
logger.warning(f"System: iPAWS Error fetching embedded alert data from {link}")
continue
# this alert is a full CAP alert
linked_xml = xml.dom.minidom.parseString(linked_data.text)
except xml.parsers.expat.ExpatError:
logger.warning(f"System: iPAWS Error parsing XML from {link}")
continue
except Exception as e:
logger.debug(f"System: iPAWS Error processing alert data from {link}: {e}")
continue
for info in linked_xml.getElementsByTagName("info"):
# only get en-US language alerts (alternative is es-US)
language_nodes = info.getElementsByTagName("language")
if not any(node.firstChild and node.firstChild.nodeValue.strip() == "en-US" for node in language_nodes):
continue # skip if not en-US
# extract values from XML
sameVal = "NONE"
geocode_value = "NONE"
@@ -518,27 +556,31 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False):
area_table = info.getElementsByTagName("area")[0]
areaDesc = area_table.getElementsByTagName("areaDesc")[0].childNodes[0].nodeValue
geocode_table = area_table.getElementsByTagName("geocode")[0]
geocode_type = geocode_table.getElementsByTagName("valueName")[0].childNodes[0].nodeValue
geocode_value = geocode_table.getElementsByTagName("value")[0].childNodes[0].nodeValue
if geocode_type == "SAME":
sameVal = geocode_value
except Exception as e:
logger.debug(f"System: iPAWS Error extracting alert data: {link}")
#print(f"DEBUG: {info.toprettyxml()}")
continue
# check if the alert is for the current location, if wanted keep alert
if (sameVal in mySAME) or (geocode_value in mySAME):
# check if the alert is for the SAME location, if wanted keep alert
if (sameVal in mySAMEList) or (geocode_value in mySAMEList) or mySAMEList == ['']:
# ignore the FEMA test alerts
if ignoreFEMAenable:
ignore_alert = False
for word in ignoreFEMAwords:
if word.lower() in headline.lower():
logger.debug(f"System: Ignoring FEMA Alert: {headline} containing {word} at {areaDesc}")
continue
logger.debug(f"System: Filtering FEMA Alert by WORD: {headline} containing {word} at {areaDesc}")
ignore_alert = True
break
if ignore_alert:
continue
# add to alerts list
# add to alert list
alerts.append({
'alertType': alertType,
'alertCode': alertCode,
@@ -548,10 +590,10 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False):
'geocode_value': geocode_value,
'description': description
})
# else:
# # these are discarded some day but logged for debugging currently
# logger.debug(f"Debug iPAWS: Type:{alertType} Code:{alertCode} Desc:{areaDesc} GeoType:{geocode_type} GeoVal:{geocode_value}, Headline:{headline}")
else:
logger.debug(f"System: iPAWS Alert not in SAME List: {sameVal} or {geocode_value} for {headline} at {areaDesc}")
continue
# return the numWxAlerts of alerts
if len(alerts) > 0:
for alertItem in alerts[:numWxAlerts]:
@@ -626,26 +668,36 @@ def get_volcano_usgs(lat=0, lon=0):
try:
volcano_data = requests.get(usgs_volcano_url, timeout=urlTimeoutSeconds)
if not volcano_data.ok:
logger.warning("System: USGS fetching volcano alerts from USGS")
logger.warning("System: Issue with fetching volcano alerts from USGS")
return ERROR_FETCHING_DATA
except (requests.exceptions.RequestException):
logger.warning("System: USGS fetching volcano alerts from USGS")
logger.warning("System: Issue with fetching volcano alerts from USGS")
return ERROR_FETCHING_DATA
volcano_json = volcano_data.json()
# extract alerts from main feed
for alert in volcano_json:
# check if the alert lat long is within the range of bot latitudeValue and longitudeValue
if (alert['latitude'] >= latitudeValue - 10 and alert['latitude'] <= latitudeValue + 10) and (alert['longitude'] >= longitudeValue - 10 and alert['longitude'] <= longitudeValue + 10):
volcano_name = alert['volcano_name_appended']
alert_level = alert['alert_level']
color_code = alert['color_code']
cap_severity = alert['cap_severity']
synopsis = alert['synopsis']
# format Alert
alerts += f"🌋🚨: {volcano_name}, {alert_level} {color_code}, {cap_severity}.\n{synopsis}\n"
else:
#logger.debug(f"System: USGS volcano alert not in range: {alert['volcano_name_appended']}")
continue
if volcano_json and isinstance(volcano_json, list):
for alert in volcano_json:
# check ignore list
if ignoreUSGSEnable:
for word in ignoreUSGSwords:
if word.lower() in alert['volcano_name_appended'].lower():
logger.debug(f"System: Ignoring USGS Alert: {alert['volcano_name_appended']} containing {word}")
continue
# check if the alert lat long is within the range of bot latitudeValue and longitudeValue
if (alert['latitude'] >= latitudeValue - 10 and alert['latitude'] <= latitudeValue + 10) and (alert['longitude'] >= longitudeValue - 10 and alert['longitude'] <= longitudeValue + 10):
volcano_name = alert['volcano_name_appended']
alert_level = alert['alert_level']
color_code = alert['color_code']
cap_severity = alert['cap_severity']
synopsis = alert['synopsis']
# format Alert
alerts += f"🌋🚨: {volcano_name}, {alert_level} {color_code}, {cap_severity}.\n{synopsis}\n"
else:
#logger.debug(f"System: USGS volcano alert not in range: {alert['volcano_name_appended']}")
continue
else:
logger.debug("Location:Error fetching volcano data from USGS")
return NO_ALERTS
if alerts == "":
return NO_ALERTS
# trim off last newline
@@ -655,4 +707,308 @@ def get_volcano_usgs(lat=0, lon=0):
alerts = abbreviate_noaa(alerts)
return alerts
def get_nws_marine(zone, days=3):
# forcast from NWS coastal products
try:
marine_pz_data = requests.get(zone, timeout=urlTimeoutSeconds)
if not marine_pz_data.ok:
logger.warning("Location:Error fetching NWS Marine PZ data")
return ERROR_FETCHING_DATA
except (requests.exceptions.RequestException):
logger.warning("Location:Error fetching NWS Marine PZ data")
return ERROR_FETCHING_DATA
marine_pz_data = marine_pz_data.text
#validate data
todayDate = today.strftime("%Y%m%d")
if marine_pz_data.startswith("Expires:"):
expires = marine_pz_data.split(";;")[0].split(":")[1]
expires_date = expires[:8]
if expires_date < todayDate:
logger.debug("Location: NWS Marine PZ data expired")
return ERROR_FETCHING_DATA
else:
logger.debug("Location: NWS Marine PZ data not valid")
return ERROR_FETCHING_DATA
# process the marine forecast data
marine_pzz_lines = marine_pz_data.split("\n")
marine_pz_report = ""
day_blocks = []
current_block = ""
in_forecast = False
for line in marine_pzz_lines:
if line.startswith(".") and "..." in line:
in_forecast = True
if current_block:
day_blocks.append(current_block.strip())
current_block = ""
current_block += line.strip() + " "
elif in_forecast and line.strip() != "":
current_block += line.strip() + " "
if current_block:
day_blocks.append(current_block.strip())
# Only keep up to pzDays blocks
for block in day_blocks[:days]:
marine_pz_report += block + "\n"
# remove last newline
if marine_pz_report.endswith("\n"):
marine_pz_report = marine_pz_report[:-1]
# remove NOAA EOF $$
if marine_pz_report.endswith("$$"):
marine_pz_report = marine_pz_report[:-2].strip()
# abbreviate the report
marine_pz_report = abbreviate_noaa(marine_pz_report)
if marine_pz_report == "":
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

@@ -83,18 +83,14 @@ if log_messages_to_file:
# Pretty Timestamp
def getPrettyTime(seconds):
# convert unix time to minutes, hours, or days, or years for simple display
designator = "s"
if seconds > 0:
seconds = round(seconds / 60)
designator = "m"
if seconds > 60:
seconds = round(seconds / 60)
designator = "h"
if seconds > 24:
seconds = round(seconds / 24)
designator = "d"
if seconds > 365:
seconds = round(seconds / 365)
designator = "y"
return str(seconds) + designator
# convert unix time to minutes, hours, days, or years for simple display
if seconds < 60:
return f"{int(seconds)}s"
elif seconds < 3600:
return f"{int(round(seconds / 60))}m"
elif seconds < 86400:
return f"{int(round(seconds / 3600))}h"
elif seconds < 31536000:
return f"{int(round(seconds / 86400))}d"
else:
return f"{int(round(seconds / 31536000))}y"

View File

@@ -21,8 +21,7 @@ ping_enabled = True # ping feature to respond to pings, ack's etc.
sitrep_enabled = True # sitrep feature to respond to sitreps
lastHamLibAlert = 0 # last alert from hamlib
lastFileAlert = 0 # last alert from file monitor
max_retry_count1 = 4 # max retry count for interface 1
max_retry_count2 = 4 # max retry count for interface 2
max_retry_count1 = max_retry_count2 = max_retry_count3 = max_retry_count4 = max_retry_count5 = max_retry_count6 = max_retry_count7 = max_retry_count8 = max_retry_count9 = 4 # default retry count for interfaces
retry_int1 = False
retry_int2 = False
wiki_return_limit = 3 # limit the number of sentences returned off the first paragraph first hit
@@ -38,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.")
@@ -198,6 +201,7 @@ try:
ignoreChannels = config['general'].get('ignoreChannels', '').split(',') # ignore these channels
ignoreDefaultChannel = config['general'].getboolean('ignoreDefaultChannel', False)
cmdBang = config['general'].getboolean('cmdBang', False) # default off
explicitCmd = config['general'].getboolean('explicitCmd', True) # default on
zuluTime = config['general'].getboolean('zuluTime', False) # aka 24 hour time
log_messages_to_file = config['general'].getboolean('LogMessagesToFile', False) # default off
log_backup_count = config['general'].getint('LogBackupCount', 32) # default 32 days
@@ -220,8 +224,15 @@ try:
solar_conditions_enabled = config['general'].getboolean('spaceWeather', True)
wikipedia_enabled = config['general'].getboolean('wikipedia', False)
llm_enabled = config['general'].getboolean('ollama', False) # https://ollama.com
llmModel = config['general'].get('ollamaModel', 'gemma2:2b') # default gemma2:2b
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) # 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
@@ -231,10 +242,18 @@ 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
email_sentry_alerts = config['sentry'].getboolean('emailSentryAlerts', False) # default False
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)
@@ -245,25 +264,33 @@ try:
repeater_lookup = config['location'].get('repeaterLookup', 'rbook') # default repeater lookup source
n2yoAPIKey = config['location'].get('n2yoAPIKey', '') # default empty
satListConfig = config['location'].get('satList', '25544').split(',') # default 25544 ISS
riverListDefault = config['location'].get('riverList', '').split(',') # default 12061500 Skagit River
riverListDefault = config['location'].get('riverList', '').split(',') # default None
coastalEnabled = config['location'].getboolean('coastalEnabled', False) # default False
myCoastalZone = config['location'].get('myCoastalZone', None) # default None
coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days
# location alerts
emergencyAlertBrodcastEnabled = config['location'].getboolean('eAlertBroadcastEnabled', False) # default False
wxAlertBroadcastEnabled = config['location'].getboolean('wxAlertBroadcastEnabled', False) # default False
enableGBalerts = config['location'].getboolean('enableGBalerts', False) # default False
enableDEalerts = config['location'].getboolean('enableDEalerts', False) # default False
wxAlertsEnabled = config['location'].getboolean('NOAAalertsEnabled', True) # default True
mySAME = config['location'].get('mySAME', '').split(',') # default empty
ignoreEASenable = config['location'].getboolean('ignoreEASenable', False) # default False
ignoreEASwords = config['location'].get('ignoreEASwords', 'test,advisory').split(',') # default test,advisory
myRegionalKeysDE = config['location'].get('myRegionalKeysDE', '110000000000').split(',') # default city Berlin
forecastDuration = config['location'].getint('NOAAforecastDuration', 4) # NOAA forcast days
numWxAlerts = config['location'].getint('NOAAalertCount', 2) # default 2 alerts
enableExtraLocationWx = config['location'].getboolean('enableExtraLocationWx', False) # default False
ipawsPIN = config['location'].get('ipawsPIN', '000000') # default 000000
myStateFIPSList = config['location'].get('myFIPSList', '').split(',') # default empty
mySAMEList = config['location'].get('mySAMEList', '').split(',') # default empty
ignoreFEMAenable = config['location'].getboolean('ignoreFEMAenable', True) # default True
ignoreFEMAwords = config['location'].get('ignoreFEMAwords', 'test,exercise').split(',') # default test,exercise
wxAlertBroadcastChannel = config['location'].get('wxAlertBroadcastCh', '2').split(',') # default Channel 2
emergencyAlertBroadcastCh = config['location'].get('eAlertBroadcastCh', '2').split(',') # default Channel 2
volcanoAlertBroadcastEnabled = config['location'].getboolean('volcanoAlertBroadcastEnabled', False) # default False
volcanoAlertBroadcastChannel = config['location'].get('volcanoAlertBroadcastCh', '2').split(',') # default Channel 2
ignoreUSGSEnable = config['location'].getboolean('ignoreVolcanoEnable', False) # default False
ignoreUSGSWords = config['location'].get('ignoreVolcanoWords', 'test,advisory').split(',') # default test,advisory
# bbs
bbs_enabled = config['bbs'].getboolean('enabled', False)
@@ -272,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)
@@ -281,7 +309,7 @@ try:
# qrz hello
qrz_hello_enabled = config['qrz'].getboolean('enabled', False)
qrz_db = config['qrz'].get('qrz_db', 'data/qrz.db')
qrz_hello_string = config['qrz'].get('qrz_hello_string', 'send CMD or DM me for more info.')
qrz_hello_string = config['qrz'].get('qrz_hello_string', 'MeshBot says Hello! DM for more info.')
train_qrz = config['qrz'].getboolean('training', True)
# E-Mail Settings
@@ -307,6 +335,12 @@ try:
# scheduler
scheduler_enabled = config['scheduler'].getboolean('enabled', False)
schedulerInterface = config['scheduler'].getint('interface', 1) # default interface 1
schedulerChannel = config['scheduler'].getint('channel', 2) # default channel 2
schedulerMessage = config['scheduler'].get('message', 'Scheduled message') # default message
schedulerInterval = config['scheduler'].get('interval', '') # default empty
schedulerTime = config['scheduler'].get('time', '') # default empty
schedulerValue = config['scheduler'].get('value', '') # default empty
# radio monitoring
radio_detection_enabled = config['radioMon'].getboolean('enabled', False)
@@ -320,14 +354,16 @@ try:
# file monitor
file_monitor_enabled = config['fileMon'].getboolean('filemon_enabled', False)
file_monitor_file_path = config['fileMon'].get('file_path', 'alert.txt') # default alert.txt
file_monitor_broadcastCh = config['fileMon'].getint('broadcastCh', 2) # default 2
file_monitor_broadcastCh = config['fileMon'].get('broadcastCh', '2').split(',') # default Channel 2
read_news_enabled = config['fileMon'].getboolean('enable_read_news', False) # default disabled
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
game_hop_limit = config['games'].getint('game_hop_limit', 5) # default 5 hops
disable_emojis_in_games = config['games'].getboolean('disable_emojis', False) # default False
dopewars_enabled = config['games'].getboolean('dopeWars', True)
lemonade_enabled = config['games'].getboolean('lemonade', True)
blackjack_enabled = config['games'].getboolean('blackjack', True)
@@ -336,15 +372,21 @@ 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
splitDelay = config['messagingSettings'].getfloat('splitDelay', 0) # default 0
MESSAGE_CHUNK_SIZE = config['messagingSettings'].getint('MESSAGE_CHUNK_SIZE', 160) # default 160
wantAck = config['messagingSettings'].getboolean('wantAck', False) # default False
maxBuffer = config['messagingSettings'].getint('maxBuffer', 220) # default 220
except KeyError as e:
maxBuffer = config['messagingSettings'].getint('maxBuffer', 200) # default 200
enableHopLogs = config['messagingSettings'].getboolean('enableHopLogs', False) # default False
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

@@ -6,20 +6,25 @@ import requests # pip install requests
import xml.dom.minidom
from datetime import datetime
import ephem # pip install pyephem
from datetime import timedelta
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
@@ -63,7 +68,7 @@ def drap_xray_conditions():
def get_sun(lat=0, lon=0):
# get sunrise and sunset times using callers location or default
obs = ephem.Observer()
obs.date = datetime.now()
obs.date = datetime.now(timezone.utc)
sun = ephem.Sun()
if lat != 0 and lon != 0:
obs.lat = str(lat)
@@ -74,9 +79,17 @@ def get_sun(lat=0, lon=0):
sun.compute(obs)
sun_table = {}
# get the sun azimuth and altitude
sun_table['azimuth'] = sun.az
sun_table['altitude'] = sun.alt
# sun is up include altitude
if sun_table['altitude'] > 0:
sun_table['altitude'] = sun.alt
else:
sun_table['altitude'] = 0
# get the next rise and set times
local_sunrise = ephem.localtime(obs.next_rising(sun))
local_sunset = ephem.localtime(obs.next_setting(sun))
@@ -86,19 +99,25 @@ def get_sun(lat=0, lon=0):
else:
sun_table['rise_time'] = local_sunrise.strftime('%a %d %I:%M%p')
sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p')
# if sunset is before sunrise, then it's tomorrow
# if sunset is before sunrise, then data will be for tomorrow format sunset first and sunrise second
if local_sunset < local_sunrise:
local_sunset = ephem.localtime(obs.next_setting(sun)) + timedelta(1)
if zuluTime:
sun_table['set_time'] = local_sunset.strftime('%a %d %H:%M')
else:
sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p')
sun_data = "SunRise: " + sun_table['rise_time'] + "\nSet: " + sun_table['set_time']
sun_data = "SunSet: " + sun_table['set_time'] + "\nRise: " + sun_table['rise_time']
else:
sun_data = "SunRise: " + sun_table['rise_time'] + "\nSet: " + sun_table['set_time']
sun_data += "\nDaylight: " + str((local_sunset - local_sunrise).seconds // 3600) + "h " + str(((local_sunset - local_sunrise).seconds // 60) % 60) + "m"
if sun_table['altitude'] > 0:
sun_data += "\nRemaining: " + str((local_sunset - datetime.now()).seconds // 3600) + "h " + str(((local_sunset - datetime.now()).seconds // 60) % 60) + "m"
sun_data += "\nAzimuth: " + str('{0:.2f}'.format(sun_table['azimuth'] * 180 / ephem.pi)) + "°"
if sun_table['altitude'] > 0:
sun_data += "\nAltitude: " + str('{0:.2f}'.format(sun_table['altitude'] * 180 / ephem.pi)) + "°"
return sun_data
def get_moon(lat=0, lon=0):
# get moon phase and rise/set times using callers location or default
# the phase calculation mght not be accurate (followup later)
obs = ephem.Observer()
moon = ephem.Moon()
if lat != 0 and lon != 0:
@@ -108,10 +127,28 @@ def get_moon(lat=0, lon=0):
obs.lat = str(latitudeValue)
obs.lon = str(longitudeValue)
obs.date = datetime.now()
obs.date = datetime.now(timezone.utc)
moon.compute(obs)
moon_table = {}
moon_phase = ['NewMoon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'FullMoon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][round(moon.phase / (2 * ephem.pi) * 8) % 8]
illum = moon.phase # 0 = new, 50 = first/last quarter, 100 = full
if illum < 1.0:
moon_phase = 'New Moon🌑'
elif illum < 49:
moon_phase = 'Waxing Crescent 🌒'
elif 49 <= illum < 51:
moon_phase = 'First Quarter 🌓'
elif illum < 99:
moon_phase = 'Waxing Gibbous 🌔'
elif illum >= 99:
moon_phase = 'Full Moon🌕'
elif illum > 51:
moon_phase = 'Waning Gibbous 🌖'
elif 51 >= illum > 49:
moon_phase = 'Last Quarter 🌗'
else:
moon_phase = 'Waning Crescent 🌘'
moon_table['phase'] = moon_phase
moon_table['illumination'] = moon.phase
moon_table['azimuth'] = moon.az
@@ -135,9 +172,14 @@ 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:
moon_data += "\nAz: " + str('{0:.2f}'.format(moon_table['azimuth'] * 180 / ephem.pi)) + "°" + \
"\nAlt: " + str('{0:.2f}'.format(moon_table['altitude'] * 180 / ephem.pi)) + "°"
return moon_data
@@ -169,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"
@@ -178,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,17 +7,84 @@ 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()
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:
@@ -26,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")
@@ -69,12 +142,12 @@ else:
if enableCmdHistory:
trap_list = trap_list + ("history",)
#help_message = help_message + ", history"
# Location Configuration
if location_enabled:
from modules.locationdata import * # from the spudgunman/meshing-around repo
trap_list = trap_list + trap_list_location # items tide, whereami, wxc, wx
help_message = help_message + ", whereami, wx, wxc, rlist"
trap_list = trap_list + trap_list_location
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")
@@ -86,17 +159,35 @@ if location_enabled:
# Open-Meteo Configuration for worldwide weather
if use_meteo_wxApi:
trap_list = trap_list + ("wxc",)
help_message = help_message + ", wxc"
from modules.wx_meteo import * # from the spudgunman/meshing-around repo
else:
# NOAA only features
help_message = help_message + ", wxa, tide"
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", "wxc", "wxa", "wxalert", "ea", "ealert", "valert")
help_message = help_message + ", wxalert, ealert, valert"
trap_list = trap_list + ("wx", "wxa", "wxalert", "ea", "ealert", "valert")
help_message = help_message + ", ealert, valert"
# NOAA Coastal Waters Forecasts
if coastalEnabled:
from modules.locationdata import * # from the spudgunman/meshing-around repo
trap_list = trap_list + ("mwx","tide",)
help_message = help_message + ", mwx, tide"
# BBS Configuration
if bbs_enabled:
@@ -169,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","tic-tac-toe",)
games_enabled = True
# Games Configuration
if games_enabled is True:
help_message = help_message + ", games"
@@ -180,7 +276,8 @@ if games_enabled is True:
if lemonade_enabled:
gamesCmdList += "lemonStand, "
if gTnW_enabled:
trap_list = trap_list + ("globalthermonuclearwar",)
trap_list = trap_list + ("globalthermonuclearwar","chess")
gamesCmdList += "chess, "
if blackjack_enabled:
gamesCmdList += "blackJack, "
if videoPoker_enabled:
@@ -193,6 +290,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 = ""
@@ -236,6 +335,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(", ")
@@ -255,6 +357,8 @@ if ble_count > 1:
logger.debug(f"System: Initializing Interfaces")
interface1 = interface2 = interface3 = interface4 = interface5 = interface6 = interface7 = interface8 = interface9 = None
retry_int1 = retry_int2 = retry_int3 = retry_int4 = retry_int5 = retry_int6 = retry_int7 = retry_int8 = retry_int9 = False
myNodeNum1 = myNodeNum2 = myNodeNum3 = myNodeNum4 = myNodeNum5 = myNodeNum6 = myNodeNum7 = myNodeNum8 = myNodeNum9 = 777
max_retry_count1 = max_retry_count2 = max_retry_count3 = max_retry_count4 = max_retry_count5 = max_retry_count6 = max_retry_count7 = max_retry_count8 = max_retry_count9 = interface_retry_count
for i in range(1, 10):
interface_type = globals().get(f'interface{i}_type')
if not interface_type or interface_type == 'none' or globals().get(f'interface{i}_enabled') == False:
@@ -384,27 +488,27 @@ def get_node_list(nodeInt=1):
return node_list
def get_node_location(number, nodeInt=1, channel=0):
def get_node_location(nodeID, nodeInt=1, channel=0):
interface = globals()[f'interface{nodeInt}']
# Get the location of a node by its number from nodeDB on device
# if no location data, return default location
latitude = latitudeValue
longitude = longitudeValue
position = [latitudeValue,longitudeValue]
lastheard = 0
if interface.nodes:
for node in interface.nodes.values():
if number == node['num']:
if 'position' in node:
if nodeID == node['num']:
if 'position' in node and node['position'] is not {}:
try:
latitude = node['position']['latitude']
longitude = node['position']['longitude']
logger.debug(f"System: location data for {nodeID} is {latitude},{longitude}")
position = [latitude,longitude]
except Exception as e:
logger.warning(f"System: Error getting location data for {number}")
logger.debug(f"System: location data for {number} is {latitude},{longitude}")
position = [latitude,longitude]
logger.debug(f"System: No location data for {nodeID} use default location")
return position
else:
logger.warning(f"System: No location data for {number} using default location")
logger.debug(f"System: No location data for {nodeID} using default location")
# request location data
# try:
# logger.debug(f"System: Requesting location data for {number}")
@@ -413,7 +517,7 @@ def get_node_location(number, nodeInt=1, channel=0):
# logger.error(f"System: Error requesting location data for {number}. Error: {e}")
return position
else:
logger.warning(f"System: No nodes found")
logger.warning(f"System: Location for NodeID {nodeID} not found in nodeDb")
return position
@@ -466,11 +570,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}']
@@ -540,6 +644,15 @@ def messageChunker(message):
if current_chunk:
message_list.append(current_chunk)
# Consolidate any adjacent messages that can fit in a single chunk.
idx = 0
while idx < len(message_list) - 1:
if len(message_list[idx]) + len(message_list[idx+1]) < MESSAGE_CHUNK_SIZE:
message_list[idx] += '\n' + message_list[idx+1]
del message_list[idx+1]
else:
idx += 1
# Ensure no chunk exceeds MESSAGE_CHUNK_SIZE
final_message_list = []
for chunk in message_list:
@@ -661,13 +774,24 @@ 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 word in message is in the trap list, return True
if t.lower() == m.lower():
return True
if cmdBang and m.startswith("!"):
return True
if not explicitCmd:
# if word in message is in the trap list, return True
if t.lower() == m.lower():
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:
return True
# if no trap words found, run a search for near misses like ping? or cmd?
for m in message_list:
for t in range(len(trap_list)):
@@ -724,8 +848,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
@@ -765,6 +891,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)
@@ -772,6 +902,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)
@@ -779,12 +913,16 @@ def handleAlertBroadcast(deviceID=1):
send_message(ukAlert, emergencyAlertBroadcastCh, 0, deviceID)
return True
if NO_ALERTS not in deAlert:
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
@@ -792,6 +930,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)
@@ -804,7 +946,7 @@ def handleAlertBroadcast(deviceID=1):
if volcanoAlertBroadcastEnabled:
volcanoAlert = get_volcano_usgs(latitudeValue, longitudeValue)
if volcanoAlert and volcanoAlert != NO_ALERTS and volcanoAlert != ERROR_FETCHING_DATA:
if volcanoAlert and NO_ALERTS not in volcanoAlert and ERROR_FETCHING_DATA not in volcanoAlert:
# check if the alert is different from the last one
if volcanoAlert != priorVolcanoAlert:
priorVolcanoAlert = volcanoAlert
@@ -816,40 +958,9 @@ def handleAlertBroadcast(deviceID=1):
return True
def onDisconnect(interface):
global retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9
rxType = type(interface).__name__
if rxType in ['SerialInterface', 'TCPInterface', 'BLEInterface']:
identifier = interface.__dict__.get('devPath', interface.__dict__.get('hostname', 'BLE'))
logger.critical(f"System: Lost Connection to Device {identifier}")
for i in range(1, 10):
if globals().get(f'interface{i}_enabled'):
if (rxType == 'SerialInterface' and globals().get(f'port{i}') in identifier) or \
(rxType == 'TCPInterface' and globals().get(f'hostname{i}') in identifier) or \
(rxType == 'BLEInterface' and globals().get(f'interface{i}_type') == 'ble'):
globals()[f'retry_int{i}'] = True
break
def exit_handler():
# Close the interface and save the BBS messages
logger.debug(f"System: Closing Autoresponder")
try:
logger.debug(f"System: Closing Interface1")
interface1.close()
if multiple_interface:
for i in range(2, 10):
if globals().get(f'interface{i}_enabled'):
logger.debug(f"System: Closing Interface{i}")
globals()[f'interface{i}'].close()
except Exception as e:
logger.error(f"System: closing: {e}")
if bbs_enabled:
save_bbsdb()
save_bbsdm()
logger.debug(f"System: BBS Messages Saved")
logger.debug(f"System: Exiting")
asyncLoop.stop()
asyncLoop.close()
exit (0)
# Handle disconnection of the interface
logger.warning(f"System: Abrupt Disconnection of Interface detected")
interface.close()
# Telemetry Functions
telemetryData = {}
@@ -876,6 +987,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}')
@@ -933,26 +1061,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
@@ -965,66 +1106,164 @@ 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)
# Keep the positionMetadata dictionary at 5 records
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: TELEMETRY_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']
# 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)
# 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']
# 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"
# 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']
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}"
# 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']
send_message(msg, highfly_channel, 0, highfly_interface)
time.sleep(responseDelay)
# 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}")
# 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)
except Exception as e:
logger.debug(f"System: DETECTION_SENSOR_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}")
# 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}")
# 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}")
# ADMIN_APP
# IP_TUNNEL_APP
# SERIAL_APP
# STORE_FOWARD_APP
# RANGE_TEST_APP
# COMPRESSED_TEXT_APP
# AUDIO_APP
# SIMULATOR_APP
return True
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
@@ -1096,7 +1335,7 @@ async def handleFileWatcher():
# if fileWatchBroadcastCh list contains multiple channels, broadcast to all
if type(file_monitor_broadcastCh) is list:
for ch in file_monitor_broadcastCh:
if antiSpam and ch != publicChannel:
if antiSpam and int(ch) != publicChannel:
send_message(msg, int(ch), 0, 1)
time.sleep(responseDelay)
if multiple_interface:
@@ -1122,21 +1361,26 @@ async def handleFileWatcher():
pass
async def retry_interface(nodeID):
global max_retry_count
global retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9
global max_retry_count1, max_retry_count2, max_retry_count3, max_retry_count4, max_retry_count5, max_retry_count6, max_retry_count7, max_retry_count8, max_retry_count9
interface = globals()[f'interface{nodeID}']
retry_int = globals()[f'retry_int{nodeID}']
max_retry_count = globals()[f'max_retry_count{nodeID}']
if dont_retry_disconnect:
logger.critical(f"System: dont_retry_disconnect is set, not retrying interface{nodeID}")
exit_handler()
if interface is not None:
retry_int = True
max_retry_count -= 1
globals()[f'retry_int{nodeID}'] = True
globals()[f'max_retry_count{nodeID}'] -= 1
logger.debug(f"System: Retrying interface{nodeID} {globals()[f'max_retry_count{nodeID}']} attempts left")
try:
interface.close()
logger.debug(f"System: Retrying interface{nodeID} in 15 seconds")
except Exception as e:
logger.error(f"System: closing interface{nodeID}: {e}")
logger.debug(f"System: Retrying interface{nodeID} in 15 seconds")
if max_retry_count == 0:
if globals()[f'max_retry_count{nodeID}'] == 0:
logger.critical(f"System: Max retry count reached for interface{nodeID}")
exit_handler()
@@ -1146,15 +1390,19 @@ async def retry_interface(nodeID):
if retry_int:
interface = None
globals()[f'interface{nodeID}'] = None
logger.debug(f"System: Retrying Interface{nodeID}")
interface_type = globals()[f'interface{nodeID}_type']
if interface_type == 'serial':
logger.debug(f"System: Retrying Interface{nodeID} Serial on port: {globals().get(f'port{nodeID}')}")
globals()[f'interface{nodeID}'] = meshtastic.serial_interface.SerialInterface(globals().get(f'port{nodeID}'))
elif interface_type == 'tcp':
logger.debug(f"System: Retrying Interface{nodeID} TCP on hostname: {globals().get(f'hostname{nodeID}')}")
globals()[f'interface{nodeID}'] = meshtastic.tcp_interface.TCPInterface(globals().get(f'hostname{nodeID}'))
elif interface_type == 'ble':
logger.debug(f"System: Retrying Interface{nodeID} BLE on mac: {globals().get(f'mac{nodeID}')}")
globals()[f'interface{nodeID}'] = meshtastic.ble_interface.BLEInterface(globals().get(f'mac{nodeID}'))
logger.debug(f"System: Interface{nodeID} Opened!")
# reset the retry_int and retry_count
globals()[f'max_retry_count{nodeID}'] = interface_retry_count
globals()[f'retry_int{nodeID}'] = False
except Exception as e:
logger.error(f"System: Error Opening interface{nodeID} on: {e}")
@@ -1194,10 +1442,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}")
@@ -1241,4 +1487,38 @@ 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
logger.debug(f"System: Closing Autoresponder")
try:
logger.debug(f"System: Closing Interface1")
interface1.close()
if multiple_interface:
for i in range(2, 10):
if globals().get(f'interface{i}_enabled'):
logger.debug(f"System: Closing Interface{i}")
globals()[f'interface{i}'].close()
except Exception as e:
logger.error(f"System: closing: {e}")
if bbs_enabled:
save_bbsdb()
save_bbsdm()
logger.debug(f"System: BBS Messages Saved")
logger.debug(f"System: Exiting")
asyncLoop.stop()
asyncLoop.close()
exit (0)

View File

@@ -40,8 +40,8 @@ class QuietHandler(http.server.SimpleHTTPRequestHandler):
# Change the current working directory to webRoot
os.chdir(webRoot)
# boot up simple HTTP server
httpd = http.server.HTTPServer(('127.0.0.1', PORT), QuietHandler)
# Create the HTTP server instance with the desired IP address
httpd = http.server.HTTPServer((server_ip, PORT), QuietHandler)
if SSL:
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
@@ -51,12 +51,9 @@ if SSL:
print("SSL certificate file not found. Please generate it using the command provided in the comments.")
exit(1)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
# Create the HTTP server instance with the desired IP address
httpd = http.server.HTTPServer((server_ip, PORT), QuietHandler)
# Print out the URL using the IP address stored in server_ip
print(f"Serving reports at http://{server_ip}:{PORT} Press ^C to quit.\n\n")
print(f"Serving reports at https://{server_ip}:{PORT} Press ^C to quit.\n\n")
else:
print(f"Serving reports at http://{server_ip}:{PORT} Press ^C to quit.\n\n")
if not webServerLogs:
print("Server Logs are disabled")

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
@@ -217,15 +244,15 @@ def onReceive(packet, interface):
if rxType == 'TCPInterface':
rxHost = interface.__dict__.get('hostname', 'unknown')
if hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1
elif multiple_interface and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2
elif multiple_interface and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3
elif multiple_interface and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4
elif multiple_interface and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5
elif multiple_interface and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6
elif multiple_interface and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7
elif multiple_interface and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8
elif multiple_interface and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9
if rxHost and hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1
elif multiple_interface and rxHost and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2
elif multiple_interface and rxHost and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3
elif multiple_interface and rxHost and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4
elif multiple_interface and rxHost and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5
elif multiple_interface and rxHost and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6
elif multiple_interface and rxHost and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7
elif multiple_interface and rxHost and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8
elif multiple_interface and rxHost and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9
if rxType == 'BLEInterface':
if interface1_type == 'ble': rxNode = 1
@@ -254,6 +281,7 @@ def onReceive(packet, interface):
if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
via_mqtt = packet['decoded'].get('viaMqtt', False)
# check if the packet is from us
if message_from_id == myNodeNum1 or message_from_id == myNodeNum2:
@@ -283,10 +311,17 @@ def onReceive(packet, interface):
else:
hop_start = 0
if enableHopLogs:
logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start}")
if hop_away == 0 and hop_limit == 0 and hop_start == 0:
hop = "Last Hop"
hop_count = 0
if hop_start == hop_limit:
hop = "Direct"
hop_count = 0
elif hop_start == 0 and hop_limit > 0:
elif hop_start == 0 and hop_limit > 0 or via_mqtt:
hop = "MQTT"
hop_count = 0
else:
@@ -310,7 +345,7 @@ def onReceive(packet, interface):
isDM = True
# check if the message contains a trap word, DMs are always responded to
if (messageTrap(message_string) and not llm_enabled) or messageTrap(message_string.split()[0]):
# log the message to the message log
# log the message to stdout
logger.info(f"Device:{rxNode} Channel: {channel_number} " + CustomFormatter.green + f"Received DM: " + CustomFormatter.white + f"{message_string} " + CustomFormatter.purple +\
"From: " + CustomFormatter.white + f"{get_name_from_number(message_from_id, 'long', rxNode)}")
# respond with DM
@@ -321,7 +356,8 @@ def onReceive(packet, interface):
time.sleep(responseDelay)
# log the message to the message log
msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | " + message_string.replace('\n', '-nl-'))
if log_messages_to_file:
msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | DM | " + message_string.replace('\n', '-nl-'))
else:
# message is on a channel
if messageTrap(message_string):
@@ -380,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}")
@@ -404,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:
@@ -432,21 +470,49 @@ 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.debug(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.debug("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)
try:
if __name__ == "__main__":
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
exit_handler()
pass
except KeyboardInterrupt:
exit_handler()
except SystemExit:
pass
# EOF

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

@@ -24,4 +24,21 @@ else
fi
# print telemetry data rounded to 2 decimal places
printf "Disk:%s RAM:%.2f%% CPU:%.2f%% CPU-T:%.2f°C (%.2f°F)\n" "$free_space" "$ram_usage" "$cpu_usage" "$temp" "$tempf"
printf "Disk:%s RAM:%.2f%% CPU:%.2f%% CPU-T:%.2f°C (%.2f°F)\n" "$free_space" "$ram_usage" "$cpu_usage" "$temp" "$tempf"
# attempt check for updates
if command -v git &> /dev/null
then
if [ -d ../.git ]; then
# check for updates
git fetch --quiet
local_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$local_branch" != "HEAD" ] && git show-ref --verify --quiet "refs/remotes/origin/$local_branch"; then
local_commit=$(git rev-parse "$local_branch")
remote_commit=$(git rev-parse "origin/$local_branch")
if [ "$local_commit" != "$remote_commit" ]; then
echo "Bot Update Available!"
fi
fi
fi
fi

71
update.sh Normal file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# MeshBot Update Script
# Usage: bash update.sh or ./update.sh after making it executable with chmod +x update.sh
# Check if the mesh_bot.service or pong_bot.service
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
# 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..."
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
# 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
# Print completion message
echo "Update completed successfully?"
exit 0
# End of script