Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Test

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
pytest:
name: Run tests on virtual hardware
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install pytest
run: pip install pytest
- name: Run tests
run: python -m pytest tests/ -v
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Marks the repo root for pytest so tests can import the src package.
15 changes: 10 additions & 5 deletions docs/developer_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,21 @@ entering the ```repl``` and importing the required classes.
Light shows and other effects can be activated such as the following.

```python
import asyncio
from src.hardware import get_hardware
from src.gunpla.nu_gundam import NuGundam
g = NuGundam()
g.activation(None)
g = NuGundam(get_hardware())
asyncio.run(g.activation())
```

Individual effects can be used as such on a individual LED directly

```python
from src.pi.LED import LED
import asyncio
from src.hardware import get_hardware
from src.pi.led_effect import LEDEffects
led = LED(0, "0")
LEDEffects.fire(led)
hardware = get_hardware()
led = hardware.create_led(0, "head")
asyncio.run(LEDEffects.fire(led))
asyncio.run(LEDEffects(hardware).brighten(led))
```
13 changes: 4 additions & 9 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import asyncio

import src
import src.hardware
from src import settings
from src.hardware.Hardware import Hardware
from src.server.webserver import WebServer
from src.server.webserver import run_server


def main():
hardware: Hardware = src.hardware.get_hardware()
webserver = WebServer(settings.webserver, hardware)
asyncio.run( webserver.run())
run_server(settings.webserver, src.hardware.get_hardware())


if __name__ == "__main__":
main()
main()
6 changes: 5 additions & 1 deletion src/config.py.template
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ webserver = {
"ssid": '<replace with your WLAN SSID>',
"password": '<replace with your WLAN password>',
"hostname": 'gunpla.local', # replace with a desired hostname
"model": GenericGundam # The gunpla you have (class reference, not an instance)
"model": GenericGundam, # The gunpla you have (class reference, not an instance)
# Optional server settings, shown with their defaults:
# "host": '0.0.0.0',
# "port": 80,
# "debug": True,
}
7 changes: 4 additions & 3 deletions src/gunpla/base_gundam.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import asyncio
import json

from src.pi.disabled_LED import DisabledLED
from src.pi.LED import LED
from src.pi.led_effect import LEDEffects


class BaseGundam:
Expand All @@ -17,8 +17,8 @@ def __init__(self, hardware, config: dict = None):
"""
from src.hardware.Hardware import Hardware
self.hardware: Hardware = hardware
self.effects = LEDEffects(hardware)
self._leds = {}
self.lightshow_lock = asyncio.Lock()
if config is not None:
self.config: json = config
else:
Expand Down Expand Up @@ -64,10 +64,11 @@ def all_off(self) -> None:
for led in self.get_all_leds():
led.off()

def get_all_leds(self, ignore_list: list[str] = []) -> list[LED]:
def get_all_leds(self, ignore_list: list[str] = None) -> list[LED]:
"""
Returns all LEDs configured, enabled or disabled. But not the board_led
"""
ignore_list = ignore_list or []
leds = []
for led_entry in self.config['leds']:
led_name = led_entry['name']
Expand Down
7 changes: 3 additions & 4 deletions src/gunpla/nu_gundam.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import random

from src.gunpla.base_gundam import BaseGundam
from src.pi.led_effect import LEDEffects


class NuGundam(BaseGundam):
Expand All @@ -26,15 +25,15 @@ async def activation(self) -> None:
await asyncio.sleep(0.1)
head_led.off()
await asyncio.sleep(0.5)
await LEDEffects.brighten(head_led)
await self.effects.brighten(head_led)

async def fire_funnels(self) -> None:
"""
Light Show that fires fin funnels in order
"""
for i in range(1, 7):
funnel = self._get_led_from_name(f"fin_funnel_{i}")
await LEDEffects.fire(funnel)
await self.effects.fire(funnel)

async def random_funnels(self) -> None:
"""
Expand All @@ -50,5 +49,5 @@ async def random_funnels(self) -> None:

while True:
funnel = random.choice(funnels)
await LEDEffects.charge_fire(funnel)
await self.effects.charge_fire(funnel)
await asyncio.sleep(random.uniform(0, 3))
3 changes: 1 addition & 2 deletions src/gunpla/unicorn_banshee.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio

from src.gunpla.base_gundam import BaseGundam
from src.pi.led_effect import LEDEffects


class UnicornBansheeGundam(BaseGundam):
Expand All @@ -19,6 +18,6 @@ async def glow(self) -> None:
"""
Runs the glow lightshow
"""
await LEDEffects.brighten_all(self.get_all_leds())
await self.effects.brighten_all(self.get_all_leds())
await asyncio.sleep(3)
self.all_off()
8 changes: 4 additions & 4 deletions src/hardware/Hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ def get_pin(self, pin_num, mode):
def get_pwm(self, pin_obj):
raise NotImplementedError

def reset_pin(self, pin_obj):
def reset_pin(self, pin):
"""
Reinitializes a pin previously used for PWM back to plain digital output.
:param pin_obj: The Pin object to reinitialize
:return: The same Pin object, ready for plain digital I/O
:param pin: The Pin object to reinitialize
:return: A Pin object ready for plain digital I/O on the same GPIO
"""
raise NotImplementedError

