Skip to content

Commit

Permalink
refactor; unify emoji style; use recent python features
Browse files Browse the repository at this point in the history
  • Loading branch information
Tom Hensel committed Jul 8, 2024
1 parent b22cb74 commit a3fe320
Show file tree
Hide file tree
Showing 13 changed files with 958 additions and 579 deletions.
20 changes: 9 additions & 11 deletions .github/workflows/code-quality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ jobs:
- name: Install dependencies
run: |
python -m pip install -q -U pip
pip install -q -U flake8 pytest
if [ -f requirements.txt ]; then pip install -q -U -r requirements.txt; fi
- name: Lint with flake8
continue-on-error: true
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics
- name: Test with pytest
pip install -q -U -r requirements.txt
pip install -q -U pytest pytest-asyncio coverage 'prospector[with_everything]'
- name: Run tests with coverage
run: |
pytest
coverage run -m pytest
coverage report -m
- name: Run Prospector
run: prospector
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ wheels/
.installed.cfg
*.egg

.coverage

# dotenv
.envrc

Expand Down
13 changes: 13 additions & 0 deletions .prospector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
strictness: medium
test-warnings: false
doc-warnings: false

ignore-paths:
- config

pylint:
disable:
- logging-fstring-interpolation

pylint:
run: false
51 changes: 34 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ Connect your Meshtastic mesh network with Telegram group chats! 📡💬

## 🌟 Features

- 🔌 Supports serial and TCP connections
- 🔄 Automatic reconnection
- 🚦 Rate limiting
- 🔔 Regular updates
- ✅ Read receipts
- 📝 Optional syslog logging
- 🔌 Supports both serial and TCP connections to Meshtastic devices
- 🔄 Automatic reconnection to Meshtastic device
- 🚦 Message queuing and retry mechanism
- 🔔 Command to send bell notifications to Meshtastic nodes
- 📊 Real-time status updates for nodes (telemetry, position, routing, neighbors)
- 🗺️ Location sharing between Telegram and Meshtastic
- 🔐 User authorization for Telegram commands
- 📝 Optional logging to file and syslog

## 🛠 Requirements

- Python 3.11+ 🐍
- Python 3.12+ 🐍
- Dependencies:
- `envyaml` 📄
- `meshtastic` 📡
- `python-telegram-bot` 🤖
- `envyaml`: For YAML configuration file parsing with environment variable support
- `meshtastic`: Python API for Meshtastic devices
- `python-telegram-bot`: Telegram Bot API wrapper
- `pubsub`: For publish-subscribe messaging pattern

## 🚀 Quick Start

Expand All @@ -35,7 +38,7 @@ Connect your Meshtastic mesh network with Telegram group chats! 📡💬

3. **Install dependencies:**
```bash
pip install -U -r requirements.txt
pip install -r requirements.txt
```

4. **Configure the project:**
Expand All @@ -48,25 +51,39 @@ Connect your Meshtastic mesh network with Telegram group chats! 📡💬
- 123456789

meshtastic:
connection_type: "serial"
device: "/dev/ttyUSB0"
connection_type: "serial" # or "tcp"
device: "/dev/ttyUSB0" # or "hostname:port" for TCP
default_node_id: "!abcdef12"
local_nodes:
- "!abcdef12"
- "!12345678"

