Skip to content
Draft
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# kabumm
KABel Unterbrechen Mit Methode

## Subfolders
* [python gamemaster](./python)
12 changes: 12 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[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",
"RPi.GPIO",
# 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"


Empty file added python/src/kabumm/__init__.py
Empty file.
91 changes: 91 additions & 0 deletions python/src/kabumm/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import logging
import contextlib

import board
import neopixel
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_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=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
self.num_leds = num_wires_and_pixels
self.num_wires = num_wires_and_pixels


def write(self, string):
logger.info("display %s", 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)
self.pixels[:] = values
self.pixels.show()

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() -> 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(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=_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 = chip.request_lines({port: settings for port in port_number_of_pixel})
try:
yield RaspiBackend(request, num_wires_and_pixels, port_number_of_pixel)
finally:
request.release()
logger.info("backend cleaned up during normal exit")




144 changes: 144 additions & 0 deletions python/src/kabumm/game.py
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 27 additions & 0 deletions python/src/kabumm/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import argparse
import logging
import time


from kabumm.backend import make_backend
from kabumm.game import Game

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()
logging.basicConfig(level={0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}[args.verbose])
return args

def main():
args = parse_args()
with make_backend(num_wires_and_pixels=args.number) as backend:
backend.set_pixels([(i, 0, 0) for i in range(args.number)])
game = Game(backend)
while not game.finished:
game.tick()
time.sleep(0.1)