vincibot 0.2.0__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.
vincibot/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """vincibot – den VinciBot (MatataStudio) per Bluetooth mit Python steuern."""
2
+ from .roboter import VinciBot
3
+ from .asynchron import VinciBotAsync
4
+ from ._protokoll import Sensorwerte
5
+ from ._typen import Farbe
6
+ from ._woerter import RoboterFehler, FARBEN
7
+ from ._verbindung import VerbindungsFehler
8
+
9
+ __all__ = ["VinciBot", "VinciBotAsync", "RoboterFehler", "VerbindungsFehler", "Sensorwerte", "Farbe", "FARBEN"]
10
+ __version__ = "0.2.0"
vincibot/_protokoll.py ADDED
@@ -0,0 +1,109 @@
1
+ """Internes: Aufbau der Bluetooth-Pakete (für Anfänger nicht nötig)."""
2
+ from collections.abc import Iterable
3
+ from typing import TypedDict
4
+
5
+ class Sensorwerte(TypedDict):
6
+ """Die aktuellen Messwerte des Roboters."""
7
+ hell_links: int
8
+ hell_rechts: int
9
+ linie: list[int]
10
+ geraeusch: int
11
+ farbe: tuple[int, int, int]
12
+ abstand_cm: float
13
+ lautstaerke: int
14
+ tempo: int
15
+
16
+
17
+ # Sequenznummern: Der Bot nummeriert Befehlsarten durch und antwortet mit derselben Nummer.
18
+ SEQUENZ: dict[str, int] = {
19
+ "strecke": 0, "winkel": 1, "fahrt_start": 2, "fahrt_stopp": 3,
20
+ "strecke_tempo": 4, "winkel_tempo": 5, "motor_lauf": 6, "motor_tempo": 8, "motor_stopp": 9,
21
+ "tanz": 10, "emotion": 11, "verhalten": 12, "bild": 16, "text": 17, "anzeige_aus": 18,
22
+ "helligkeit": 19, "pixel": 20, "ausrichtung": 21, "led_feld": 22, "anzeige_drehen": 23,
23
+ "led_alle": 26, "led_einzeln": 27, "led_aus": 28,
24
+ "effekt": 31, "lied": 33, "sprich": 35, "datei": 37, "ton_stopp": 38,
25
+ "lautstaerke": 39, "trommel": 43, "pause": 44, "note": 45, "instrument": 46, "tempo": 47,
26
+ "ir_senden": 52, "abfrage": 0,
27
+ }
28
+
29
+ # Begrüßung nach dem Verbinden (wie in der offiziellen Web-Oberfläche)
30
+ BEGRUESSUNG: list[list[int]] = [[126, 2, 2, 0, 0], [1, 1]]
31
+
32
+
33
+ def pruefsumme(daten: Iterable[int], startwert: int = 0xFFFF) -> int:
34
+ ergebnis = startwert
35
+ for byte in daten:
36
+ ergebnis = ((ergebnis >> 8) | (ergebnis << 8)) & 0xFFFF
37
+ ergebnis ^= byte
38
+ ergebnis ^= (ergebnis & 0xFF) >> 4
39
+ ergebnis ^= (ergebnis << 12) & 0xFFFF
40
+ ergebnis ^= ((ergebnis & 0xFF) << 5) & 0xFFFF
41
+ return ergebnis
42
+
43
+
44
+ def _maskiere(byte: int) -> list[int]:
45
+ if byte == 0xFE:
46
+ return [0xFD, 0xDE]
47
+ if byte == 0xFD:
48
+ return [0xFD, 0xDD]
49
+ return [byte]
50
+
51
+
52
+ def baue_paket(nutzdaten: list[int]) -> bytes:
53
+ """Startbyte 0xFE, Länge, Nutzdaten, Prüfsumme (Sonderbytes werden maskiert)."""
54
+ laenge = len(nutzdaten) + 2
55
+ summe = pruefsumme(nutzdaten, pruefsumme([laenge]))
56
+ paket: list[int] = [0xFE] + _maskiere(laenge)
57
+ for byte in nutzdaten:
58
+ paket += _maskiere(byte)
59
+ paket += _maskiere(summe >> 8) + _maskiere(summe & 0xFF)
60
+ return bytes(paket)
61
+
62
+
63
+ def baue_befehlspaket(code: str, sequenz: int) -> bytes:
64
+ """Paket für einen Python-Befehl, den der Bot ausführt."""
65
+ return baue_paket([0x70, 0x00, sequenz] + list(code.encode("ascii", "replace")))
66
+
67
+
68
+ def text_literal(text: object) -> str:
69
+ """Macht aus beliebigem Text ein Python-Textstück für den Bot (Umlaute als \\uXXXX)."""
70
+ teile: list[str] = []
71
+ for zeichen in str(text):
72
+ if zeichen in "\\'\"":
73
+ teile.append("\\" + zeichen)
74
+ elif ord(zeichen) > 127:
75
+ teile.append("\\u%04x" % ord(zeichen))
76
+ else:
77
+ teile.append(zeichen)
78
+ return "'" + "".join(teile) + "'"
79
+
80
+
81
+ def entpacke(puffer: list[int]) -> list[list[int]]:
82
+ """Holt vollständige Pakete aus einem Byte-Puffer. Gibt (liste_von_nutzdaten) zurück
83
+ und entfernt Verarbeitetes aus dem Puffer."""
84
+ pakete: list[list[int]] = []
85
+ while True:
86
+ if 0xFE not in puffer:
87
+ puffer.clear()
88
+ return pakete
89
+ del puffer[:puffer.index(0xFE)]
90
+ roh: list[int] = []
91
+ i = 1
92
+ ende = None
93
+ while i < len(puffer):
94
+ b = puffer[i]
95
+ if b == 0xFD and i + 1 < len(puffer):
96
+ roh.append(0xFE if puffer[i + 1] == 0xDE else 0xFD)
97
+ i += 2
98
+ elif b == 0xFD:
99
+ break
100
+ else:
101
+ roh.append(b)
102
+ i += 1
103
+ if roh and len(roh) >= roh[0] + 1:
104
+ ende = i
105
+ break
106
+ if ende is None:
107
+ return pakete # Paket noch unvollständig
108
+ del puffer[:ende]
109
+ pakete.append(roh[1:roh[0] - 1])
vincibot/_typen.py ADDED
@@ -0,0 +1,45 @@
1
+ """Erlaubte Texte für die Befehle, als `Literal`-Typen.
2
+
3
+ Dadurch erkennt der Editor (Linter) Tippfehler sofort, zum Beispiel `roboter.gefuehl("froelich")`.
4
+ Ein Test (`tests/test_typen.py`) prüft, dass diese Listen zu den Wörterbüchern passen.
5
+ """
6
+ from typing import Literal
7
+
8
+ Farbname = Literal["rot", "gruen", "grün", "blau", "gelb", "orange", "lila", "tuerkis", "türkis",
9
+ "rosa", "weiss", "weiß", "schwarz", "aus"]
10
+ Farbe = Farbname | tuple[int, int, int]
11
+
12
+ Position = Literal["links oben", "links mitte", "links unten",
13
+ "rechts oben", "rechts mitte", "rechts unten"]
14
+
15
+ Gefuehl = Literal["froehlich", "fröhlich", "wuetend", "wütend", "traurig", "ueberrascht",
16
+ "überrascht", "aengstlich", "ängstlich", "angeekelt", "begeistert", "schwindelig",
17
+ "muede", "müde", "schlaefrig", "schläfrig", "wach", "stolz"]
18
+ Tanz = Literal["tango", "walzer", "steppen", "samba", "polonaise", "jazz", "rock", "polka",
19
+ "rumba", "disco", "mazurka"]
20
+ Bewegung = Literal["umschauen", "verletzt", "sprinten", "kreisen", "schuetteln", "schütteln",
21
+ "frech", "stolz"]
22
+ Geraeusch = Literal["krankenwagen", "polizei", "feuerwehr", "zug", "zugpfeife", "schiffshorn",
23
+ "flugzeug", "auto", "automotor", "magnetbahn"]
24
+ Lied = Literal["funkel", "jingle bells", "geburtstag", "freude", "holzsoldaten", "jagdpolka",
25
+ "elise", "forelle", "peter und der wolf"]
26
+ Instrument = Literal["klavier", "e-klavier", "orgel", "gitarre", "e-gitarre", "bass", "zupfbass",
27
+ "cello", "posaune", "klarinette", "saxophon", "floete", "holzfloete", "fagott",
28
+ "chor", "vibraphon", "spieluhr", "steeldrum", "marimba", "synthesizer",
29
+ "weicher synthesizer"]
30
+ Note = Literal["c", "d", "e", "f", "g", "a", "h"]
31
+
32
+ FahrRichtung = Literal["vorwaerts", "vorwärts", "rueckwaerts", "rückwärts", "links", "rechts"]
33
+ Rad = Literal["links", "rechts", "beide"]
34
+ Einheit = Literal["grad", "umdrehungen", "sekunden"]
35
+ Drehrichtung = Literal["uhrzeigersinn", "gegen"]
36
+ DisplayRichtung = Literal["hoch", "runter", "links", "rechts"]
37
+
38
+ Seite = Literal["links", "rechts"]
39
+ SensorFarbe = Literal["weiss", "weiß", "rot", "gelb", "gruen", "grün", "blau", "lila", "schwarz"]
40
+ Linienfarbe = Literal["schwarz", "weiss", "weiß"]
41
+ Farbkanal = Literal["rot", "gruen", "grün", "blau"]
42
+ Bodensensor = Literal[1, 2, 3, 4, 5]
43
+ Taste = Literal["dreieck", "quadrat", "kreis"]
44
+ Fernbedienungstaste = Literal["volume", "mode", "speed", "forward", "backward", "left", "right",
45
+ "play", "dance", "led", "music", "1", "2", "3"]
@@ -0,0 +1,160 @@
1
+ """Internes: Bluetooth-Verbindung (asynchron). Anfänger nutzen stattdessen `VinciBot`."""
2
+ import asyncio
3
+ import shutil
4
+ import sys
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from bleak import BleakClient, BleakScanner
9
+ from bleak.backends.device import BLEDevice
10
+ from bleak.backends.characteristic import BleakGATTCharacteristic
11
+
12
+ from ._protokoll import BEGRUESSUNG, Sensorwerte, baue_befehlspaket, baue_paket, entpacke
13
+
14
+ SCHREIB_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
15
+ MELDUNG_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
16
+ PAKETGROESSE = 20
17
+
18
+
19
+ class VerbindungsFehler(Exception):
20
+ """Der Roboter konnte nicht gefunden oder verbunden werden."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Antwort:
25
+ """Die Antwort des Roboters auf einen Befehl."""
26
+ sequenz: int
27
+ status: int
28
+ text: str
29
+
30
+ @property
31
+ def fehler(self) -> bool:
32
+ return self.status != 0
33
+
34
+ class Verbindung:
35
+ def __init__(self, adresse: str | None = None, namensanfang: str = "VinciBot",
36
+ suchdauer: float = 15) -> None:
37
+ self.adresse: str | None = adresse
38
+ self.namensanfang: str = namensanfang
39
+ self.suchdauer: float = suchdauer
40
+ self.sensoren: Sensorwerte | None = None
41
+ self._client: BleakClient | None = None
42
+ self._puffer: list[int] = []
43
+ self._erwartet: dict[int, asyncio.Future[Antwort]] = {}
44
+ self._begruessung: asyncio.Future[list[int]] | None = None
45
+ self._letzte_meldung: tuple[bytes, float] = (b"", 0.0)
46
+ self.firmware: str | None = None
47
+
48
+ async def _suche(self) -> BLEDevice | None:
49
+ if self.adresse:
50
+ return await BleakScanner.find_device_by_address(self.adresse, timeout=self.suchdauer)
51
+ return await BleakScanner.find_device_by_filter(
52
+ lambda g, _: bool(g.name) and g.name.startswith(self.namensanfang),
53
+ timeout=self.suchdauer)
54
+
55
+ async def _haengende_verbindung_loesen(self) -> bool:
56
+ """Linux: Ein Roboter, der im System noch als 'verbunden' gilt (z. B. nach einem
57
+ abgebrochenen Programm), wird nicht mehr gefunden. Dann trennen wir ihn hier."""
58
+ if not sys.platform.startswith("linux") or shutil.which("bluetoothctl") is None:
59
+ return False
60
+ prozess = await asyncio.create_subprocess_exec(
61
+ "bluetoothctl", "devices", "Connected",
62
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
63
+ ausgabe, _ = await prozess.communicate()
64
+ gefunden = False
65
+ for zeile in ausgabe.decode("utf-8", "replace").splitlines():
66
+ teile = zeile.split(maxsplit=2)
67
+ if len(teile) == 3 and teile[0] == "Device" and (
68
+ teile[1].upper() == (self.adresse or "").upper()
69
+ or (not self.adresse and teile[2].startswith(self.namensanfang))):
70
+ trennen = await asyncio.create_subprocess_exec(
71
+ "bluetoothctl", "disconnect", teile[1],
72
+ stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
73
+ _ = await trennen.wait()
74
+ gefunden = True
75
+ if gefunden:
76
+ await asyncio.sleep(3)
77
+ return gefunden
78
+
79
+ async def verbinden(self) -> None:
80
+ # Erst prüfen, ob der Roboter im System noch als "verbunden" hängt (spart die Wartezeit der Suche).
81
+ _ = await self._haengende_verbindung_loesen()
82
+ geraet = await self._suche()
83
+ if geraet is None:
84
+ raise VerbindungsFehler(
85
+ "Kein VinciBot gefunden. Ist er eingeschaltet und nicht schon mit einem anderen"
86
+ + " Programm oder Browser verbunden?")
87
+ self._client = BleakClient(geraet)
88
+ await self._client.connect()
89
+ await self._client.start_notify(MELDUNG_UUID, self._bei_meldung)
90
+ for nutzdaten in BEGRUESSUNG:
91
+ self._begruessung = asyncio.get_running_loop().create_future()
92
+ await self._sende(baue_paket(nutzdaten))
93
+ try:
94
+ _ = await asyncio.wait_for(self._begruessung, 3)
95
+ except asyncio.TimeoutError:
96
+ pass
97
+
98
+ async def trennen(self) -> None:
99
+ if self._client is not None:
100
+ try:
101
+ await self._client.disconnect()
102
+ finally:
103
+ self._client = None
104
+
105
+ async def _sende(self, paket: bytes) -> None:
106
+ assert self._client is not None, "Nicht verbunden"
107
+ for anfang in range(0, len(paket), PAKETGROESSE):
108
+ await self._client.write_gatt_char(
109
+ SCHREIB_UUID, paket[anfang:anfang + PAKETGROESSE], response=True)
110
+
111
+ def _bei_meldung(self, _: BleakGATTCharacteristic, daten: bytearray) -> None:
112
+ # Der Roboter schickt jede Meldung doppelt; die zweite Kopie wird ignoriert.
113
+ jetzt = time.monotonic()
114
+ if bytes(daten) == self._letzte_meldung[0] and jetzt - self._letzte_meldung[1] < 0.2:
115
+ return
116
+ self._letzte_meldung = (bytes(daten), jetzt)
117
+ self._puffer.extend(daten)
118
+ for nutzdaten in entpacke(self._puffer):
119
+ self._verarbeite(nutzdaten)
120
+
121
+ def _verarbeite(self, n: list[int]) -> None:
122
+ if not n:
123
+ return
124
+ if n[0] == 0x74 and len(n) >= 16:
125
+ self.sensoren = Sensorwerte(
126
+ hell_links=n[1], hell_rechts=n[2], linie=[n[3], n[4], n[5], n[6], n[7]],
127
+ geraeusch=n[8], farbe=(n[9], n[10], n[11]),
128
+ abstand_cm=((n[12] << 8) | n[13]) / 10, lautstaerke=n[14], tempo=n[15])
129
+ elif n[0] == 0x70 and len(n) >= 4:
130
+ antwort = Antwort(n[2], n[3], bytes(n[4:]).decode("utf-8", "replace"))
131
+ erwartet = self._erwartet.pop(antwort.sequenz, None)
132
+ if erwartet and not erwartet.done():
133
+ erwartet.set_result(antwort)
134
+ elif n[0] == 1 and len(n) >= 5:
135
+ self.firmware = f"{n[2]}.{n[3]}.{n[4]}"
136
+ if self._begruessung and not self._begruessung.done():
137
+ self._begruessung.set_result(n)
138
+ elif self._begruessung and not self._begruessung.done():
139
+ self._begruessung.set_result(n)
140
+
141
+ async def firmware_abfragen(self) -> str | None:
142
+ """Fragt die Firmware-Version des Roboters ab, z. B. "1.2.0"."""
143
+ self._begruessung = asyncio.get_running_loop().create_future()
144
+ await self._sende(baue_paket([1, 1]))
145
+ try:
146
+ _ = await asyncio.wait_for(self._begruessung, 3)
147
+ except asyncio.TimeoutError:
148
+ pass
149
+ return self.firmware
150
+
151
+ async def befehl(self, code: str, sequenz: int = 0, zeitlimit: float = 15) -> Antwort | None:
152
+ """Führt einen Python-Befehl auf dem Bot aus und wartet auf seine Antwort."""
153
+ zukunft: asyncio.Future[Antwort] = asyncio.get_running_loop().create_future()
154
+ self._erwartet[sequenz] = zukunft
155
+ await self._sende(baue_befehlspaket(code, sequenz))
156
+ try:
157
+ return await asyncio.wait_for(zukunft, zeitlimit)
158
+ except asyncio.TimeoutError:
159
+ _ = self._erwartet.pop(sequenz, None)
160
+ return None
vincibot/_woerter.py ADDED
@@ -0,0 +1,87 @@
1
+ """Internes: Wörterbücher (Deutsch -> Befehle des Roboters) und Hilfsfunktionen."""
2
+ from collections.abc import Mapping
3
+ from typing import TypeVar
4
+
5
+ from ._typen import Farbe
6
+
7
+ _T = TypeVar("_T")
8
+
9
+ # ---------------------------------------------------------------- Wörterbücher (Deutsch -> Roboter)
10
+ FARBEN: dict[str, tuple[int, int, int]] = {
11
+ "rot": (255, 0, 0), "gruen": (0, 255, 0), "grün": (0, 255, 0), "blau": (0, 0, 255),
12
+ "gelb": (255, 200, 0), "orange": (255, 100, 0), "lila": (160, 0, 255),
13
+ "tuerkis": (0, 220, 220), "türkis": (0, 220, 220), "rosa": (255, 80, 160),
14
+ "weiss": (255, 255, 255), "weiß": (255, 255, 255), "schwarz": (0, 0, 0), "aus": (0, 0, 0),
15
+ }
16
+ FARBEN_SENSOR: dict[str, str] = {"weiss": "white", "weiß": "white", "rot": "red", "gelb": "yellow",
17
+ "gruen": "green", "grün": "green", "blau": "blue", "lila": "purple",
18
+ "schwarz": "black"}
19
+ GEFUEHLE: dict[str, str] = {
20
+ "froehlich": "happy", "fröhlich": "happy", "wuetend": "angry", "wütend": "angry",
21
+ "traurig": "sad", "ueberrascht": "surprised", "überrascht": "surprised",
22
+ "aengstlich": "feared", "ängstlich": "feared", "angeekelt": "disgusted",
23
+ "begeistert": "joyful", "schwindelig": "dizzy", "muede": "fatigued", "müde": "fatigued",
24
+ "schlaefrig": "sleepy", "schläfrig": "sleepy", "wach": "awake", "stolz": "proud",
25
+ }
26
+ TAENZE: dict[str, str] = {"tango": "tango", "walzer": "waltz", "steppen": "tap", "samba": "samba",
27
+ "polonaise": "polonaise", "jazz": "jazz", "rock": "rock", "polka": "polka",
28
+ "rumba": "rumba", "disco": "disco", "mazurka": "mazurka"}
29
+ BEWEGUNGEN: dict[str, str] = {"umschauen": "look around", "verletzt": "hurt", "sprinten": "sprint",
30
+ "kreisen": "circling", "schuetteln": "shake", "schütteln": "shake",
31
+ "frech": "naughty", "stolz": "proud"}
32
+ GERAEUSCHE: dict[str, str] = {
33
+ "krankenwagen": "ambulance", "polizei": "police car", "feuerwehr": "fire engine",
34
+ "zug": "train", "zugpfeife": "train whistle", "schiffshorn": "boat whistle",
35
+ "flugzeug": "aircraft", "auto": "automobile", "automotor": "automobile engine",
36
+ "magnetbahn": "magnetically levitated train",
37
+ }
38
+ LIEDER: dict[str, str] = {
39
+ "funkel": "twinkle", "jingle bells": "jingle bells", "geburtstag": "happy birthday to you",
40
+ "freude": "ode to joy", "holzsoldaten": "parade of the wooden soldiers",
41
+ "jagdpolka": "hunting polka", "elise": "to alice", "forelle": "die forelle",
42
+ "peter und der wolf": "peter and the wolf",
43
+ }
44
+ INSTRUMENTE: list[str] = ["klavier", "e-klavier", "orgel", "gitarre", "e-gitarre", "bass", "zupfbass",
45
+ "cello", "posaune", "klarinette", "saxophon", "floete", "holzfloete", "fagott",
46
+ "chor", "vibraphon", "spieluhr", "steeldrum", "marimba", "synthesizer",
47
+ "weicher synthesizer"]
48
+ # Tonhöhen in Hertz für die vierte Oktave (deutsche Notennamen: h statt b/si)
49
+ NOTEN: dict[str, int] = {"c": 262, "d": 294, "e": 330, "f": 349, "g": 392, "a": 440, "h": 494}
50
+
51
+
52
+ # Die sechs Lichter von vorne gesehen: 1 6 / 2 5 / 3 4 (Nummer = Platz in der Befehlsliste des Roboters)
53
+ POSITIONEN: dict[str, int] = {
54
+ "links oben": 1, "links mitte": 2, "links unten": 3,
55
+ "rechts unten": 4, "rechts mitte": 5, "rechts oben": 6,
56
+ }
57
+ POSITIONEN.update({name.replace(" ", "_"): nr for name, nr in list(POSITIONEN.items())})
58
+ POSITION_NAMEN: dict[int, str] = {nr: name for name, nr in POSITIONEN.items() if " " in name}
59
+
60
+
61
+ class RoboterFehler(Exception):
62
+ """Etwas ist beim Steuern des Roboters schiefgegangen."""
63
+
64
+
65
+ def farbe_umrechnen(farbe: Farbe) -> tuple[int, int, int]:
66
+ """Nimmt einen Farbnamen ('rot') oder drei Zahlen (255, 0, 0) und gibt (r, g, b) zurück."""
67
+ if isinstance(farbe, str):
68
+ name = farbe.lower().strip()
69
+ if name not in FARBEN:
70
+ raise RoboterFehler(f"Die Farbe '{farbe}' kenne ich nicht. Mögliche Farben: "
71
+ + ", ".join(sorted({k for k in FARBEN if k.isascii()})))
72
+ return FARBEN[name]
73
+ r, g, b = farbe
74
+ for wert in (r, g, b):
75
+ if not 0 <= wert <= 255:
76
+ raise RoboterFehler("Farbwerte müssen zwischen 0 und 255 liegen.")
77
+ return (int(r), int(g), int(b))
78
+
79
+
80
+ def waehle(name: object, tabelle: Mapping[str, _T], art: str) -> _T:
81
+ schluessel = str(name).lower().strip()
82
+ if schluessel not in tabelle:
83
+ raise RoboterFehler(f"Ich kenne {art} '{name}' nicht. Mögliche Werte: "
84
+ + ", ".join(sorted({k for k in tabelle if k.isascii()})))
85
+ return tabelle[schluessel]
86
+
87
+