Expand All @@ -33,6 +33,6 @@ def create_led(self, pin_number: int, name: str):
Creates an LED instance appropriate for this hardware.
:param pin_number: GPIO pin number
:param name: LED name
:return: LED instance (LED or MockLED)
:return: LED instance appropriate for this hardware
"""
raise NotImplementedError
8 changes: 4 additions & 4 deletions src/hardware/PicoHardwre.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ def get_pin(self, pin_num, mode="OUT"):
def get_pwm(self, pin_obj):
return self.PWM(pin_obj)

def reset_pin(self, pin_obj):
"""Re-initializes the pin in place to clear PWM settings"""
pin_obj.init(self.Pin.OUT)
return pin_obj
def reset_pin(self, pin):
"""Re-initializes the pin to clear PWM settings. machine.Pin accepts an
existing Pin object as the id, so cached LED pins are re-muxed in place."""
return self.Pin(pin, self.Pin.OUT)

def create_led(self, pin_number: int, name: str):
"""Creates a real LED with actual GPIO pin"""
Expand Down
121 changes: 66 additions & 55 deletions src/hardware/VirtualHardware.py
Original file line number Diff line number Diff line change
@@ -1,90 +1,101 @@
import src.hardware
from src.hardware.Hardware import Hardware
from src.hardware.Networking import Networking
from src.pi.board_led import BoardLED
from src.pi.LED import LED


class VirtualHardware(Hardware):
class MockPin:
"""
Virtual Hardware
Fake implementation so the webserver can run without a physical Raspberry Pi Pico connected to it.
Partial implementation of Pico Pin, only using the currently needed methods
"""
class MockPin:
"""
Partial implementation of Pico Pin, only using the currently needed methods
"""

def __init__(self, num):
self.num = num
def __init__(self, num):
self.num = num

def on(self):
print(f"[SIM] Pin {self.num} ON")
def on(self):
print(f"[SIM] Pin {self.num} ON")

def off(self):
print(f"[SIM] Pin {self.num} OFF")
def off(self):
print(f"[SIM] Pin {self.num} OFF")

class MockPWM:
"""
Partial implementation of Pico PWM, only using the currently needed methods
"""

def __init__(self, p):
self.p = p
class MockPWM:
"""
Partial implementation of Pico PWM, only using the currently needed methods
"""

def freq(self, f):
pass
def __init__(self, p):
self.p = p

def duty_u16(self, d):
print(f"[SIM] PWM {self.p.num} @ {d}")
def freq(self, f):
pass

def deinit(self):
print(f"[SIM] PWM {self.p.num} De-initialized")
def duty_u16(self, d):
print(f"[SIM] PWM {self.p.num} @ {d}")

class NoOpNetworking(Networking):
"""
Networking implementation that does nothing
"""
def deinit(self):
print(f"[SIM] PWM {self.p.num} De-initialized")

def __init__(self):
pass

async def connect_to_wifi(self, ssid: str, password: str, attempts=10) -> str:
return "123.123.123.123"
class MockLED(LED):
"""
LED implementation for simulation that prints actions to console.
Used when running with VirtualHardware for testing without physical hardware.
"""

def on(self):
"""Turns on the LED with simulation output"""
print(f"[SIM] LED '{self._led_name}' (Pin {self._pin.num}) ON")
self._pin.on()

def off(self):
"""Turns off the LED with simulation output"""
print(f"[SIM] LED '{self._led_name}' (Pin {self._pin.num}) OFF")
self._pin.off()

def configure_host(self, host_name: str):
pass

class MockBoardLED(BoardLED):
"""
Fake implementation of the onboard led.
"""
class NoOpNetworking(Networking):
"""
Networking implementation that does nothing
"""

def __init__(self):
self._pin = src.hardware.VirtualHardware.MockPin(1)
self._led_name = "Mock Board LED"
def __init__(self):
pass

async def connect_to_wifi(self, ssid: str, password: str, attempts=10) -> str:
return "123.123.123.123"

def configure_host(self, host_name: str):
pass


class VirtualHardware(Hardware):
"""
Virtual Hardware
Fake implementation so the webserver can run without a physical Raspberry Pi Pico connected to it.
"""

def __init__(self):
self.pin = self.MockPin
self.pwm = self.MockPWM
# Cached like PicoHardware, so both implementations behave the same
self._networking = NoOpNetworking()
self._board_led = BoardLED(MockPin("LED"))

def get_pin(self, pin_num, mode="OUT"):
return self.pin(pin_num)
return MockPin(pin_num)

def get_pwm(self, pin_obj):
return self.pwm(pin_obj)
return MockPWM(pin_obj)

def board_led(self) -> BoardLED:
return self.MockBoardLED()
return self._board_led

def networking(self) -> Networking:
return self.NoOpNetworking()
return self._networking

def reset_pin(self, pin_obj):
print(f"[SIM] Pin {pin_obj.num} reset to standard GPIO")
return pin_obj
def reset_pin(self, pin):
print(f"[SIM] Pin {pin.num} reset to standard GPIO")
return pin

def create_led(self, pin_number: int, name: str):
"""Creates a mock LED for simulation"""
from src.pi.LED import MockLED
pin = self.get_pin(pin_number, mode="OUT")
return MockLED(pin, name)
return MockLED(self.get_pin(pin_number), name)
7 changes: 5 additions & 2 deletions src/hardware/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import sys

from src.hardware.Hardware import Hardware
from src.hardware.PicoHardwre import PicoHardware
from src.hardware.VirtualHardware import VirtualHardware


def get_hardware() -> Hardware:
"""
:return: the appropriate hardware

Imports are done lazily so the Pico never loads the mock implementation
(and the laptop never touches machine/network).
"""
if sys.platform == 'rp2':
from src.hardware.PicoHardwre import PicoHardware
return PicoHardware()
from src.hardware.VirtualHardware import VirtualHardware
return VirtualHardware()
Loading
Loading