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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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