logging:
level: "info"
level_telegram: "warn"
level_httpx: "warn"
use_syslog: false
syslog_host: "localhost"
syslog_port: 514
syslog_protocol: "udp"
```
5. **Run Meshgram:**
```bash
python src/meshgram.py
```

## 🤝 Contributing
## 📡 Telegram Commands

We love contributions! 💖 Please open an issue or submit a pull request.
- `/start` - Start the bot and see available commands
- `/help` - Show help message
- `/status` - Check the current status of Meshgram and Meshtastic
- `/bell [node_id]` - Send a bell notification to a Meshtastic node
- `/node [node_id]` - Get information about a specific node
- `/user` - Get information about your Telegram user

## 📜 License
## 🤝 Contributing

This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
We welcome contributions! 💖 Please open an issue or submit a pull request if you have any improvements or bug fixes.

Happy meshing! 🎉
2 changes: 2 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ meshtastic:

logging:
level: 'info'
level_telegram: 'warn'
level_httpx: 'warn'
use_syslog: false
syslog_host: "${SYSLOG_HOST}"
syslog_port: 514
Expand Down
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
pythonpath = src
testpaths = tests
44 changes: 24 additions & 20 deletions src/config_manager.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import logging
import re
from typing import Any, Optional, List
from typing import Any, Optional, List, Dict
from envyaml import EnvYAML

class ConfigManager:
def __init__(self, config_path: str = 'config/config.yaml'):
try:
self.config = EnvYAML(config_path)
self.config: Dict[str, Any] = EnvYAML(config_path)
except Exception as e:
raise ValueError(f"Failed to load configuration from {config_path}: {e}")
self.setup_logging()
self._setup_logging()

def get(self, key: str, default: Optional[Any] = None) -> Any:
value = self.config.get(key, default)
Expand All @@ -21,21 +21,25 @@ def get_authorized_users(self) -> List[int]:
users = self.get('telegram.authorized_users', [])
return [int(user) for user in users if str(user).isdigit()]

def setup_logging(self) -> None:
def _setup_logging(self) -> None:
log_level = self._parse_log_level(self.get('logging.level', 'INFO'))
formatter = SensitiveFormatter('%(asctime)s %(levelname)s [%(name)s] %(message)s',)
log_level_telegram = self._parse_log_level(self.get('logging.level_telegram', 'INFO'))
log_level_httpx = self._parse_log_level(self.get('logging.level_telegram', 'WARN'))

formatter = SensitiveFormatter('%(asctime)s %(levelname)s %(name)s - %(message)s')

handlers = [logging.StreamHandler()]
if self.get('logging.file_log', False):
handlers.append(logging.FileHandler(self.get('logging.file_path', 'meshgram.log')))

logging.basicConfig(
level=log_level,
handlers=[
logging.StreamHandler(),
logging.FileHandler('meshgram.log')
]
)
logging.basicConfig(level=log_level, handlers=handlers, format='%(asctime)s %(levelname)s %(name)s - %(message)s')

for handler in logging.getLogger().handlers:
for handler in logging.root.handlers:
handler.setFormatter(formatter)

logging.getLogger('httpx').setLevel(log_level_httpx)
logging.getLogger('telegram').setLevel(log_level_telegram)

if self.get('logging.use_syslog', False):
self._setup_syslog_handler()

Expand All @@ -54,9 +58,10 @@ def _parse_log_level(self, level: Any) -> int:

def _setup_syslog_handler(self) -> None:
try:
syslog_handler = logging.handlers.SysLogHandler(
from logging.handlers import SysLogHandler
syslog_handler = SysLogHandler(
address=(self.get('logging.syslog_host'), self.get('logging.syslog_port', 514)),
socktype=logging.handlers.socket.SOCK_DGRAM if self.get('logging.syslog_protocol', 'udp') == 'udp' else logging.handlers.socket.SOCK_STREAM
socktype=SysLogHandler.UDP_SOCKET if self.get('logging.syslog_protocol', 'udp').lower() == 'udp' else SysLogHandler.TCP_SOCKET
)
syslog_handler.setFormatter(SensitiveFormatter('%(name)s - %(levelname)s - %(message)s'))
logging.getLogger().addHandler(syslog_handler)
Expand All @@ -70,16 +75,15 @@ def validate_config(self) -> None:
'meshtastic.connection_type',
'meshtastic.device',
]
for key in required_keys:
if not self.get(key):
raise ValueError(f"Missing required configuration: {key}")
missing_keys = [key for key in required_keys if not self.get(key)]
if missing_keys:
raise ValueError(f"Missing required configuration: {', '.join(missing_keys)}")

class SensitiveFormatter(logging.Formatter):
def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None):
super().__init__(fmt, datefmt)
self.sensitive_patterns = [
(re.compile(r'(bot\d+):(AAH[\w-]{34})'), r'\1:[REDACTED]'),
(re.compile(r'(token=)([A-Za-z0-9-_]{35,})'), r'\1[REDACTED]'),
(re.compile(r'(https://api\.telegram\.org/bot)([A-Za-z0-9:_-]{35,})(/\w+)'), r'\1[redacted]\3')
]

def format(self, record: logging.LogRecord) -> str:
Expand Down
86 changes: 49 additions & 37 deletions src/meshgram.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,86 +15,98 @@ def __init__(self, config: ConfigManager) -> None:
self.telegram: Optional[TelegramInterface] = None
self.message_processor: Optional[MessageProcessor] = None
self.tasks: List[Task] = []
self.is_shutting_down: bool = False

async def setup(self) -> None:
self.logger.info("Setting up meshgram...")
try:
self.meshtastic = MeshtasticInterface(self.config)
await self.meshtastic.setup()

self.telegram = TelegramInterface(self.config)
await self.telegram.setup()

self.meshtastic = await self._setup_meshtastic()
self.telegram = await self._setup_telegram()
self.message_processor = MessageProcessor(self.meshtastic, self.telegram, self.config)
self.logger.info("Meshgram setup complete.")
except Exception as e:
self.logger.error(f"Error during setup: {e}")
self.logger.error(f"Error during setup: {e}", exc_info=True)
await self.shutdown()
raise

async def _setup_meshtastic(self) -> MeshtasticInterface:
meshtastic = MeshtasticInterface(self.config)
await meshtastic.setup()
return meshtastic

async def _setup_telegram(self) -> TelegramInterface:
telegram = TelegramInterface(self.config)
await telegram.setup()
return telegram

async def shutdown(self) -> None:
if self.is_shutting_down:
self.logger.info("Shutdown already in progress, skipping.")
return

self.is_shutting_down = True
self.logger.info("Shutting down meshgram...")

# Cancel all tasks
for task in self.tasks:
if not task.done():
task.cancel()

# Wait for all tasks to complete
await asyncio.gather(*self.tasks, return_exceptions=True)

# Shutdown components in reverse order of creation
components = [self.message_processor, self.telegram, self.meshtastic]
for component in components:
if component:
try:
await component.close()
except Exception as e:
self.logger.error(f"Error closing {component.__class__.__name__}: {e}", exc_info=True)

self.logger.info("Meshgram shutdown complete.")

async def run(self) -> None:
try:
await self.setup()
except Exception as e:
self.logger.error(f"Failed to set up Meshgram: {e}")
self.logger.error(f"Failed to set up Meshgram: {e}", exc_info=True)
return

self.logger.info("Meshgram is running ヽ(´▽`)/")
self.logger.info("Meshgram is running.")
self.tasks = [
asyncio.create_task(self.message_processor.process_messages()),
asyncio.create_task(self.meshtastic.process_thread_safe_queue()),
asyncio.create_task(self.meshtastic.process_pending_messages()),
asyncio.create_task(self.telegram.start_polling()),
asyncio.create_task(self.message_processor.check_heartbeats())
]
try:
await asyncio.gather(*self.tasks)
except asyncio.CancelledError:
self.logger.info("Received cancellation signal.")
except Exception as e:
self.logger.error(f"An error occurred: {e}", exc_info=True)
self.logger.error(f"Unexpected error: {e}", exc_info=True)
finally:
await self.shutdown()

