rpi-app-framework 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rpi_app_framework/__init__.py +10 -0
- rpi_app_framework/device_manager.py +84 -0
- rpi_app_framework/hardware.py +132 -0
- rpi_app_framework/led_simple.py +83 -0
- rpi_app_framework/microdot_manager.py +81 -0
- rpi_app_framework/motor_driver_tb6612.py +288 -0
- rpi_app_framework/rpi_app.py +186 -0
- rpi_app_framework/wifi_manager.py +180 -0
- rpi_app_framework-0.1.2.dist-info/METADATA +107 -0
- rpi_app_framework-0.1.2.dist-info/RECORD +13 -0
- rpi_app_framework-0.1.2.dist-info/WHEEL +5 -0
- rpi_app_framework-0.1.2.dist-info/licenses/LICENSE +21 -0
- rpi_app_framework-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .rpi_app import RPIApp
|
|
2
|
+
from .device_manager import DeviceManager
|
|
3
|
+
from .led_simple import LEDSimple
|
|
4
|
+
from .wifi_manager import WiFiManager
|
|
5
|
+
from .motor_driver_tb6612 import MotorDriverTB6612FNG
|
|
6
|
+
from .microdot_manager import MicrodotManager
|
|
7
|
+
from .hardware import PiHardwareAdapter
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.1"
|
|
10
|
+
__all__ = ['RPIApp', 'DeviceManager', 'LEDSimple', 'WiFiManager', 'MotorDriverTB6612FNG', 'MicrodotManager','PiHardwareAdapter']
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Conditional imports for cross-platform compatibility
|
|
2
|
+
try:
|
|
3
|
+
from machine import Pin, PWM
|
|
4
|
+
MICROPYTHON = True
|
|
5
|
+
except ImportError:
|
|
6
|
+
import RPi.GPIO as GPIO
|
|
7
|
+
from gpiozero import LED, PWMOutputDevice as PWMLED
|
|
8
|
+
MICROPYTHON = False
|
|
9
|
+
|
|
10
|
+
class DeviceManager:
|
|
11
|
+
"""
|
|
12
|
+
Abstract base class for device managers.
|
|
13
|
+
Provides common logging functionality and device naming with validation.
|
|
14
|
+
Supports Pico (MicroPython) and full RPi (Python) via conditional imports.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, name=None, log_func=None):
|
|
18
|
+
"""
|
|
19
|
+
Initialize the DeviceManager instance.
|
|
20
|
+
|
|
21
|
+
:param name: Optional custom name for the device instance (defaults to class name).
|
|
22
|
+
Must be a non-empty string, max 50 characters, alphanumeric with spaces/underscores/dashes.
|
|
23
|
+
:param log_func: Optional function to log messages (e.g., app's log method).
|
|
24
|
+
:raises ValueError: If name is invalid.
|
|
25
|
+
"""
|
|
26
|
+
self.logging_enabled = True # Default to logging enabled
|
|
27
|
+
self.log_func = log_func
|
|
28
|
+
self._validate_name(name)
|
|
29
|
+
self._name = name or self.__class__.__name__ # Use provided name or class name
|
|
30
|
+
|
|
31
|
+
def _validate_name(self, name):
|
|
32
|
+
"""
|
|
33
|
+
Validate the device name.
|
|
34
|
+
|
|
35
|
+
:param name: The name to validate.
|
|
36
|
+
:raises ValueError: If name is invalid (not a string, empty, too long, or contains invalid characters).
|
|
37
|
+
"""
|
|
38
|
+
if name is None:
|
|
39
|
+
return # Will default to class name, which is valid
|
|
40
|
+
if not isinstance(name, str):
|
|
41
|
+
raise ValueError("Device name must be a string")
|
|
42
|
+
if len(name.strip()) == 0:
|
|
43
|
+
raise ValueError("Device name cannot be empty")
|
|
44
|
+
if len(name) > 50:
|
|
45
|
+
raise ValueError("Device name must be 50 characters or fewer")
|
|
46
|
+
# Allow alphanumeric, spaces, underscores, dashes; no other special chars for log/file safety
|
|
47
|
+
import re
|
|
48
|
+
if not re.match(r'^[a-zA-Z0-9 _\-]+$', name):
|
|
49
|
+
raise ValueError("Device name can only contain alphanumeric characters, spaces, underscores, and dashes")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self):
|
|
53
|
+
"""
|
|
54
|
+
Get the device name.
|
|
55
|
+
|
|
56
|
+
:return: The name of the device.
|
|
57
|
+
"""
|
|
58
|
+
return self._name
|
|
59
|
+
|
|
60
|
+
def enable_logging(self):
|
|
61
|
+
"""
|
|
62
|
+
Enable logging for the device.
|
|
63
|
+
"""
|
|
64
|
+
self.logging_enabled = True
|
|
65
|
+
|
|
66
|
+
def disable_logging(self):
|
|
67
|
+
"""
|
|
68
|
+
Disable logging for the device.
|
|
69
|
+
"""
|
|
70
|
+
self.logging_enabled = False
|
|
71
|
+
|
|
72
|
+
def _log(self, message):
|
|
73
|
+
"""
|
|
74
|
+
Internal method to log a message if logging is enabled.
|
|
75
|
+
Prefixes the message with the device name.
|
|
76
|
+
|
|
77
|
+
:param message: The message to log.
|
|
78
|
+
"""
|
|
79
|
+
if self.logging_enabled:
|
|
80
|
+
prefixed_message = f"[{self.name}] {message}"
|
|
81
|
+
if self.log_func:
|
|
82
|
+
self.log_func(prefixed_message)
|
|
83
|
+
else:
|
|
84
|
+
print(prefixed_message)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# rpi_app_framework/pi_hardware_adapter.py
|
|
2
|
+
from device_manager import DeviceManager
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from machine import Pin, PWM
|
|
6
|
+
MICROPYTHON = True
|
|
7
|
+
except ImportError:
|
|
8
|
+
import RPi.GPIO as GPIO
|
|
9
|
+
from gpiozero import LED, PWMOutputDevice
|
|
10
|
+
GPIO.setmode(GPIO.BCM)
|
|
11
|
+
MICROPYTHON = False
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PiHardwareAdapter(DeviceManager):
|
|
18
|
+
"""
|
|
19
|
+
Unified hardware adapter for all Raspberry Pi models.
|
|
20
|
+
Provides:
|
|
21
|
+
• Pin factory (digital out/in, PWM)
|
|
22
|
+
• Board model detection
|
|
23
|
+
• CPU temperature
|
|
24
|
+
Works on Pico 2 W (MicroPython) and full-size Raspberry Pi (Python).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name="PiHardwareAdapter", log_func=None):
|
|
28
|
+
super().__init__(name=name, log_func=log_func)
|
|
29
|
+
self._model = self._detect_model()
|
|
30
|
+
self._log(f"Hardware: {self._model}")
|
|
31
|
+
|
|
32
|
+
def _detect_model(self) -> str:
|
|
33
|
+
if MICROPYTHON:
|
|
34
|
+
return "Raspberry Pi Pico 2 W"
|
|
35
|
+
try:
|
|
36
|
+
with open("/proc/device-tree/model", "r") as f:
|
|
37
|
+
model = f.read().strip("\x00")
|
|
38
|
+
return model or "Raspberry Pi (unknown)"
|
|
39
|
+
except Exception:
|
|
40
|
+
return "Raspberry Pi (detection failed)"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def model(self) -> str:
|
|
44
|
+
"""Read-only: detected board model."""
|
|
45
|
+
return self._model
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def cpu_temperature(self) -> float:
|
|
49
|
+
"""Read-only: CPU/core temperature in °C."""
|
|
50
|
+
if MICROPYTHON:
|
|
51
|
+
# RP2040 built-in sensor
|
|
52
|
+
try:
|
|
53
|
+
sensor = machine.ADC(4)
|
|
54
|
+
reading = sensor.read_u16()
|
|
55
|
+
voltage = reading * 3.3 / 65535
|
|
56
|
+
temp = 27 - (voltage - 0.706) / 0.001721
|
|
57
|
+
return round(temp, 2)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise RuntimeError(f"Pico temp sensor error: {e}")
|
|
60
|
+
|
|
61
|
+
# Full-size RPi
|
|
62
|
+
try:
|
|
63
|
+
out = subprocess.check_output(["vcgencmd", "measure_temp"], text=True)
|
|
64
|
+
m = re.search(r"temp=([\d.]+)", out)
|
|
65
|
+
if m:
|
|
66
|
+
return float(m.group(1))
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
try:
|
|
70
|
+
with open("/sys/class/thermal/thermal_zone0/temp") as f:
|
|
71
|
+
return round(int(f.read().strip()) / 1000.0, 2)
|
|
72
|
+
except Exception:
|
|
73
|
+
raise RuntimeError("CPU temperature unavailable")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def cpu_temp(self) -> float:
|
|
77
|
+
"""Alias for cpu_temperature."""
|
|
78
|
+
return self.cpu_temperature
|
|
79
|
+
|
|
80
|
+
# ——— Pin Abstraction ———
|
|
81
|
+
|
|
82
|
+
def digital_out(self, pin, value=None):
|
|
83
|
+
"""Return a digital output pin object (or set value directly)."""
|
|
84
|
+
if MICROPYTHON:
|
|
85
|
+
p = Pin(pin, Pin.OUT)
|
|
86
|
+
if value is not None:
|
|
87
|
+
p.value(value)
|
|
88
|
+
return p
|
|
89
|
+
else:
|
|
90
|
+
GPIO.setup(pin, GPIO.OUT)
|
|
91
|
+
if value is not None:
|
|
92
|
+
GPIO.output(pin, value)
|
|
93
|
+
return lambda v=None: GPIO.output(pin, v) if v is not None else None
|
|
94
|
+
|
|
95
|
+
def digital_in(self, pin, pull=None):
|
|
96
|
+
"""Return a digital input pin (with optional pull-up/down)."""
|
|
97
|
+
if MICROPYTHON:
|
|
98
|
+
mode = Pin.IN
|
|
99
|
+
if pull == "up":
|
|
100
|
+
mode = Pin.IN | Pin.PULL_UP
|
|
101
|
+
elif pull == "down":
|
|
102
|
+
mode = Pin.IN | Pin.PULL_DOWN
|
|
103
|
+
return Pin(pin, mode)
|
|
104
|
+
else:
|
|
105
|
+
pull_mode = GPIO.PUD_UP if pull == "up" else GPIO.PUD_DOWN if pull == "down" else GPIO.PUD_OFF
|
|
106
|
+
GPIO.setup(pin, GPIO.IN, pull_up_down=pull_mode)
|
|
107
|
+
return lambda: GPIO.input(pin)
|
|
108
|
+
|
|
109
|
+
def pwm(self, pin, freq=1000, duty=0):
|
|
110
|
+
"""Return a PWM output object."""
|
|
111
|
+
if MICROPYTHON:
|
|
112
|
+
p = PWM(Pin(pin))
|
|
113
|
+
p.freq(freq)
|
|
114
|
+
p.duty_u16(int(duty * 655.35)) # 0–100 → 0–65535
|
|
115
|
+
return p
|
|
116
|
+
else:
|
|
117
|
+
pwm_obj = PWMOutputDevice(pin, frequency=freq)
|
|
118
|
+
pwm_obj.value = duty / 100.0
|
|
119
|
+
return pwm_obj
|
|
120
|
+
|
|
121
|
+
def led(self, pin):
|
|
122
|
+
"""Convenient factory for an LED (digital out)."""
|
|
123
|
+
if MICROPYTHON:
|
|
124
|
+
return self.digital_out(pin)
|
|
125
|
+
else:
|
|
126
|
+
return LED(pin)
|
|
127
|
+
|
|
128
|
+
def cleanup(self):
|
|
129
|
+
"""Clean up GPIO resources (full RPi only)."""
|
|
130
|
+
if not MICROPYTHON:
|
|
131
|
+
GPIO.cleanup()
|
|
132
|
+
self._log("GPIO cleaned up")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Conditional imports for cross-platform compatibility
|
|
2
|
+
try:
|
|
3
|
+
from machine import Pin
|
|
4
|
+
MICROPYTHON = True
|
|
5
|
+
except ImportError:
|
|
6
|
+
import RPi.GPIO as GPIO
|
|
7
|
+
from gpiozero import LED
|
|
8
|
+
MICROPYTHON = False
|
|
9
|
+
|
|
10
|
+
from .device_manager import DeviceManager
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
class LEDSimple(DeviceManager):
|
|
14
|
+
"""
|
|
15
|
+
Simple LED manager without PWM support.
|
|
16
|
+
Inherits from DeviceManager for logging and naming.
|
|
17
|
+
Provides basic on/off and blink functionality.
|
|
18
|
+
Works on Pico (MicroPython) or full RPi (Python).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, pin="LED", name=None, log_func=None):
|
|
22
|
+
"""
|
|
23
|
+
Initialize the LEDSimple instance.
|
|
24
|
+
|
|
25
|
+
:param pin: Pin to use for the LED (default: "LED").
|
|
26
|
+
:param name: Optional custom name for this LED instance.
|
|
27
|
+
:param log_func: Optional function to log messages (e.g., app's log method).
|
|
28
|
+
"""
|
|
29
|
+
super().__init__(name=name, log_func=log_func)
|
|
30
|
+
if MICROPYTHON:
|
|
31
|
+
self.led = Pin(pin, Pin.OUT)
|
|
32
|
+
else:
|
|
33
|
+
self.led_pin = int(pin)
|
|
34
|
+
GPIO.setmode(GPIO.BCM)
|
|
35
|
+
GPIO.setup(self.led_pin, GPIO.OUT)
|
|
36
|
+
self.led = LED(self.led_pin) # Use gpiozero for easy control
|
|
37
|
+
self.off() # Ensure LED is off initially
|
|
38
|
+
|
|
39
|
+
def on(self):
|
|
40
|
+
"""
|
|
41
|
+
Turn the LED on.
|
|
42
|
+
"""
|
|
43
|
+
if MICROPYTHON:
|
|
44
|
+
self.led.value(1)
|
|
45
|
+
else:
|
|
46
|
+
self.led.on()
|
|
47
|
+
self._log("LED turned on")
|
|
48
|
+
|
|
49
|
+
def off(self):
|
|
50
|
+
"""
|
|
51
|
+
Turn the LED off.
|
|
52
|
+
"""
|
|
53
|
+
if MICROPYTHON:
|
|
54
|
+
self.led.value(0)
|
|
55
|
+
else:
|
|
56
|
+
self.led.off()
|
|
57
|
+
self._log("LED turned off")
|
|
58
|
+
|
|
59
|
+
def toggle(self):
|
|
60
|
+
"""
|
|
61
|
+
Toggle the LED state (on to off or off to on).
|
|
62
|
+
"""
|
|
63
|
+
if MICROPYTHON:
|
|
64
|
+
self.led.toggle()
|
|
65
|
+
state = "on" if self.led.value() else "off"
|
|
66
|
+
else:
|
|
67
|
+
self.led.toggle()
|
|
68
|
+
state = "on" if self.led.is_active else "off"
|
|
69
|
+
self._log(f"LED toggled to {state}")
|
|
70
|
+
|
|
71
|
+
def blink(self, duration=0.5, count=1):
|
|
72
|
+
"""
|
|
73
|
+
Blink the LED a specified number of times.
|
|
74
|
+
|
|
75
|
+
:param duration: Duration of each blink in seconds (default: 0.5).
|
|
76
|
+
:param count: Number of blinks (default: 1).
|
|
77
|
+
"""
|
|
78
|
+
for _ in range(count):
|
|
79
|
+
self.on()
|
|
80
|
+
time.sleep(duration)
|
|
81
|
+
self.off()
|
|
82
|
+
time.sleep(duration)
|
|
83
|
+
self._log(f"LED blinked {count} times with duration {duration}s")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Conditional imports for cross-platform compatibility
|
|
2
|
+
try:
|
|
3
|
+
from microdot_asyncio import Microdot
|
|
4
|
+
MICROPYTHON = True
|
|
5
|
+
except ImportError:
|
|
6
|
+
from microdot import Microdot # Full Python version
|
|
7
|
+
MICROPYTHON = False
|
|
8
|
+
|
|
9
|
+
from .device_manager import DeviceManager
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
class MicrodotManager(DeviceManager):
|
|
13
|
+
"""
|
|
14
|
+
Manager class for running a Microdot web server on Raspberry Pi (Pico 2 W or full RPi).
|
|
15
|
+
Inherits from DeviceManager for logging and naming.
|
|
16
|
+
Assumes WiFi is already connected (e.g., via WiFiManager).
|
|
17
|
+
Supports adding routes and running the server asynchronously.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, name="Web Server", log_func=None, port=80):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the MicrodotManager instance.
|
|
23
|
+
|
|
24
|
+
:param name: Optional custom name for this manager instance (default: "Web Server").
|
|
25
|
+
:param log_func: Optional function to log messages (e.g., app's log method).
|
|
26
|
+
:param port: Port to run the web server on (default: 80).
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(name=name, log_func=log_func)
|
|
29
|
+
self.app = Microdot()
|
|
30
|
+
self.port = port
|
|
31
|
+
self._log(f"Microdot web server initialized on port {port}")
|
|
32
|
+
|
|
33
|
+
def setup(self):
|
|
34
|
+
"""
|
|
35
|
+
Setup method for MicrodotManager.
|
|
36
|
+
Can be overridden to add routes or configure the server.
|
|
37
|
+
"""
|
|
38
|
+
self._log("MicrodotManager setup complete")
|
|
39
|
+
|
|
40
|
+
def add_route(self, path, handler, methods=['GET']):
|
|
41
|
+
"""
|
|
42
|
+
Add a route to the web server.
|
|
43
|
+
|
|
44
|
+
:param path: URL path for the route (e.g., '/status').
|
|
45
|
+
:param handler: Function to handle the route (receives request object).
|
|
46
|
+
:param methods: List of HTTP methods (e.g., ['GET', 'POST']).
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
self.app.route(path, methods=methods)(handler)
|
|
50
|
+
self._log(f"Added route: {path} ({methods})")
|
|
51
|
+
except Exception as e:
|
|
52
|
+
self._log(f"Error adding route {path}: {e}")
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
async def run_server_async(self):
|
|
56
|
+
"""
|
|
57
|
+
Run the Microdot server asynchronously.
|
|
58
|
+
Suitable for MicroPython's asyncio or full Python.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
if MICROPYTHON:
|
|
62
|
+
await self.app.start_server(port=self.port)
|
|
63
|
+
else:
|
|
64
|
+
self.app.run(port=self.port) # Full Python runs synchronously
|
|
65
|
+
self._log(f"Web server running on port {self.port}")
|
|
66
|
+
except Exception as e:
|
|
67
|
+
self._log(f"Error running web server: {e}")
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
def run(self):
|
|
71
|
+
"""
|
|
72
|
+
Synchronous wrapper to run the server (for compatibility).
|
|
73
|
+
Starts the async server using asyncio.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
asyncio.run(self.run_server_async())
|
|
77
|
+
except KeyboardInterrupt:
|
|
78
|
+
self._log("Web server stopped by user")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
self._log(f"Error in web server: {e}")
|
|
81
|
+
raise
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Conditional imports for cross-platform compatibility
|
|
2
|
+
try:
|
|
3
|
+
from machine import Pin, PWM
|
|
4
|
+
MICROPYTHON = True
|
|
5
|
+
except ImportError:
|
|
6
|
+
import RPi.GPIO as GPIO
|
|
7
|
+
from gpiozero import PWMOutputDevice as PWMLED
|
|
8
|
+
MICROPYTHON = False
|
|
9
|
+
|
|
10
|
+
from .device_manager import DeviceManager
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
class MotorDriverTB6612FNG(DeviceManager):
|
|
14
|
+
"""
|
|
15
|
+
Device manager for TB6612FNG Dual DC Motor Driver.
|
|
16
|
+
Supports two DC motors with PWM speed control and direction.
|
|
17
|
+
Inherits from DeviceManager for logging and naming.
|
|
18
|
+
Allows individual names for each motor.
|
|
19
|
+
Provides unified methods using motor names.
|
|
20
|
+
Tracks current speed and direction for increase/decrease speed.
|
|
21
|
+
Includes start/stop methods for specific motors and enable/disable for the driver.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, pwma_pin, ain1_pin, ain2_pin, pwmb_pin, bin1_pin, bin2_pin, stby_pin, motor_a_name="Motor A", motor_b_name="Motor B", name=None, log_func=None, freq=1000):
|
|
25
|
+
"""
|
|
26
|
+
Initialize the MotorDriverTB6612FNG instance.
|
|
27
|
+
|
|
28
|
+
:param pwma_pin: PWM pin for Motor A (int or str).
|
|
29
|
+
:param ain1_pin: Direction pin 1 for Motor A (int or str).
|
|
30
|
+
:param ain2_pin: Direction pin 2 for Motor A (int or str).
|
|
31
|
+
:param pwmb_pin: PWM pin for Motor B (int or str).
|
|
32
|
+
:param bin1_pin: Direction pin 1 for Motor B (int or str).
|
|
33
|
+
:param bin2_pin: Direction pin 2 for Motor B (int or str).
|
|
34
|
+
:param stby_pin: Standby pin (int or str).
|
|
35
|
+
:param motor_a_name: Name for Motor A (default: "Motor A").
|
|
36
|
+
:param motor_b_name: Name for Motor B (default: "Motor B").
|
|
37
|
+
:param name: Optional custom name for this driver instance.
|
|
38
|
+
:param log_func: Optional function to log messages.
|
|
39
|
+
:param freq: PWM frequency in Hz (default: 1000).
|
|
40
|
+
:raises ValueError: If pins are invalid for Pico 2 W or names are invalid.
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(name=name, log_func=log_func)
|
|
43
|
+
self._validate_names(motor_a_name, motor_b_name)
|
|
44
|
+
self.motor_a_name = motor_a_name
|
|
45
|
+
self.motor_b_name = motor_b_name
|
|
46
|
+
self._validate_pins([pwma_pin, ain1_pin, ain2_pin, pwmb_pin, bin1_pin, bin2_pin, stby_pin])
|
|
47
|
+
|
|
48
|
+
if MICROPYTHON:
|
|
49
|
+
self.pwma = PWM(Pin(pwma_pin))
|
|
50
|
+
self.pwma.freq(freq)
|
|
51
|
+
self.ain1 = Pin(ain1_pin, Pin.OUT)
|
|
52
|
+
self.ain2 = Pin(ain2_pin, Pin.OUT)
|
|
53
|
+
self.pwmb = PWM(Pin(pwmb_pin))
|
|
54
|
+
self.pwmb.freq(freq)
|
|
55
|
+
self.bin1 = Pin(bin1_pin, Pin.OUT)
|
|
56
|
+
self.bin2 = Pin(bin2_pin, Pin.OUT)
|
|
57
|
+
self.stby = Pin(stby_pin, Pin.OUT)
|
|
58
|
+
else:
|
|
59
|
+
GPIO.setmode(GPIO.BCM)
|
|
60
|
+
self.pwma_pin = int(pwma_pin)
|
|
61
|
+
self.ain1_pin = int(ain1_pin)
|
|
62
|
+
self.ain2_pin = int(ain2_pin)
|
|
63
|
+
self.pwmb_pin = int(pwmb_pin)
|
|
64
|
+
self.bin1_pin = int(bin1_pin)
|
|
65
|
+
self.bin2_pin = int(bin2_pin)
|
|
66
|
+
self.stby_pin = int(stby_pin)
|
|
67
|
+
GPIO.setup([self.ain1_pin, self.ain2_pin, self.bin1_pin, self.bin2_pin, self.stby_pin], GPIO.OUT)
|
|
68
|
+
self.pwma = PWMLED(self.pwma_pin)
|
|
69
|
+
self.pwmb = PWMLED(self.pwmb_pin)
|
|
70
|
+
self.ain1 = GPIO.PWM(self.ain1_pin, freq)
|
|
71
|
+
self.ain2 = GPIO.PWM(self.ain2_pin, freq)
|
|
72
|
+
self.bin1 = GPIO.PWM(self.bin1_pin, freq)
|
|
73
|
+
self.bin2 = GPIO.PWM(self.bin2_pin, freq)
|
|
74
|
+
self.stby = GPIO.PWM(self.stby_pin, freq)
|
|
75
|
+
|
|
76
|
+
self.stby.value(0) # Start disabled
|
|
77
|
+
|
|
78
|
+
# Track current speed and direction for each motor
|
|
79
|
+
self.motor_a_speed = 0
|
|
80
|
+
self.motor_a_direction = None # 'forward', 'backward', or None
|
|
81
|
+
self.motor_b_speed = 0
|
|
82
|
+
self.motor_b_direction = None # 'forward', 'backward', or None
|
|
83
|
+
|
|
84
|
+
self._log("TB6612FNG initialized (disabled)")
|
|
85
|
+
|
|
86
|
+
def _validate_names(self, motor_a_name, motor_b_name):
|
|
87
|
+
"""
|
|
88
|
+
Validate motor names.
|
|
89
|
+
|
|
90
|
+
:param motor_a_name: Name for Motor A.
|
|
91
|
+
:param motor_b_name: Name for Motor B.
|
|
92
|
+
:raises ValueError: If names are invalid.
|
|
93
|
+
"""
|
|
94
|
+
for name in [motor_a_name, motor_b_name]:
|
|
95
|
+
if not isinstance(name, str) or len(name.strip()) == 0 or len(name) > 50:
|
|
96
|
+
raise ValueError("Motor name must be a non-empty string up to 50 characters")
|
|
97
|
+
|
|
98
|
+
def _validate_pins(self, pins):
|
|
99
|
+
"""
|
|
100
|
+
Validate pins for Raspberry Pi Pico 2 W or full RPi.
|
|
101
|
+
|
|
102
|
+
:param pins: List of pins to validate.
|
|
103
|
+
:raises ValueError: If any pin is invalid.
|
|
104
|
+
"""
|
|
105
|
+
for pin in pins:
|
|
106
|
+
if MICROPYTHON and pin == "LED":
|
|
107
|
+
continue # Onboard LED is valid for Pico
|
|
108
|
+
try:
|
|
109
|
+
pin_num = int(pin)
|
|
110
|
+
if MICROPYTHON and not 0 <= pin_num <= 29:
|
|
111
|
+
raise ValueError("Invalid pin number for Pico")
|
|
112
|
+
elif not MICROPYTHON and not 1 <= pin_num <= 40:
|
|
113
|
+
raise ValueError("Invalid pin number for full RPi")
|
|
114
|
+
except (ValueError, TypeError):
|
|
115
|
+
raise ValueError("Pin must be an integer (0-29 for Pico, 1-40 for RPi) or 'LED' for Pico")
|
|
116
|
+
|
|
117
|
+
def enable(self):
|
|
118
|
+
"""
|
|
119
|
+
Enable the motor driver (set STBY high).
|
|
120
|
+
Allows both motors to operate.
|
|
121
|
+
"""
|
|
122
|
+
if MICROPYTHON:
|
|
123
|
+
self.stby.value(1)
|
|
124
|
+
else:
|
|
125
|
+
GPIO.output(self.stby_pin, GPIO.HIGH)
|
|
126
|
+
self._log("Motor driver enabled")
|
|
127
|
+
|
|
128
|
+
def disable(self):
|
|
129
|
+
"""
|
|
130
|
+
Disable the motor driver (set STBY low).
|
|
131
|
+
Stops both motors and prevents operation.
|
|
132
|
+
"""
|
|
133
|
+
if MICROPYTHON:
|
|
134
|
+
self.pwma.duty_u16(0)
|
|
135
|
+
self.pwmb.duty_u16(0)
|
|
136
|
+
self.stby.value(0)
|
|
137
|
+
else:
|
|
138
|
+
self.pwma.value = 0
|
|
139
|
+
self.pwmb.value = 0
|
|
140
|
+
GPIO.output(self.stby_pin, GPIO.LOW)
|
|
141
|
+
self.motor_a_speed = 0
|
|
142
|
+
self.motor_a_direction = None
|
|
143
|
+
self.motor_b_speed = 0
|
|
144
|
+
self.motor_b_direction = None
|
|
145
|
+
self._log("Motor driver disabled")
|
|
146
|
+
|
|
147
|
+
def start(self, motor_name):
|
|
148
|
+
"""
|
|
149
|
+
Start the specified motor (sets it to forward at default speed).
|
|
150
|
+
|
|
151
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
152
|
+
:raises ValueError: If motor_name is invalid.
|
|
153
|
+
"""
|
|
154
|
+
pwm, _, _, actual_name, speed_attr, dir_attr = self._get_motor_functions(motor_name)
|
|
155
|
+
self.enable() # Ensure driver is enabled
|
|
156
|
+
self.forward(motor_name, speed=50) # Default to 50% speed forward
|
|
157
|
+
self._log(f"{actual_name} started (forward at 50%)")
|
|
158
|
+
|
|
159
|
+
def stop(self, motor_name):
|
|
160
|
+
"""
|
|
161
|
+
Stop the specified motor.
|
|
162
|
+
|
|
163
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
164
|
+
:raises ValueError: If motor_name is invalid.
|
|
165
|
+
"""
|
|
166
|
+
pwm, _, _, actual_name, speed_attr, dir_attr = self._get_motor_functions(motor_name)
|
|
167
|
+
if MICROPYTHON:
|
|
168
|
+
pwm.duty_u16(0)
|
|
169
|
+
else:
|
|
170
|
+
pwm.value = 0
|
|
171
|
+
setattr(self, speed_attr, 0)
|
|
172
|
+
setattr(self, dir_attr, None)
|
|
173
|
+
self._log(f"{actual_name} stopped")
|
|
174
|
+
|
|
175
|
+
def _get_motor_functions(self, motor_name):
|
|
176
|
+
"""
|
|
177
|
+
Get motor functions based on name.
|
|
178
|
+
|
|
179
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
180
|
+
:raises ValueError: If motor_name is invalid.
|
|
181
|
+
:return: Tuple (pwm, dir1, dir2, name, speed_attr, dir_attr).
|
|
182
|
+
"""
|
|
183
|
+
if motor_name == self.motor_a_name:
|
|
184
|
+
return (self.pwma, self.ain1, self.ain2, self.motor_a_name, 'motor_a_speed', 'motor_a_direction')
|
|
185
|
+
elif motor_name == self.motor_b_name:
|
|
186
|
+
return (self.pwmb, self.bin1, self.bin2, self.motor_b_name, 'motor_b_speed', 'motor_b_direction')
|
|
187
|
+
else:
|
|
188
|
+
raise ValueError(f"Invalid motor name: {motor_name}. Must be '{self.motor_a_name}' or '{self.motor_b_name}'")
|
|
189
|
+
|
|
190
|
+
def forward(self, motor_name, speed=100):
|
|
191
|
+
"""
|
|
192
|
+
Set the specified motor to forward direction with speed.
|
|
193
|
+
|
|
194
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
195
|
+
:param speed: Speed from 0 to 100 (default: 100).
|
|
196
|
+
:raises ValueError: If motor_name is invalid or speed is out of range.
|
|
197
|
+
"""
|
|
198
|
+
if not 0 <= speed <= 100:
|
|
199
|
+
raise ValueError("Speed must be between 0 and 100")
|
|
200
|
+
pwm, dir1, dir2, actual_name, speed_attr, dir_attr = self._get_motor_functions(motor_name)
|
|
201
|
+
if MICROPYTHON:
|
|
202
|
+
dir1.value(1)
|
|
203
|
+
dir2.value(0)
|
|
204
|
+
pwm.duty_u16(int((speed / 100.0) * 65535))
|
|
205
|
+
else:
|
|
206
|
+
GPIO.output(dir1, GPIO.HIGH)
|
|
207
|
+
GPIO.output(dir2, GPIO.LOW)
|
|
208
|
+
pwm.value = speed / 100.0
|
|
209
|
+
setattr(self, speed_attr, speed)
|
|
210
|
+
setattr(self, dir_attr, 'forward')
|
|
211
|
+
self._log(f"{actual_name} forward at {speed}%")
|
|
212
|
+
|
|
213
|
+
def backward(self, motor_name, speed=100):
|
|
214
|
+
"""
|
|
215
|
+
Set the specified motor to backward direction with speed.
|
|
216
|
+
|
|
217
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
218
|
+
:param speed: Speed from 0 to 100 (default: 100).
|
|
219
|
+
:raises ValueError: If motor_name is invalid or speed is out of range.
|
|
220
|
+
"""
|
|
221
|
+
if not 0 <= speed <= 100:
|
|
222
|
+
raise ValueError("Speed must be between 0 and 100")
|
|
223
|
+
pwm, dir1, dir2, actual_name, speed_attr, dir_attr = self._get_motor_functions(motor_name)
|
|
224
|
+
if MICROPYTHON:
|
|
225
|
+
dir1.value(0)
|
|
226
|
+
dir2.value(1)
|
|
227
|
+
pwm.duty_u16(int((speed / 100.0) * 65535))
|
|
228
|
+
else:
|
|
229
|
+
GPIO.output(dir1, GPIO.LOW)
|
|
230
|
+
GPIO.output(dir2, GPIO.HIGH)
|
|
231
|
+
pwm.value = speed / 100.0
|
|
232
|
+
setattr(self, speed_attr, speed)
|
|
233
|
+
setattr(self, dir_attr, 'backward')
|
|
234
|
+
self._log(f"{actual_name} backward at {speed}%")
|
|
235
|
+
|
|
236
|
+
def stop_motor(self, motor_name):
|
|
237
|
+
"""
|
|
238
|
+
Stop the specified motor (alias for stop method, for clarity).
|
|
239
|
+
|
|
240
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
241
|
+
:raises ValueError: If motor_name is invalid.
|
|
242
|
+
"""
|
|
243
|
+
self.stop(motor_name)
|
|
244
|
+
|
|
245
|
+
def increase_speed(self, motor_name, amount=10):
|
|
246
|
+
"""
|
|
247
|
+
Increase the speed of the specified motor by the given amount.
|
|
248
|
+
|
|
249
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
250
|
+
:param amount: Amount to increase speed by (default: 10).
|
|
251
|
+
:raises ValueError: If motor_name is invalid or amount is out of range.
|
|
252
|
+
"""
|
|
253
|
+
if not 0 < amount <= 100:
|
|
254
|
+
raise ValueError("Amount must be positive and <= 100")
|
|
255
|
+
pwm, _, _, actual_name, speed_attr, dir_attr = self._get_motor_functions(motor_name)
|
|
256
|
+
current_speed = getattr(self, speed_attr)
|
|
257
|
+
new_speed = min(100, current_speed + amount)
|
|
258
|
+
if new_speed > 0 and getattr(self, dir_attr) is not None:
|
|
259
|
+
# Apply new speed in current direction
|
|
260
|
+
if getattr(self, dir_attr) == 'forward':
|
|
261
|
+
self.forward(motor_name, new_speed)
|
|
262
|
+
else:
|
|
263
|
+
self.backward(motor_name, new_speed)
|
|
264
|
+
else:
|
|
265
|
+
self._log(f"{actual_name} speed increase skipped (no direction or already stopped)")
|
|
266
|
+
|
|
267
|
+
def decrease_speed(self, motor_name, amount=10):
|
|
268
|
+
"""
|
|
269
|
+
Decrease the speed of the specified motor by the given amount.
|
|
270
|
+
|
|
271
|
+
:param motor_name: The motor name ("Motor A" or "Motor B").
|
|
272
|
+
:param amount: Amount to decrease speed by (default: 10).
|
|
273
|
+
:raises ValueError: If motor_name is invalid or amount is out of range.
|
|
274
|
+
"""
|
|
275
|
+
if not 0 < amount <= 100:
|
|
276
|
+
raise ValueError("Amount must be positive and <= 100")
|
|
277
|
+
pwm, _, _, actual_name, speed_attr, dir_attr = self._get_motor_functions(motor_name)
|
|
278
|
+
current_speed = getattr(self, speed_attr)
|
|
279
|
+
new_speed = max(0, current_speed - amount)
|
|
280
|
+
if new_speed > 0 and getattr(self, dir_attr) is not None:
|
|
281
|
+
# Apply new speed in current direction
|
|
282
|
+
if getattr(self, dir_attr) == 'forward':
|
|
283
|
+
self.forward(motor_name, new_speed)
|
|
284
|
+
else:
|
|
285
|
+
self.backward(motor_name, new_speed)
|
|
286
|
+
else:
|
|
287
|
+
self.stop(motor_name)
|
|
288
|
+
self._log(f"{actual_name} speed decreased to 0, stopped")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Conditional imports for cross-platform compatibility
|
|
2
|
+
try:
|
|
3
|
+
from machine import Pin
|
|
4
|
+
MICROPYTHON = True
|
|
5
|
+
except ImportError:
|
|
6
|
+
import RPi.GPIO as GPIO
|
|
7
|
+
MICROPYTHON = False
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
class RPIApp:
|
|
13
|
+
"""
|
|
14
|
+
Generic base class for RPi applications (Pico 2 W or full RPi).
|
|
15
|
+
Provides common functionality like logging and application lifecycle management.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, app_name="RPIApp", max_log_files=10, enable_file_logging=True):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the RPIApp instance.
|
|
21
|
+
|
|
22
|
+
:param app_name: Name of the application (default: "RPIApp").
|
|
23
|
+
:param max_log_files: Maximum number of log files to keep (default: 10).
|
|
24
|
+
:param enable_file_logging: Whether to enable logging to file (default: True).
|
|
25
|
+
If False, logs only print to console.
|
|
26
|
+
"""
|
|
27
|
+
self.running = False # Reintroduce running flag
|
|
28
|
+
self._app_name = app_name # Private application name
|
|
29
|
+
self._max_log_files = max_log_files # Maximum number of log files to keep
|
|
30
|
+
self.enable_file_logging = enable_file_logging
|
|
31
|
+
if self.enable_file_logging:
|
|
32
|
+
# Ensure LOGFILES directory exists
|
|
33
|
+
try:
|
|
34
|
+
os.mkdir("LOGFILES")
|
|
35
|
+
except OSError:
|
|
36
|
+
pass # Directory already exists
|
|
37
|
+
# Ensure app-specific log subdirectory exists
|
|
38
|
+
app_log_dir = f"LOGFILES/{app_name}"
|
|
39
|
+
try:
|
|
40
|
+
os.mkdir(app_log_dir)
|
|
41
|
+
except OSError:
|
|
42
|
+
pass # Directory already exists
|
|
43
|
+
# Generate log file name with app_name and date/time in app-specific folder
|
|
44
|
+
t = time.localtime()
|
|
45
|
+
self.log_file = f"{app_log_dir}/{app_name}_{t[0]:04d}{t[1]:02d}{t[2]:02d}_{t[3]:02d}{t[4]:02d}{t[5]:02d}.log"
|
|
46
|
+
# Delete oldest log files to keep max_log_files or fewer
|
|
47
|
+
self._manage_log_files()
|
|
48
|
+
else:
|
|
49
|
+
self.log_file = None
|
|
50
|
+
|
|
51
|
+
def _manage_log_files(self):
|
|
52
|
+
"""
|
|
53
|
+
Manage log files by deleting the oldest ones to keep only max_log_files.
|
|
54
|
+
This is an internal method and should not be called directly.
|
|
55
|
+
"""
|
|
56
|
+
if not self.enable_file_logging:
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
app_log_dir = f"LOGFILES/{self._app_name}"
|
|
60
|
+
# List all files in the app-specific log directory
|
|
61
|
+
files = os.listdir(app_log_dir)
|
|
62
|
+
# Filter log files starting with app_name
|
|
63
|
+
log_files = [f for f in files if f.startswith(self._app_name) and f.endswith('.log')]
|
|
64
|
+
# Sort files by name (timestamp ensures chronological order)
|
|
65
|
+
log_files.sort()
|
|
66
|
+
# Keep only the max_log_files most recent log files
|
|
67
|
+
while len(log_files) > self._max_log_files:
|
|
68
|
+
oldest_file = log_files.pop(0) # Remove the oldest file
|
|
69
|
+
try:
|
|
70
|
+
os.remove(f"{app_log_dir}/{oldest_file}")
|
|
71
|
+
self.log(f"Deleted old log file: {oldest_file}")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
print(f"Failed to delete log file {app_log_dir}/{oldest_file}: {e}")
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"Error managing log files: {e}")
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def app_name(self):
|
|
79
|
+
"""
|
|
80
|
+
Get the application name.
|
|
81
|
+
|
|
82
|
+
:return: The name of the application.
|
|
83
|
+
"""
|
|
84
|
+
return self._app_name
|
|
85
|
+
|
|
86
|
+
def start(self):
|
|
87
|
+
"""
|
|
88
|
+
Start the application.
|
|
89
|
+
Sets running flag to True, logs the start, calls setup, and then run.
|
|
90
|
+
"""
|
|
91
|
+
self.running = True
|
|
92
|
+
self.log(f"{self.app_name} started")
|
|
93
|
+
self.setup()
|
|
94
|
+
self.run()
|
|
95
|
+
|
|
96
|
+
def setup(self):
|
|
97
|
+
"""
|
|
98
|
+
Setup method to be overridden by child classes.
|
|
99
|
+
Called once after start. Logs any errors that occur with stack trace.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
pass # To be overridden by child class
|
|
103
|
+
except Exception as e:
|
|
104
|
+
self._log_exception("Error in setup", e)
|
|
105
|
+
raise # Re-raise to halt application start
|
|
106
|
+
|
|
107
|
+
def run(self):
|
|
108
|
+
"""
|
|
109
|
+
Run method to be overridden by child classes.
|
|
110
|
+
Contains the main loop or logic of the application.
|
|
111
|
+
"""
|
|
112
|
+
pass # To be overridden by child class
|
|
113
|
+
|
|
114
|
+
def stop(self):
|
|
115
|
+
"""
|
|
116
|
+
Stop the application.
|
|
117
|
+
Sets running flag to False and logs the stop.
|
|
118
|
+
"""
|
|
119
|
+
self.running = False
|
|
120
|
+
self.log(f"{self.app_name} stopped")
|
|
121
|
+
|
|
122
|
+
def log(self, message):
|
|
123
|
+
"""
|
|
124
|
+
Log a message to the log file with timestamp and app name.
|
|
125
|
+
Also prints the message to console.
|
|
126
|
+
|
|
127
|
+
:param message: The message to log.
|
|
128
|
+
"""
|
|
129
|
+
# Get current time for timestamp
|
|
130
|
+
t = time.localtime()
|
|
131
|
+
timestamp = f"[{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}]"
|
|
132
|
+
log_line = f"{timestamp} [{self.app_name}] {message}"
|
|
133
|
+
print(log_line) # Print to console
|
|
134
|
+
if self.enable_file_logging:
|
|
135
|
+
log_line += "\n"
|
|
136
|
+
# Append log message to file
|
|
137
|
+
try:
|
|
138
|
+
with open(self.log_file, "a") as log_file:
|
|
139
|
+
log_file.write(log_line)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
print(f"Failed to write to log file: {e}")
|
|
142
|
+
|
|
143
|
+
def _log_exception(self, context, exc):
|
|
144
|
+
"""
|
|
145
|
+
Log an exception with stack trace to the log file.
|
|
146
|
+
Also prints the exception to console.
|
|
147
|
+
|
|
148
|
+
:param context: A string describing the context of the error.
|
|
149
|
+
:param exc: The exception object.
|
|
150
|
+
"""
|
|
151
|
+
t = time.localtime()
|
|
152
|
+
timestamp = f"[{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}]"
|
|
153
|
+
error_msg = f"{timestamp} [{self.app_name}] {context}: {exc}"
|
|
154
|
+
print(error_msg) # Print to console
|
|
155
|
+
if self.enable_file_logging:
|
|
156
|
+
try:
|
|
157
|
+
# Attempt to import traceback inside the method for MicroPython compatibility
|
|
158
|
+
import traceback
|
|
159
|
+
stack_trace = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
160
|
+
full_error = ''.join(stack_trace)
|
|
161
|
+
print(full_error) # Print stack trace to console
|
|
162
|
+
error_msg += "\n" + full_error + "\n"
|
|
163
|
+
except ImportError:
|
|
164
|
+
# Fallback if traceback not available
|
|
165
|
+
print("(Stack trace unavailable)") # Print fallback to console
|
|
166
|
+
error_msg += "\n(Stack trace unavailable)\n"
|
|
167
|
+
except Exception as trace_e:
|
|
168
|
+
print(f"(Error formatting stack trace: {trace_e})") # Print error to console
|
|
169
|
+
error_msg += f"\n(Error formatting stack trace: {trace_e})\n"
|
|
170
|
+
# Append log message to file
|
|
171
|
+
try:
|
|
172
|
+
with open(self.log_file, "a") as log_file:
|
|
173
|
+
log_file.write(error_msg)
|
|
174
|
+
except Exception as log_e:
|
|
175
|
+
print(f"Failed to log exception: {log_e}")
|
|
176
|
+
else:
|
|
177
|
+
# If no file logging, still try to print stack trace
|
|
178
|
+
try:
|
|
179
|
+
import traceback
|
|
180
|
+
stack_trace = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
181
|
+
full_error = ''.join(stack_trace)
|
|
182
|
+
print(full_error) # Print stack trace to console
|
|
183
|
+
except ImportError:
|
|
184
|
+
print("(Stack trace unavailable)")
|
|
185
|
+
except Exception as trace_e:
|
|
186
|
+
print(f"(Error formatting stack trace: {trace_e})")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from .device_manager import DeviceManager
|
|
2
|
+
import network
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
class WiFiManager(DeviceManager):
|
|
8
|
+
"""
|
|
9
|
+
Manager class for WiFi connections on Raspberry Pi Pico 2 W.
|
|
10
|
+
Supports multiple networks defined by location names, read from wifi_config.json.
|
|
11
|
+
Allows dynamic or static IP configuration per location from the JSON.
|
|
12
|
+
Logs activity and raises exceptions on connection failure.
|
|
13
|
+
Provides scan-based connection to find matching SSID.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
CONFIG_FILE = "wifi_config.json"
|
|
17
|
+
|
|
18
|
+
def __init__(self, name="WiFi Manager", log_func=None):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the WiFiManager instance.
|
|
21
|
+
|
|
22
|
+
:param name: Optional custom name for this manager instance (default: "WiFi Manager").
|
|
23
|
+
:param log_func: Optional function to log messages (e.g., app's log method).
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(name=name, log_func=log_func)
|
|
26
|
+
self.wlan = None
|
|
27
|
+
self.current_location = None
|
|
28
|
+
self.networks = self._load_config()
|
|
29
|
+
self._validate_config()
|
|
30
|
+
|
|
31
|
+
def _load_config(self):
|
|
32
|
+
"""
|
|
33
|
+
Load WiFi configurations from JSON file.
|
|
34
|
+
|
|
35
|
+
:return: Dict of networks {location: {'ssid': str, 'password': str, 'static_ip': str (optional), ...}}.
|
|
36
|
+
:raises FileNotFoundError: If config file not found.
|
|
37
|
+
:raises json.JSONDecodeError: If file is invalid JSON.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
config_file = self.CONFIG_FILE
|
|
41
|
+
if not os.path.exists(config_file):
|
|
42
|
+
raise FileNotFoundError(f"WiFi config file not found: {config_file}")
|
|
43
|
+
with open(config_file, 'r') as f:
|
|
44
|
+
config = json.load(f)
|
|
45
|
+
self._log(f"Loaded WiFi config from {config_file}")
|
|
46
|
+
return config
|
|
47
|
+
except Exception as e:
|
|
48
|
+
self._log(f"Error loading WiFi config: {e}")
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
def _validate_config(self):
|
|
52
|
+
"""
|
|
53
|
+
Validate the loaded config has valid networks.
|
|
54
|
+
|
|
55
|
+
:raises ValueError: If config is empty or invalid.
|
|
56
|
+
"""
|
|
57
|
+
if not self.networks or not isinstance(self.networks, dict):
|
|
58
|
+
raise ValueError("WiFi config must be a non-empty dict of networks")
|
|
59
|
+
for location, details in self.networks.items():
|
|
60
|
+
if not isinstance(details, dict) or 'ssid' not in details or 'password' not in details:
|
|
61
|
+
raise ValueError(f"Invalid config for location '{location}': missing ssid/password")
|
|
62
|
+
# Optional static IP fields are validated during connection
|
|
63
|
+
self._log(f"Validated {len(self.networks)} networks")
|
|
64
|
+
|
|
65
|
+
def connect(self, location):
|
|
66
|
+
"""
|
|
67
|
+
Connect to the WiFi network at the specified location (direct method).
|
|
68
|
+
|
|
69
|
+
:param location: The location name from config (e.g., 'home').
|
|
70
|
+
:raises ValueError: If location not in config.
|
|
71
|
+
:raises Exception: If connection fails.
|
|
72
|
+
"""
|
|
73
|
+
if location not in self.networks:
|
|
74
|
+
raise ValueError(f"Location '{location}' not in config")
|
|
75
|
+
self._connect_to_location(location)
|
|
76
|
+
|
|
77
|
+
def connect_scan(self, target_ssid):
|
|
78
|
+
"""
|
|
79
|
+
Scan for WiFi networks, find one matching the target SSID,
|
|
80
|
+
identify the location from config, and connect using that config (scan method).
|
|
81
|
+
|
|
82
|
+
:param target_ssid: The SSID to search for in scan results.
|
|
83
|
+
:raises ValueError: If no matching SSID found in scan or config.
|
|
84
|
+
:raises Exception: If connection fails.
|
|
85
|
+
"""
|
|
86
|
+
self._log(f"Scanning for SSID: {target_ssid}")
|
|
87
|
+
try:
|
|
88
|
+
# Scan for available networks
|
|
89
|
+
temp_wlan = network.WLAN(network.STA_IF)
|
|
90
|
+
temp_wlan.active(True)
|
|
91
|
+
scan_results = temp_wlan.scan()
|
|
92
|
+
available_ssids = [net[0].decode('utf-8') for net in scan_results]
|
|
93
|
+
|
|
94
|
+
if target_ssid not in available_ssids:
|
|
95
|
+
raise ValueError(f"Target SSID '{target_ssid}' not found in scan results")
|
|
96
|
+
|
|
97
|
+
# Find location in config with matching SSID
|
|
98
|
+
matching_location = None
|
|
99
|
+
for location, details in self.networks.items():
|
|
100
|
+
if details['ssid'] == target_ssid:
|
|
101
|
+
matching_location = location
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if not matching_location:
|
|
105
|
+
raise ValueError(f"Target SSID '{target_ssid}' not found in config")
|
|
106
|
+
|
|
107
|
+
self._log(f"Found matching location: {matching_location}")
|
|
108
|
+
self._connect_to_location(matching_location)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
self._log(f"Scan connection failed for '{target_ssid}': {e}")
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
def _connect_to_location(self, location):
|
|
114
|
+
"""
|
|
115
|
+
Internal method to connect using location config.
|
|
116
|
+
|
|
117
|
+
:param location: The location name from config.
|
|
118
|
+
:raises Exception: If connection fails.
|
|
119
|
+
"""
|
|
120
|
+
self.current_location = location
|
|
121
|
+
details = self.networks[location]
|
|
122
|
+
ssid = details['ssid']
|
|
123
|
+
password = details['password']
|
|
124
|
+
self._log(f"Connecting to {location} ({ssid})...")
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
self.wlan = network.WLAN(network.STA_IF)
|
|
128
|
+
if self.wlan is None:
|
|
129
|
+
raise Exception("Failed to create WLAN object")
|
|
130
|
+
self.wlan.active(True)
|
|
131
|
+
self.wlan.connect(ssid, password)
|
|
132
|
+
|
|
133
|
+
max_retries = 20
|
|
134
|
+
for retry in range(max_retries):
|
|
135
|
+
if self.wlan.isconnected():
|
|
136
|
+
break
|
|
137
|
+
self._log(f"Connection attempt {retry + 1}/{max_retries}...")
|
|
138
|
+
time.sleep(1)
|
|
139
|
+
else:
|
|
140
|
+
raise Exception(f"Failed to connect to {ssid} after {max_retries} retries")
|
|
141
|
+
|
|
142
|
+
# Set static IP if provided in config for this location
|
|
143
|
+
static_ip = details.get('static_ip')
|
|
144
|
+
if static_ip:
|
|
145
|
+
netmask = details.get('netmask', '255.255.255.0')
|
|
146
|
+
gateway = details.get('gateway')
|
|
147
|
+
dns = details.get('dns', '8.8.8.8')
|
|
148
|
+
if gateway:
|
|
149
|
+
self.wlan.ifconfig((static_ip, netmask, gateway, dns))
|
|
150
|
+
self._log(f"Static IP set for {location}: {static_ip}")
|
|
151
|
+
else:
|
|
152
|
+
self._log(f"Warning: Static IP '{static_ip}' specified for {location} but no gateway; using DHCP")
|
|
153
|
+
else:
|
|
154
|
+
self._log("Using DHCP for dynamic IP")
|
|
155
|
+
|
|
156
|
+
ip_addr = self.wlan.ifconfig()[0]
|
|
157
|
+
self._log(f"Connected to {location}! IP: {ip_addr}")
|
|
158
|
+
except Exception as e:
|
|
159
|
+
self._log(f"Connection failed for {location}: {e}")
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def ip_address(self):
|
|
164
|
+
"""
|
|
165
|
+
Get the current IP address.
|
|
166
|
+
|
|
167
|
+
:return: IP address string or None if not connected.
|
|
168
|
+
"""
|
|
169
|
+
if self.wlan and self.wlan.isconnected():
|
|
170
|
+
return self.wlan.ifconfig()[0]
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def disconnect(self):
|
|
174
|
+
"""
|
|
175
|
+
Disconnect from current WiFi network.
|
|
176
|
+
"""
|
|
177
|
+
if self.wlan:
|
|
178
|
+
self.wlan.active(False)
|
|
179
|
+
self._log(f"Disconnected from {self.current_location}")
|
|
180
|
+
self.current_location = None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rpi-app-framework
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A Python framework for building applications on all Raspberry Pi versions, including Pico.
|
|
5
|
+
Home-page: https://github.com/yourusername/rpi-app-framework
|
|
6
|
+
Author: Your Name
|
|
7
|
+
Author-email: John Singer <john@singerlinks.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/jsinger0420/rpi-app-framework
|
|
10
|
+
Project-URL: Repository, https://github.com/jsinger0420/rpi-app-framework.git
|
|
11
|
+
Project-URL: Documentation, https://github.com/jsinger0420/rpi-app-framework/wiki
|
|
12
|
+
Project-URL: Bug Tracker, https://github.com/jsinger0420/rpi-app-framework/issues
|
|
13
|
+
Keywords: raspberry-pi,pico,framework,app-development,iot
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
24
|
+
Classifier: Topic :: System :: Hardware
|
|
25
|
+
Requires-Python: >=3.6
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: pi
|
|
29
|
+
Requires-Dist: RPi.GPIO>=0.7.0; extra == "pi"
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
35
|
+
Requires-Dist: isort>=5.0; extra == "dev"
|
|
36
|
+
Requires-Dist: flake8>=6.0; extra == "dev"
|
|
37
|
+
Dynamic: author
|
|
38
|
+
Dynamic: home-page
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
Dynamic: requires-python
|
|
41
|
+
|
|
42
|
+
RPi App Framework
|
|
43
|
+
Modular framework for all Raspberry Pi models (Pico 2 W with MicroPython/Thonny, full RPi 1-5 with Python).
|
|
44
|
+
Installation
|
|
45
|
+
For MicroPython/Pico (Thonny):
|
|
46
|
+
|
|
47
|
+
Copy the rpi_app_framework folder to /lib on your Pico.
|
|
48
|
+
|
|
49
|
+
For full Python/RPi:
|
|
50
|
+
|
|
51
|
+
pip install rpi-app-framework
|
|
52
|
+
|
|
53
|
+
Usage
|
|
54
|
+
Create a main.py to start your app:
|
|
55
|
+
# examples/simple_demo.py
|
|
56
|
+
from rpi_app_framework import RPIApp, PiHardwareAdapter
|
|
57
|
+
import time
|
|
58
|
+
|
|
59
|
+
class SimpleDemo(RPIApp):
|
|
60
|
+
"""
|
|
61
|
+
Minimal demo that shows:
|
|
62
|
+
• Board model detection
|
|
63
|
+
• CPU temperature monitoring
|
|
64
|
+
• On-board LED blinking
|
|
65
|
+
Works on Pico 2 W and all full-size Raspberry Pi models.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def setup(self):
|
|
69
|
+
# Initialise the hardware adapter
|
|
70
|
+
self.hw = PiHardwareAdapter(log_func=self.log)
|
|
71
|
+
|
|
72
|
+
# Use the onboard LED (works on Pico and full RPi)
|
|
73
|
+
self.status_led = self.hw.led("LED" if "Pico" in self.hw.model else 13)
|
|
74
|
+
|
|
75
|
+
# Log startup info
|
|
76
|
+
self.log(f"Running on: {self.hw.model}")
|
|
77
|
+
self.log(f"Initial CPU temperature: {self.hw.cpu_temperature}°C")
|
|
78
|
+
|
|
79
|
+
def run(self):
|
|
80
|
+
while self.running:
|
|
81
|
+
# Blink the LED
|
|
82
|
+
self.status_led.value(1)
|
|
83
|
+
time.sleep(0.5)
|
|
84
|
+
self.status_led.value(0)
|
|
85
|
+
|
|
86
|
+
# Log temperature every 5 seconds
|
|
87
|
+
self.log(f"CPU temperature: {self.hw.cpu_temp}°C")
|
|
88
|
+
time.sleep(4.5)
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
SimpleDemo(max_log_files=10).start()
|
|
92
|
+
|
|
93
|
+
Compatibility
|
|
94
|
+
|
|
95
|
+
Pico 2 W (MicroPython): Uses machine for pins/PWM.
|
|
96
|
+
Full RPi (Python): Uses RPi.GPIO and gpiozero for pins/PWM.
|
|
97
|
+
|
|
98
|
+
Features
|
|
99
|
+
|
|
100
|
+
RPIApp: Base class for app lifecycle and logging.
|
|
101
|
+
DeviceManager: Base for hardware managers with logging.
|
|
102
|
+
LEDSimple: Controls LEDs (on/off/blink).
|
|
103
|
+
WiFiManager: Manages WiFi connections (Pico only).
|
|
104
|
+
MotorDriverTB6612FNG: Controls dual DC motors.
|
|
105
|
+
MicrodotManager: Runs a lightweight web server.
|
|
106
|
+
|
|
107
|
+
For example applications see projects at www.singerlinks.com
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
rpi_app_framework/__init__.py,sha256=Z_o15zcbhnaMdkriPiE_JJdKHMGvEZk6aMjgQczoZZc,442
|
|
2
|
+
rpi_app_framework/device_manager.py,sha256=iyMJWQhTOXUaUrIhLoYEV-o-rkMgBfvvLTElNy4f1iM,3036
|
|
3
|
+
rpi_app_framework/hardware.py,sha256=23s0SgGZTuopNfoSbibG07w9PoBLXg4C7CWmzpN-ZAM,4438
|
|
4
|
+
rpi_app_framework/led_simple.py,sha256=7AHO3QrOTu7T2yRKjNSxpuhXBhqugFlgoG5x1X0VOgc,2459
|
|
5
|
+
rpi_app_framework/microdot_manager.py,sha256=PEjB_TS1yCI4sxakhgxghb18adKnujnunFZJIm9ojF8,2900
|
|
6
|
+
rpi_app_framework/motor_driver_tb6612.py,sha256=c2yhHqCawb7cNd_99fqWVWGIXQjTw3cACGsyLpVb3H4,12070
|
|
7
|
+
rpi_app_framework/rpi_app.py,sha256=LR0Xp0pMnub2ioSZB0SImRjz9gtlJZ14wfrBu0tg4fY,7629
|
|
8
|
+
rpi_app_framework/wifi_manager.py,sha256=06XYB90KtG_xh6meOMbUOqgyxoi1gtr5gV8yEOQsJ54,7074
|
|
9
|
+
rpi_app_framework-0.1.2.dist-info/licenses/LICENSE,sha256=iiV86G80RSWaWnXbHY3UfW1tmSHObi-BY4UOB882FkA,1067
|
|
10
|
+
rpi_app_framework-0.1.2.dist-info/METADATA,sha256=qlae74VWY7QRfzvUMjzrbA-LNSebTQkdV_Qo8SWdULk,3783
|
|
11
|
+
rpi_app_framework-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
rpi_app_framework-0.1.2.dist-info/top_level.txt,sha256=OhCegglLhoeGuSaAVVE2kNYZjAkFVaCr9XMWNunrcYQ,18
|
|
13
|
+
rpi_app_framework-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 John Singer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rpi_app_framework
|