From 1e303199989f2a47ec8c85c3d5c850d1235100d9 Mon Sep 17 00:00:00 2001 From: hephaisto <1156478+hephaisto@users.noreply.github.com> Date: Tue, 19 Aug 2025 21:46:43 +0200 Subject: [PATCH 1/4] add package definition and stub main --- README.md | 3 +++ python/README.md | 12 ++++++++++++ python/pyproject.toml | 36 +++++++++++++++++++++++++++++++++++ python/src/kabumm/__init__.py | 0 python/src/kabumm/run.py | 15 +++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 python/README.md create mode 100644 python/pyproject.toml create mode 100644 python/src/kabumm/__init__.py create mode 100644 python/src/kabumm/run.py diff --git a/README.md b/README.md index 4cffc98..fe78f9d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # kabumm KABel Unterbrechen Mit Methode + +## Subfolders +* [python gamemaster](./python) diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..bbf1386 --- /dev/null +++ b/python/README.md @@ -0,0 +1,12 @@ +# kabumm python game master + +This folder contains the python code for the game master on a raspberry pi. + +## Installation on raspbian + +**todo**: install overlay for port expander +**todo**: enable I2C + + python3 -m venv env + source env/bin/activate + python -m pip install --editable git+https://github.com/hackffm/kabumm.git@master#subdirectory=python diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..17e7c02 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "kabumm" +version = "0.0.1" +description = "A bomb defusal game" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +keywords = ["openscad", "stl"] +authors = [ + { name = "hephaisto" }, +] +classifiers = [ + "Programming Language :: Python :: 3", +] +dependencies = [ + # port expander + "gpiod >= 2.0.2", + # segment display + "adafruit-circuitpython-ht16k33", + # neopixel + "adafruit-circuitpython-neopixel", + "adafruit-blinka", + "rpi_ws281x", +] + +[project.urls] +source = "https://github.com/hackffm/kabumm" + +[project.scripts] +kabumm = "kabumm.run:main" + + diff --git a/python/src/kabumm/__init__.py b/python/src/kabumm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/kabumm/run.py b/python/src/kabumm/run.py new file mode 100644 index 0000000..e65ac49 --- /dev/null +++ b/python/src/kabumm/run.py @@ -0,0 +1,15 @@ +import argparse +import logging + +def parse_args(): + parser = argparse.ArgumentParser() + + parser.add_argument("--verbose", "-v", action="count", default=0, help="increase verbosity (can be used multiple times)") + + args = parser.parse_args() + logging.basicConfig(level={0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}[args.verbose]) + return args + +def main(): + args = parse_args() + From 5e8b7bcc5c27b2a073bbf28b8e860011c6b78fae Mon Sep 17 00:00:00 2001 From: hephaisto <1156478+hephaisto@users.noreply.github.com> Date: Tue, 19 Aug 2025 22:49:46 +0200 Subject: [PATCH 2/4] add backend --- python/src/kabumm/backend.py | 82 ++++++++++++++++++++++++++++++++++++ python/src/kabumm/run.py | 8 ++++ 2 files changed, 90 insertions(+) create mode 100644 python/src/kabumm/backend.py diff --git a/python/src/kabumm/backend.py b/python/src/kabumm/backend.py new file mode 100644 index 0000000..ed5f7c1 --- /dev/null +++ b/python/src/kabumm/backend.py @@ -0,0 +1,82 @@ +import logging +import contextlib + +import board +import neopixel +import gpoid +from adafruit_ht16k33.segments import Seg14x4 + +logger = logging.getLogger(__name__) + +class RaspiBackend: + def __init__(self, gpio_request, num_wires_and_pixels: int, port_number_of_pixel: list[int]): + logger.debug("creating display...") + self.i2c = board.I2C() + self.display = Seg14x4(self.i2c, address=0x70) + logger.debug("created display") + + logger.debug("creating pixels...") + self.pixels = neopixel.NeoPixel(board.D18, num_wires_and_pixels, pixel_order=GRB, auto_write=False) + self.pixels.fill((0,0,0)) + logger.debug("created pixels") + + self.gpio_request = gpio_request + + + def write(string): + logger.info("display %s", string) + self.display.print(string) + + def set_pixels(values): + logger.info("set pixels to %s", values) + self.pixels[:] = values + + def is_cut() -> list[bool]: + gpio_lines = self.gpio_request.get_values(self.port_number_of_pixel) + values = [gpio_lines[port] == gpiod.line.Value.ACTIVE for port in self.port_number_of_pixel] + logger.debug("read wires: %s", "".join(hex(i)[-1] if values[i] else "_" for i in range(len(self.port_number_of_pixel)))) + return values + +def _get_chip_path(self) -> str: + candidates = [] + for i in range(10): + path = f"/dev/gpiochip{i}" + try: + chip = gpiod.Chip(path) + info = chip.get_info() + if info.num_lines != 16: + logger.debug("gpio %s has %i lines, not a possible candidate", path, info.num_lines) + continue + candidates.append(path) + except Exception as e: + logger.debug("gpio %s excluded: %s", path, e) + if len(candidates) == 0: + raise RuntimeError("no suitable gpio expander chip found") + elif len(candidates) > 1: + raise RuntimeError("found %i matching gpio expanders (%s), not sure which one to take", len(candidates), ", ".join(candidates)) + return candidates[0] + + + +@contextlib.contextmanager +def make_backend(num_wires_and_pixels: int, port_number_of_pixel: list[int]|None=None): + if not port_number_of_pixel: + port_number_of_pixel = list(range(num_wires_and_pixels)) + assert len(self.port_number_of_pixel) == num_wires_and_pixels + assert len(set(self.port_number_of_pixel)) == num_wires_and_pixels, "port_number_of_pixel values must be unique" + assert all((v <16 for v in self.port_number_of_pixel)), "port numbers must be <16" + + logger.debug("creating gpio expander chip...") + with gpiod.Chip(path=self._get_chip_path()) as chip: + logger.debug("created gpio expander chip") + settings = gpiod.LineSettings(direction=gpiod.line.Direction.INPUT, bias=gpiod.line.Bias.PULL_UP) + request = selfchip.request_lines({port: settings for port in port_number_of_pixel}) + try: + yield RaspiBackend(num_wires_and_pixels, request, port_number_of_pixel) + finally: + request.release() + logger.info("backend cleaned up during normal exit") + + + + diff --git a/python/src/kabumm/run.py b/python/src/kabumm/run.py index e65ac49..896e856 100644 --- a/python/src/kabumm/run.py +++ b/python/src/kabumm/run.py @@ -1,9 +1,12 @@ import argparse import logging +from kabumm.backend import make_backend + def parse_args(): parser = argparse.ArgumentParser() + parser.add_argument("--number", "-n", type=int, default=16, help="number of leds/wires") parser.add_argument("--verbose", "-v", action="count", default=0, help="increase verbosity (can be used multiple times)") args = parser.parse_args() @@ -12,4 +15,9 @@ def parse_args(): def main(): args = parse_args() + with make_backend(num_wires_and_pixels=args.number) as backend: + backend.write("BUMM") + backend.set_pixels([(i, 0, 0) for i in range(args.number)]) + for i in range(10): + print(backend.is_cut()) From f7fd615ac067076f6e5a29bc29e8aeb0154f33d3 Mon Sep 17 00:00:00 2001 From: kabumm raspi Date: Wed, 24 Sep 2025 21:30:55 +0200 Subject: [PATCH 3/4] Fix initial commit --- python/pyproject.toml | 1 + python/src/kabumm/backend.py | 35 +++++++++++++++++++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 17e7c02..64df5a9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ dependencies = [ # port expander "gpiod >= 2.0.2", + "RPi.GPIO", # segment display "adafruit-circuitpython-ht16k33", # neopixel diff --git a/python/src/kabumm/backend.py b/python/src/kabumm/backend.py index ed5f7c1..0c1db6f 100644 --- a/python/src/kabumm/backend.py +++ b/python/src/kabumm/backend.py @@ -3,41 +3,44 @@ import board import neopixel -import gpoid +import gpiod from adafruit_ht16k33.segments import Seg14x4 logger = logging.getLogger(__name__) class RaspiBackend: - def __init__(self, gpio_request, num_wires_and_pixels: int, port_number_of_pixel: list[int]): + def __init__(self, gpio_request, num_wires_and_pixels: int, port_number_of_pixels: list[int]): logger.debug("creating display...") self.i2c = board.I2C() self.display = Seg14x4(self.i2c, address=0x70) logger.debug("created display") logger.debug("creating pixels...") - self.pixels = neopixel.NeoPixel(board.D18, num_wires_and_pixels, pixel_order=GRB, auto_write=False) + self.pixels = neopixel.NeoPixel(board.D18, num_wires_and_pixels, pixel_order=neopixel.GRB, auto_write=False) self.pixels.fill((0,0,0)) + self.pixels.show() logger.debug("created pixels") self.gpio_request = gpio_request + self.port_number_of_pixels = port_number_of_pixels - def write(string): + def write(self, string): logger.info("display %s", string) self.display.print(string) - def set_pixels(values): + def set_pixels(self, values): logger.info("set pixels to %s", values) self.pixels[:] = values + self.pixels.show() - def is_cut() -> list[bool]: - gpio_lines = self.gpio_request.get_values(self.port_number_of_pixel) - values = [gpio_lines[port] == gpiod.line.Value.ACTIVE for port in self.port_number_of_pixel] - logger.debug("read wires: %s", "".join(hex(i)[-1] if values[i] else "_" for i in range(len(self.port_number_of_pixel)))) + def is_cut(self) -> list[bool]: + gpio_lines = self.gpio_request.get_values(self.port_number_of_pixels) + values = [gpio_lines[port] == gpiod.line.Value.ACTIVE for port in self.port_number_of_pixels] + logger.debug("read wires: %s", "".join(hex(i)[-1] if values[i] else "_" for i in range(len(self.port_number_of_pixels)))) return values -def _get_chip_path(self) -> str: +def _get_chip_path() -> str: candidates = [] for i in range(10): path = f"/dev/gpiochip{i}" @@ -62,17 +65,17 @@ def _get_chip_path(self) -> str: def make_backend(num_wires_and_pixels: int, port_number_of_pixel: list[int]|None=None): if not port_number_of_pixel: port_number_of_pixel = list(range(num_wires_and_pixels)) - assert len(self.port_number_of_pixel) == num_wires_and_pixels - assert len(set(self.port_number_of_pixel)) == num_wires_and_pixels, "port_number_of_pixel values must be unique" - assert all((v <16 for v in self.port_number_of_pixel)), "port numbers must be <16" + assert len(port_number_of_pixel) == num_wires_and_pixels + assert len(set(port_number_of_pixel)) == num_wires_and_pixels, "port_number_of_pixel values must be unique" + assert all((v <16 for v in port_number_of_pixel)), "port numbers must be <16" logger.debug("creating gpio expander chip...") - with gpiod.Chip(path=self._get_chip_path()) as chip: + with gpiod.Chip(path=_get_chip_path()) as chip: logger.debug("created gpio expander chip") settings = gpiod.LineSettings(direction=gpiod.line.Direction.INPUT, bias=gpiod.line.Bias.PULL_UP) - request = selfchip.request_lines({port: settings for port in port_number_of_pixel}) + request = chip.request_lines({port: settings for port in port_number_of_pixel}) try: - yield RaspiBackend(num_wires_and_pixels, request, port_number_of_pixel) + yield RaspiBackend(request, num_wires_and_pixels, port_number_of_pixel) finally: request.release() logger.info("backend cleaned up during normal exit") From 720e93f72b814f281a27151d0a4433c3e2981b67 Mon Sep 17 00:00:00 2001 From: kabumm raspi Date: Wed, 24 Sep 2025 23:47:22 +0200 Subject: [PATCH 4/4] Add gamelogic --- python/src/kabumm/backend.py | 8 +- python/src/kabumm/game.py | 144 +++++++++++++++++++++++++++++++++++ python/src/kabumm/run.py | 10 ++- 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 python/src/kabumm/game.py diff --git a/python/src/kabumm/backend.py b/python/src/kabumm/backend.py index 0c1db6f..30206d2 100644 --- a/python/src/kabumm/backend.py +++ b/python/src/kabumm/backend.py @@ -23,11 +23,17 @@ def __init__(self, gpio_request, num_wires_and_pixels: int, port_number_of_pixel self.gpio_request = gpio_request self.port_number_of_pixels = port_number_of_pixels + self.num_leds = num_wires_and_pixels + self.num_wires = num_wires_and_pixels def write(self, string): logger.info("display %s", string) - self.display.print(string) + try: + self.display.print(string) + except OSError as e: + logger.warning("error while writing to display: %s", e) + self.display.print(string) def set_pixels(self, values): logger.info("set pixels to %s", values) diff --git a/python/src/kabumm/game.py b/python/src/kabumm/game.py new file mode 100644 index 0000000..647b265 --- /dev/null +++ b/python/src/kabumm/game.py @@ -0,0 +1,144 @@ +import datetime +import random +import logging + +logger = logging.getLogger(__name__) + + +def now(): + return datetime.datetime.now() + +class Countdown: + def __init__(self, backend): + self.backend = backend + self.end_time = None + self.punishments = 0 + + def init(self, remaining_seconds): + self.end_time = now() + datetime.timedelta(seconds=remaining_seconds) + + def tick(self): + if self.time_over(): + self.backend.write("BUMM") + else: + if self.punishments > 0: + self.punishments -= 1 + self.end_time -= datetime.timedelta(seconds=1) + self.print_time() + + def time_over(self) -> bool: + return now() > self.end_time + + def punish(self, seconds): + self.punishments += seconds + + def remaining_time(self) -> tuple[int, int]: + seconds = (self.end_time - now()).seconds + s = seconds % 60 + m = seconds // 60 + return m, s + + def print_time(self): + m, s = self.remaining_time() + self.backend.write(f"{m:0>2}{s:0>2}") + +class Level: + def __init__(self, game_state): + self.game_state = game_state + self.selected_leds = [] + + def select_random_leds(self, count=1): + self.selected_leds = random.sample(self.game_state.remaining_leds, k=count) + + def tick(self) -> bool: + raise NotImplementedError() + +class StandardLevel(Level): + def __init__(self, backend, game_state, time_between_rerandomization: int|None, num_selected: int=1): + super().__init__(game_state) + self.backend = backend + self.time_between_rerandomizations = time_between_rerandomization + self.num_selected = num_selected + if num_selected > 1: + self.time_between_rerandomizations = None + logger.warning("disabled rerandomization for multi-LED level") + self.select() + + def select(self): + self.select_random_leds(self.num_selected) + + def leds_cut(self) -> tuple[bool, bool]: + cuts = self.backend.is_cut() + correct_cuts = [] + for i in self.selected_leds: + if cuts[i]: + logger.info("correct cut at LED %i", i) + correct_cuts.append(i) + for i in self.game_state.remaining_leds: + if cuts[i]: + logger.info("WRONG cut at LED %i", i) + self.punish() + for cut in correct_cuts: + self.selected_leds.remove(cut) + + def punish(self): + self.game_state.punish(60) + + def tick(self) -> bool: + # TODO: randomization + self.animate() + self.leds_cut() + return len(self.selected_leds) == 0 + + def animate(self): + raise NotImplementedError() + + +class LevelGreenRed1(StandardLevel): + def __init__(self, backend, game_state): + super().__init__(backend, game_state, None, 1) + + def animate(self): + red = (255, 0, 0) + green = (0, 255, 0) + for led in self.game_state.remaining_leds: + self.game_state.leds[led] = green if led in self.selected_leds else red + +class GameState: + def __init__(self, backend, countdown, num_leds): + self.backend = backend + self.countdown = countdown + self.leds = [(0, 0, 0) for i in range(num_leds)] + self.remaining_leds = [i for i in range(num_leds)] + + def punish(self, seconds): + self.countdown.punish(seconds) + + def tick(self): + leds = [led if i in self.remaining_leds else (0, 0, 0) for i, led in enumerate(self.leds)] + self.backend.set_pixels(leds) + + +class Game: + def __init__(self, backend): + self.backend = backend + self.countdown = Countdown(backend) + self.countdown.init(5*60) + self.game_state = GameState(backend, self.countdown, backend.num_wires) + self.remaining_levels = [ + LevelGreenRed1(backend, self.game_state), + ] + self.finished = False + + def tick(self): + if self.finished: + return + level_finished = self.remaining_levels[0].tick() + if level_finished: + logger.info("finished level") + self.remaining_levels.pop(0) + if not remaining_levels: + logger.info("finished game") + self.finished = False + self.game_state.tick() + self.countdown.tick() diff --git a/python/src/kabumm/run.py b/python/src/kabumm/run.py index 896e856..6d70e0e 100644 --- a/python/src/kabumm/run.py +++ b/python/src/kabumm/run.py @@ -1,7 +1,10 @@ import argparse import logging +import time + from kabumm.backend import make_backend +from kabumm.game import Game def parse_args(): parser = argparse.ArgumentParser() @@ -16,8 +19,9 @@ def parse_args(): def main(): args = parse_args() with make_backend(num_wires_and_pixels=args.number) as backend: - backend.write("BUMM") backend.set_pixels([(i, 0, 0) for i in range(args.number)]) - for i in range(10): - print(backend.is_cut()) + game = Game(backend) + while not game.finished: + game.tick() + time.sleep(0.1)