async def shutdown(self) -> None:
self.logger.info("Shutting down meshgram...")
for task in self.tasks:
if not task.done():
task.cancel()
await asyncio.gather(*self.tasks, return_exceptions=True)
if self.meshtastic:
await self.meshtastic.close()
if self.telegram:
await self.telegram.close()
if self.message_processor:
if hasattr(self.message_processor, 'close'):
await self.message_processor.close()
else:
self.logger.warning("MessageProcessor does not have a close method.")
self.logger.info("Meshgram shutdown complete.")

async def main() -> None:
parser = argparse.ArgumentParser(description='Meshgram: Meshtastic-Telegram Bridge')
parser.add_argument('-c', '--config', default='config/config.yaml', help='Path to configuration file')
parser.add_argument('--version', action='version', version='%(prog)s 1.0.0')
args = parser.parse_args()

config = ConfigManager(args.config)
config.setup_logging()
logger = get_logger(__name__)

app = Meshgram(config)
try:
await app.run()
except ExceptionGroup as eg:
for i, e in enumerate(eg.exceptions, 1):
logger.error(f"Exception {i}: {e}", exc_info=e)
except KeyboardInterrupt:
logger.info("Received keyboard interrupt. Shutting down gracefully...")
except Exception as e:
logger.error(f"Unhandled exception: {e}", exc_info=True)
logger.info("Received keyboard interrupt.")
finally:
await app.shutdown()

if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nShutdown complete.")
asyncio.run(main())
Loading

0 comments on commit a3fe320

Please sign in to comment.