├── MANIFEST.in ├── .gitignore ├── images ├── krone_fbk.jpg ├── krone_fbue.jpg ├── omega_unit.jpg ├── ascii_render.png ├── ascii_render2.png ├── krone_zilog.jpg └── krone_fbm_pin_numbering.jpg ├── setup.py ├── pyfis ├── aesys │ ├── __init__.py │ └── dsa.py ├── microsyst │ └── __init__.py ├── mobitec │ ├── __init__.py │ └── matrix.py ├── oltmann │ ├── __init__.py │ └── utils.py ├── __init__.py ├── data_sources │ ├── __init__.py │ └── fraport.py ├── omega │ ├── __init__.py │ └── rs485.py ├── gpio_backends │ ├── __init__.py │ └── rpi_gpio.py ├── xatlabs │ ├── exceptions.py │ ├── __init__.py │ ├── splitflap.py │ ├── cheetah.py │ └── rgb_dsa.py ├── ibis │ ├── __init__.py │ ├── ibis_tcp.py │ └── ibis_serial.py ├── lawo │ ├── __init__.py │ ├── mono_serial.py │ └── lawo_font.py ├── aegmis │ ├── exceptions.py │ ├── __init__.py │ ├── mis2_text.py │ ├── mis2_protocol.py │ ├── mis1_board.py │ ├── mis1_text.py │ ├── mis1_protocol.py │ └── mis1_matrix.py ├── utils │ ├── __init__.py │ ├── dummy_serial.py │ ├── base_serial.py │ ├── tcp_serial.py │ └── utils.py ├── krone │ ├── exceptions.py │ ├── __init__.py │ ├── k9000_hlst.py │ ├── k8200_pst.py │ ├── util.py │ ├── k9000_fbm.py │ ├── k9000_rs485.py │ ├── k9000_fbk.py │ └── k8200.py ├── splitflap_display │ ├── __init__.py │ ├── gui.py │ ├── ascii_graphics.py │ └── fields.py └── metadata.py ├── examples ├── ibis_example.py └── splitflap_display_example.py ├── omega_rs485_calibrate.py ├── k9000_fbm_toolkit.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include pyfis/oltmann *.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | build/ 4 | dist/ 5 | __pycache__/ -------------------------------------------------------------------------------- /images/krone_fbk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/krone_fbk.jpg -------------------------------------------------------------------------------- /images/krone_fbue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/krone_fbue.jpg -------------------------------------------------------------------------------- /images/omega_unit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/omega_unit.jpg -------------------------------------------------------------------------------- /images/ascii_render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/ascii_render.png -------------------------------------------------------------------------------- /images/ascii_render2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/ascii_render2.png -------------------------------------------------------------------------------- /images/krone_zilog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/krone_zilog.jpg -------------------------------------------------------------------------------- /images/krone_fbm_pin_numbering.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatoLynx/pyFIS/HEAD/images/krone_fbm_pin_numbering.jpg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | metadata = {} 4 | with open('pyfis/metadata.py') as f: 5 | exec(f.read(), metadata) 6 | 7 | setup( 8 | name = metadata['name'], 9 | version = metadata['version'], 10 | description = metadata['description'], 11 | license = metadata['license'], 12 | author = metadata['author'], 13 | author_email = metadata['author_email'], 14 | install_requires = metadata['requires'], 15 | extras_require = metadata['extras_require'], 16 | url = metadata['url'], 17 | keywords = metadata['keywords'], 18 | packages = find_packages(), 19 | package_data = {"": ["pyfis/oltmann/*.json"]}, 20 | include_package_data = True 21 | ) 22 | -------------------------------------------------------------------------------- /pyfis/aesys/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .dsa import AesysDSA -------------------------------------------------------------------------------- /pyfis/microsyst/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .migra import MigraTCP 19 | -------------------------------------------------------------------------------- /pyfis/mobitec/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .matrix import MobitecMatrix -------------------------------------------------------------------------------- /pyfis/oltmann/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .vistra_i import VistraI 19 | -------------------------------------------------------------------------------- /pyfis/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2016 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .metadata import version as __version__ -------------------------------------------------------------------------------- /pyfis/data_sources/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .fraport import FraportAPI 19 | -------------------------------------------------------------------------------- /pyfis/omega/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .rs485 import OmegaRS485Controller 19 | -------------------------------------------------------------------------------- /pyfis/gpio_backends/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .rpi_gpio import RpiGpioBackend 19 | -------------------------------------------------------------------------------- /pyfis/xatlabs/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | 19 | class CommunicationError(IOError): 20 | pass 21 | -------------------------------------------------------------------------------- /pyfis/ibis/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2016 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .ibis_serial import SerialIBISMaster 19 | from .ibis_tcp import TCPIBISMaster -------------------------------------------------------------------------------- /pyfis/lawo/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2016 - 2021 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .mono_serial import SerialMONOMaster 19 | from .lawo_font import LawoFont -------------------------------------------------------------------------------- /pyfis/aegmis/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | 19 | class CommunicationError(IOError): 20 | pass 21 | 22 | class DisplayError(IOError): 23 | pass 24 | -------------------------------------------------------------------------------- /pyfis/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .utils import * 19 | from .tcp_serial import TcpSerialPort 20 | from .dummy_serial import DummySerialPort 21 | -------------------------------------------------------------------------------- /pyfis/xatlabs/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .splitflap import xatLabsSplitFlapController 19 | from .rgb_dsa import xatLabsRGBDSAController 20 | from .cheetah import xatLabsCheetah 21 | -------------------------------------------------------------------------------- /pyfis/krone/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | 19 | class CommunicationError(IOError): 20 | pass 21 | 22 | class NACKError(CommunicationError): 23 | pass 24 | 25 | class BusyError(CommunicationError): 26 | pass 27 | -------------------------------------------------------------------------------- /pyfis/splitflap_display/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .display import SplitFlapDisplay 19 | from .fields import TextField, CustomMapField, MirrorField 20 | from .ascii_graphics import AsciiGraphics 21 | from .gui import SplitFlapGUI -------------------------------------------------------------------------------- /pyfis/aegmis/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020-2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .ecs_lcd import ECSLCDisplay 19 | from .mis1_text import MIS1TextDisplay 20 | from .mis1_matrix import MIS1MatrixDisplay 21 | from .mis2_text import MIS2TextDisplay 22 | from .mis1_board import MIS1Board -------------------------------------------------------------------------------- /pyfis/krone/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .k9000_fbm import Krone9000FBM 19 | from .k9000_fbk import Krone9000FBK 20 | from .k9000_hlst import Krone9000HLST 21 | from .k8200 import Krone8200Display 22 | from .k8200_pst import Krone8200PST 23 | -------------------------------------------------------------------------------- /examples/ibis_example.py: -------------------------------------------------------------------------------- 1 | from pyfis.ibis import SerialIBISMaster, TCPIBISMaster 2 | 3 | 4 | def main(): 5 | # Initialize a serial IBIS Master on serial port /dev/ttyS0 and turn on debug output 6 | master = SerialIBISMaster("/dev/ttyS0", debug=True) 7 | 8 | # For Windows users, use a port like COM1: 9 | # master = SerialIBISMaster("COM1", debug=True) 10 | 11 | # Alternatively, uncomment to use a TCP connection on 192.168.0.42, port 5001: 12 | # master = TCPIBISMaster("192.168.0.42", 5001, debug=True) 13 | 14 | # Send a DS001 telegram (line number 123) 15 | master.DS001(123) 16 | 17 | # Send a DS009 telegram (next stop display text) 18 | master.DS009("Akazienallee") 19 | 20 | # Send a DS003a telegram (destination text) 21 | master.DS003a("Hauptbahnhof") 22 | 23 | # Query status of IBIS device with address 1 24 | status = master.DS020(1) 25 | 26 | # The variable status will contain a dict like this: 27 | # {'status': 'ok'} 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /pyfis/utils/dummy_serial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | from .base_serial import BaseSerialPort 19 | 20 | 21 | class DummySerialPort(BaseSerialPort): 22 | def write(self, data): 23 | pass 24 | 25 | def read(self, length): 26 | return bytes(length) 27 | 28 | def setRTS(self, state): 29 | pass 30 | 31 | def setDTR(self, state): 32 | pass 33 | 34 | def getCTS(self): 35 | return 0 36 | 37 | def getDSR(self): 38 | return 0 39 | 40 | def getRI(self): 41 | return 0 42 | 43 | def getCD(self): 44 | return 0 45 | -------------------------------------------------------------------------------- /pyfis/metadata.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | name = "pyFIS" 19 | version = "1.14.2" 20 | description = "A library for controlling devices in the passenger information realm" 21 | license = "GPLv3" 22 | author = "Julian Metzler" 23 | author_email = "git@mezgr.de" 24 | requires = ['pyserial', 'crccheck', 'crcmod'] 25 | extras_require = { 26 | 'full': ['pillow', 'requests', 'RPi.GPIO'], 27 | 'http': ['requests'], 28 | 'graphics': ['pillow'], 29 | 'raspberrypi': ['RPi.GPIO'] 30 | } 31 | url = "https://github.com/Mezgrman/pyFIS" 32 | keywords = "led sign message board effect library wrapper serial text display ibis vdv300 bus next stop train mono lawo splitflap industrial factory" -------------------------------------------------------------------------------- /pyfis/utils/base_serial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | 19 | class BaseSerialPort: 20 | def __init__(self): 21 | pass 22 | 23 | def write(self, data): 24 | raise NotImplementedError 25 | 26 | def read(self, length): 27 | raise NotImplementedError 28 | 29 | def setRTS(self, state): 30 | raise NotImplementedError 31 | 32 | def setDTR(self, state): 33 | raise NotImplementedError 34 | 35 | def getCTS(self): 36 | raise NotImplementedError 37 | 38 | def getDSR(self): 39 | raise NotImplementedError 40 | 41 | def getRI(self): 42 | raise NotImplementedError 43 | 44 | def getCD(self): 45 | raise NotImplementedError 46 | -------------------------------------------------------------------------------- /examples/splitflap_display_example.py: -------------------------------------------------------------------------------- 1 | from pyfis.splitflap_display import SplitFlapDisplay, TextField, CustomMapField 2 | from pyfis.krone import Krone9000FBM 3 | 4 | MAP_TRAIN_TYPE = { 5 | 32: "", 6 | 33: "EC", 7 | 34: "IC", 8 | 35: "ICE", 9 | 36: "ICT", 10 | 37: "IR", 11 | 38: "AZ", 12 | 39: "D", 13 | 40: "RE", 14 | 41: "RB", 15 | 42: "SE", 16 | 43: "EN", 17 | 44: "NZ", 18 | 45: "UEx", 19 | 46: "CNL", 20 | 47: "", 21 | 48: "", 22 | 49: "IRE", 23 | 50: "CB", 24 | } 25 | 26 | class ExampleDisplay(SplitFlapDisplay): 27 | train_type = CustomMapField(MAP_TRAIN_TYPE, start_address=1, x=0, y=0, module_width=3, module_height=1) 28 | train_number = TextField(start_address=2, length=5, x=3, y=0, module_width=1, module_height=1) 29 | destination = TextField(start_address=7, length=8, x=0, y=1, module_width=2, module_height=2) 30 | info_text = TextField(start_address=15, length=16, x=0, y=3, module_width=1, module_height=1) 31 | 32 | 33 | def main(): 34 | controller = Krone9000FBM("/dev/ttyS0") 35 | display = ExampleDisplay(controller) 36 | display.train_type.set("ICE") 37 | display.train_number.set("524") 38 | display.destination.set("Dortmund") 39 | display.info_text.set("ca 10 Min später") 40 | display.update() 41 | print(display.render_ascii()) 42 | 43 | 44 | if __name__ == "__main__": 45 | main() 46 | -------------------------------------------------------------------------------- /pyfis/oltmann/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import json 19 | import os 20 | 21 | 22 | def get_text_width(text, font): 23 | """ 24 | Get the width of the given text using the given font. 25 | """ 26 | 27 | if not text: 28 | return 0 29 | 30 | try: 31 | with open(os.path.join(os.path.dirname(__file__), "dimensions-{}.json".format(font)), 'r') as f: 32 | dimensions = json.load(f) 33 | width = 0 34 | for char in text: 35 | dims = dimensions.get(char, (0, 0)) 36 | w = dims[0] 37 | if w is None: 38 | w = 0 39 | width += w 40 | width += dimensions.get('spacing', 0) * (len(text) - 1) 41 | return width 42 | except FileNotFoundError: 43 | raise NotImplementedError("Width calculation not available for this font") -------------------------------------------------------------------------------- /pyfis/utils/tcp_serial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import socket 19 | from .base_serial import BaseSerialPort 20 | 21 | 22 | class TcpSerialPort(BaseSerialPort): 23 | def __init__(self, host, port, timeout=2.0): 24 | """ 25 | host: The hostname or IP to connect to 26 | port: The TCP port to use for communication 27 | timeout: The socket timeout in seconds 28 | """ 29 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 30 | self.socket.connect((host, port)) 31 | self.socket.settimeout(timeout) 32 | 33 | def write(self, data): 34 | return self.socket.send(bytearray(data)) 35 | 36 | def read(self, length): 37 | # Read the specified number of bytes, blocking 38 | return self.socket.recv(length) 39 | 40 | def setRTS(self, state): 41 | pass 42 | 43 | def setDTR(self, state): 44 | pass 45 | 46 | def getCTS(self): 47 | return 0 48 | 49 | def getDSR(self): 50 | return 0 51 | 52 | def getRI(self): 53 | return 0 54 | 55 | def getCD(self): 56 | return 0 57 | 58 | def __del__(self): 59 | self.socket.close() 60 | -------------------------------------------------------------------------------- /pyfis/aesys/dsa.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | 20 | 21 | class AesysDSA: 22 | def __init__(self, port, exclusive=True, debug=False, encoding_errors="strict"): 23 | self.port = port 24 | self.debug = debug 25 | self.exclusive = exclusive 26 | self.encoding_errors = encoding_errors 27 | self.open() 28 | 29 | def open(self): 30 | self.device = serial.Serial(self.port, 31 | baudrate=9600, bytesize=8, parity='N', stopbits=1, exclusive=self.exclusive) 32 | 33 | def close(self): 34 | self.device.close() 35 | 36 | def _checksum(self, data): 37 | checksum = sum(data) 38 | data += "{:04X}".format(checksum & 0xFFFF).encode('ascii') 39 | return data 40 | 41 | def send_text(self, text): 42 | data = "\x01\x17P000060{text}".format(text=text) 43 | length = len(data) 44 | frame = "\x02AVIS{length:04X}{data}\x03".format(length=length, data=data) 45 | frame = frame.encode('cp850', errors=self.encoding_errors) 46 | frame = self._checksum(frame) 47 | if self.debug: 48 | print(frame) 49 | self.device.write(frame) -------------------------------------------------------------------------------- /pyfis/gpio_backends/rpi_gpio.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import RPi.GPIO as gpio 19 | 20 | 21 | class RpiGpioBackend: 22 | """ 23 | Raspberry Pi GPIO backend 24 | """ 25 | 26 | MODE_IN = 1 27 | MODE_OUT = 2 28 | 29 | PULL_UP = 1 30 | PULL_DOWN = 2 31 | 32 | STATE_HIGH = 1 33 | STATE_LOW = 0 34 | 35 | def __init__(self, debug = False): 36 | self.debug = debug 37 | gpio.setmode(gpio.BCM) 38 | 39 | def setup_channel(self, channel, mode, pull=None): 40 | if pull == self.PULL_UP: 41 | pud = gpio.PUD_UP 42 | elif pull == self.PULL_DOWN: 43 | pud = gpio.PUD_DOWN 44 | 45 | if mode == self.MODE_OUT: 46 | gpio.setup(channel, gpio.OUT) 47 | elif mode == self.MODE_IN: 48 | gpio.setup(channel, gpio.IN, pull_up_down=pud) 49 | 50 | def clean_up(self): 51 | gpio.cleanup() 52 | 53 | def set_output(self, channel, state): 54 | if state == self.STATE_HIGH: 55 | gpio.output(channel, 1) 56 | elif state == self.STATE_LOW: 57 | gpio.output(channel, 0) 58 | 59 | def get_input(self, channel): 60 | state = gpio.input(channel) 61 | if state: 62 | return self.STATE_HIGH 63 | else: 64 | return self.STATE_LOW 65 | -------------------------------------------------------------------------------- /pyfis/ibis/ibis_tcp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2016 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import socket 19 | 20 | from .ibis_protocol import IBISProtocol 21 | 22 | class TCPIBISMaster(IBISProtocol): 23 | """ 24 | An IBIS master using TCP instead of serial 25 | """ 26 | 27 | def __init__(self, host, port, timeout = 2.0, *args, **kwargs): 28 | """ 29 | host: 30 | The hostname or IP to connect to 31 | 32 | port: 33 | The TCP port to use for communication 34 | 35 | timeout: 36 | The socket timeout in seconds 37 | """ 38 | 39 | super().__init__(*args, **kwargs) 40 | 41 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 42 | self.socket.connect((host, port)) 43 | self.socket.settimeout(timeout) 44 | 45 | def _send(self, telegram): 46 | """ 47 | Actually send the telegram. 48 | This varies depending on implementation 49 | """ 50 | 51 | self.socket.send(telegram) 52 | 53 | def _receive(self, length): 54 | """ 55 | Actually receive data. 56 | This varies depending on implementation and needs to be overridden 57 | """ 58 | 59 | return self.socket.recv(length) 60 | 61 | def __del__(self): 62 | self.socket.close() -------------------------------------------------------------------------------- /omega_rs485_calibrate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 - 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import argparse 19 | 20 | from pyfis.omega import OmegaRS485Controller 21 | 22 | 23 | def main(): 24 | parser = argparse.ArgumentParser() 25 | parser.add_argument("-p", "--port", type=str, required=True) 26 | parser.add_argument("-a", "--address", type=int, required=True) 27 | args = parser.parse_args() 28 | 29 | c = OmegaRS485Controller(args.port) 30 | serial = c.read_serial_number(args.address) 31 | if not serial: 32 | print("Failed to connect to module!") 33 | return 34 | print("Module serial number:", " ".join(["{:02X}".format(byte) for byte in serial])) 35 | confirm = input("Start calibration? [Y/n]: ").lower() in ("", "y") 36 | if not confirm: 37 | print("Aborted") 38 | return 39 | c.calibration_start(args.address) 40 | 41 | try: 42 | while True: 43 | input("Step calibration: Press Enter until a flap falls, then press Ctrl+C.") 44 | c.calibration_step(args.address) 45 | except KeyboardInterrupt: 46 | pass 47 | 48 | try: 49 | while True: 50 | input("Pulse calibration: Press Enter until a flap falls, then press Ctrl+C.") 51 | c.calibration_pulse(args.address) 52 | except KeyboardInterrupt: 53 | pass 54 | 55 | new_position = int(input("Please enter the current flap position: ")) 56 | c.calibration_finish(args.address, new_position) 57 | print("Done!") 58 | 59 | 60 | if __name__ == "__main__": 61 | main() 62 | -------------------------------------------------------------------------------- /pyfis/lawo/mono_serial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | 20 | from .mono_protocol import MONOProtocol 21 | 22 | class SerialMONOMaster(MONOProtocol): 23 | """ 24 | A MONO bus master, sending and receiving frames using a serial port 25 | """ 26 | 27 | def __init__(self, port, baudrate = 19200, bytesize = 8, parity = 'N', 28 | stopbits = 1, timeout = 2.0, *args, **kwargs): 29 | """ 30 | port: 31 | The serial port to use for communication 32 | """ 33 | 34 | super().__init__(*args, **kwargs) 35 | 36 | if isinstance(port, serial.Serial): 37 | self.device = port 38 | self.port = self.device.port 39 | else: 40 | self.port = port 41 | self.device = serial.Serial( 42 | self.port, 43 | baudrate = baudrate, 44 | bytesize = bytesize, 45 | parity = parity, 46 | stopbits = stopbits, 47 | timeout = timeout 48 | ) 49 | 50 | def _send(self, frame): 51 | """ 52 | Actually send the frame. 53 | This varies depending on implementation 54 | """ 55 | 56 | self.device.write(frame) 57 | 58 | def _receive(self, length): 59 | """ 60 | Actually receive data. 61 | This varies depending on implementation and needs to be overridden 62 | """ 63 | 64 | return self.device.read(length) 65 | 66 | def __del__(self): 67 | self.device.close() 68 | -------------------------------------------------------------------------------- /pyfis/ibis/ibis_serial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2016 - 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | 20 | from .ibis_protocol import IBISProtocol 21 | 22 | class SerialIBISMaster(IBISProtocol): 23 | """ 24 | An IBIS bus master, sending and receiving telegrams using a serial port 25 | """ 26 | 27 | def __init__(self, port, baudrate = 1200, bytesize = 7, parity = 'E', 28 | stopbits = 2, timeout = 2.0, exclusive = True, *args, **kwargs): 29 | """ 30 | port: 31 | The serial port to use for communication 32 | """ 33 | 34 | super().__init__(*args, **kwargs) 35 | 36 | if isinstance(port, serial.Serial): 37 | self.device = port 38 | self.port = self.device.port 39 | else: 40 | self.port = port 41 | self.device = serial.Serial( 42 | self.port, 43 | baudrate = baudrate, 44 | bytesize = bytesize, 45 | parity = parity, 46 | stopbits = stopbits, 47 | timeout = timeout, 48 | exclusive=exclusive 49 | ) 50 | 51 | def _send(self, telegram): 52 | """ 53 | Actually send the telegram. 54 | This varies depending on implementation 55 | """ 56 | 57 | self.device.write(telegram) 58 | 59 | def _receive(self, length): 60 | """ 61 | Actually receive data. 62 | This varies depending on implementation and needs to be overridden 63 | """ 64 | 65 | return self.device.read(length) 66 | 67 | def __del__(self): 68 | self.device.close() 69 | -------------------------------------------------------------------------------- /pyfis/aegmis/mis2_text.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .mis2_protocol import MIS2Protocol 22 | 23 | from ..utils import debug_hex 24 | from ..utils.base_serial import BaseSerialPort 25 | 26 | 27 | class MIS2TextDisplay(MIS2Protocol): 28 | ALIGN_LEFT = 0x00 29 | ALIGN_RIGHT = 0x01 30 | ALIGN_CENTER = 0x02 31 | 32 | ATTR_BLINK = 0x01 33 | ATTR_INVERT = 0x10 34 | ATTR_BLINK_INV = 0x08 35 | 36 | def merge_attributes(self, text): 37 | if type(text) in (tuple, list): 38 | merged = "" 39 | for t, attrs in text: 40 | merged += "\x00" + chr(attrs) + t 41 | return merged 42 | return text 43 | 44 | def text(self, page, row, col_start, col_end, text, attrs = ALIGN_LEFT): 45 | # Page 0xFF is the fallback page and will be saved permanently 46 | # Page 0xFE copies the page to all 10 slots 47 | text = self.merge_attributes(text) 48 | text = text.encode("CP437", errors=self.encoding_errors) 49 | data = [page, row, col_start >> 8, col_start & 0xFF, col_end >> 8, col_end & 0xFF, attrs] + list(text) 50 | return self.send_command(0x15, 0x00, data) 51 | 52 | def delete_line(self, page, line): 53 | return self.send_command(0x23, 0x00, [page, line]) 54 | 55 | def set_pages(self, pages): 56 | # pages: List of tuples in the form 57 | # (page, duration) - duration in seconds, 0.5s resolution 58 | data = [] 59 | for page, duration in pages: 60 | data.append(page) 61 | data.append(round(duration * 2)) 62 | return self.send_command(0x24, 0x00, data) 63 | 64 | def set_page(self, page): 65 | return self.set_pages([(page, 10)]) 66 | 67 | def copy_page(self, src_page, dest_page): 68 | return self.send_command(0x26, 0x00, [src_page, dest_page]) 69 | 70 | def delete_page(self, page): 71 | return self.send_command(0x2F, 0x00, [page]) 72 | -------------------------------------------------------------------------------- /pyfis/aegmis/mis2_protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from ..utils import debug_hex 22 | from ..utils.base_serial import BaseSerialPort 23 | 24 | 25 | class MIS2Protocol: 26 | def __init__(self, port, address = 1, baudrate = 9600, exclusive = True, debug = False, encoding_errors = "strict"): 27 | self.address = address 28 | self.debug = debug 29 | self.encoding_errors = encoding_errors 30 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 31 | self.port = port 32 | else: 33 | self.port = serial.Serial(port, baudrate=baudrate, bytesize=8, parity="E", stopbits=1, exclusive=exclusive) 34 | 35 | def checksum(self, data): 36 | checksum = 0x00 37 | for i, byte in enumerate(data): 38 | checksum += byte + 1 # +1 per byte because the data length needs to be added to the checksum 39 | return (checksum % 256) | 0x80 40 | 41 | def escape(self, data): 42 | escaped = [] 43 | for byte in data: 44 | if byte in (0x02, 0x03, 0x04, 0x05, 0x10, 0x17): 45 | escaped += [0x10, byte] 46 | else: 47 | escaped.append(byte) 48 | return escaped 49 | 50 | def send_raw_telegram(self, data): 51 | telegram = [0x04, (0x80 | self.address), 0x02] + self.escape(data) + [0x03] + [self.checksum(data + [0x03])] 52 | if self.debug: 53 | print(debug_hex(telegram, readable_ascii=False, readable_ctrl=False)) 54 | self.port.setRTS(1) 55 | self.port.write(telegram) 56 | time.sleep(0.1) 57 | self.port.setRTS(0) 58 | 59 | def send_command(self, code, subcode, data): 60 | return self.send_raw_telegram([code, subcode] + data) 61 | 62 | def set_timeout(self, timeout): 63 | # Timeout in seconds, resolution 0.5s, range 0 ... 32767 64 | timeout = round(timeout * 2) 65 | return self.send_command(0x01, 0x00, [timeout >> 8, timeout & 0xFF]) 66 | 67 | def reset(self): 68 | return self.send_command(0x31, 0x00, []) 69 | 70 | def set_test_mode(self, state): 71 | return self.send_command(0x32, 0x00, [1 if state else 0]) 72 | 73 | def sync(self): 74 | return self.send_command(0x34, 0x00, []) 75 | 76 | def set_outputs(self, states): 77 | # states: array of 8 bools representing outputs 0 through 7 78 | state_byte = 0x00 79 | for i in range(max(8, len(states))): 80 | if states[i]: 81 | state_byte |= (1 << i) 82 | return self.send_command(0x41, 0x00, [state_byte]) 83 | -------------------------------------------------------------------------------- /pyfis/krone/k9000_hlst.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | 20 | from .k9000_rs485 import Krone9000RS485Controller 21 | 22 | 23 | class Krone9000HLST(Krone9000RS485Controller): 24 | """ 25 | Controls a HLST (Heizungs- und Lichtsteuerung) 26 | (heater, fan and light control) board. 27 | """ 28 | 29 | BOARD_ID = 0x10 30 | BOARD_NAME = "HLST" 31 | 32 | CMD_GET_STATUS = 0x01 33 | CMD_LOCK = 0xC6 34 | CMD_UNLOCK = 0xC7 35 | CMD_CONTROL = 0x0A 36 | 37 | def _build_parameters(self, light, heater, fan, force_heater, force_fan, low_min_temp): 38 | """ 39 | light: 1=on, 0=off 40 | heater: 1=on, 0=off 41 | fan: 1=on, 0=off 42 | force_heater: 1=force on, 0=automatic 43 | force_fan: 1=force on, 0=automatic 44 | low_min_temp: 1=-20°C temperature limit, 0=0°C temperature limit 45 | """ 46 | parameter_byte = 0x00 47 | parameter_byte |= int(light) << 7 48 | parameter_byte |= int(heater) << 6 49 | parameter_byte |= int(fan) << 5 50 | parameter_byte |= int(force_heater) << 2 51 | parameter_byte |= int(force_fan) << 1 52 | parameter_byte |= int(low_min_temp) 53 | return parameter_byte 54 | 55 | def get_status(self): 56 | # Get the status of the FBK board 57 | payload = self.send_command(self.CMD_GET_STATUS, response=True) 58 | stat1 = payload[1] 59 | stat2 = payload[2] 60 | status = { 61 | 'comm_err': bool(stat1 & 0x40), 62 | 'reset': bool(stat1 & 0x20), 63 | 'locked': bool(stat1 & 0x10), 64 | 'light_err': bool(stat1 & 0x08), 65 | 'hlst_err': bool(stat1 & 0x04), 66 | 'force_ctrl': bool(stat1 & 0x02), 67 | 'low_min_temp': bool(stat1 & 0x01), 68 | 'light_on_set': bool(stat2 & 0x80), 69 | 'heater_on': bool(stat2 & 0x40), 70 | 'fan_on': bool(stat2 & 0x20), 71 | 'light_on_feedback': bool(stat2 & 0x10), 72 | 'temp_above_40c': bool(stat2 & 0x08), 73 | 'temp_above_0c': bool(stat2 & 0x04), 74 | 'temp_above_neg20c': bool(stat2 & 0x02), 75 | 'sw_ver': f"{payload[3]}.{payload[4]}" 76 | } 77 | return status 78 | 79 | def lock(self): 80 | # Lock the entire HLST 81 | return self.send_command(self.CMD_LOCK) 82 | 83 | def unlock(self): 84 | # Unlock the entire HLST 85 | return self.send_command(self.CMD_UNLOCK) 86 | 87 | def control(self, light, heater, fan, force_heater, force_fan, low_min_temp): 88 | return self.send_command(self.CMD_CONTROL, self._build_parameters(light, heater, fan, force_heater, force_fan, low_min_temp)) 89 | -------------------------------------------------------------------------------- /pyfis/xatlabs/splitflap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .exceptions import CommunicationError 22 | from ..utils.base_serial import BaseSerialPort 23 | 24 | 25 | class xatLabsSplitFlapController: 26 | """ 27 | Controls the xatLabs Arduino-based generic split-flap controller. 28 | This is basically just a very simple serial protocol for 29 | setting specified split-flap units to specified positions. 30 | """ 31 | 32 | ACT_SET_SEQ = 0xA0 # Set units sequentially 33 | ACT_SET_ADDR = 0xA1 # Set units addressed 34 | ACT_SET_HOME = 0xA2 # Set all units to home 35 | ACT_UPDATE = 0xA3 # Start the units 36 | 37 | def __init__(self, port, debug = False, exclusive = True): 38 | self.debug = debug 39 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 40 | self.port = port 41 | else: 42 | self.port = serial.Serial(port, baudrate=115200, timeout=2.0, exclusive=exclusive) 43 | 44 | def debug_message(self, message): 45 | """ 46 | Turn a message into a readable form 47 | """ 48 | result = "" 49 | for byte in message: 50 | if byte in range(0, 32) or byte >= 127: 51 | result += "<{:02X}>".format(byte) 52 | else: 53 | result += chr(byte) 54 | result += " " 55 | return result 56 | 57 | def read_response(self): 58 | """ 59 | Read the response from the addressed station 60 | """ 61 | response = self.port.read(1) 62 | if not response: 63 | raise CommunicationError("Timeout waiting for response") 64 | if self.debug: 65 | print("RX: " + self.debug_message(response)) 66 | return response 67 | 68 | def send_command(self, action, payload): 69 | data = [0xFF, action, len(payload)] + payload 70 | print("TX: " + self.debug_message(data)) 71 | self.port.write(bytearray(data)) 72 | 73 | def send_command_with_response(self, action, payload): 74 | """ 75 | Send a command and retrieve the response data 76 | """ 77 | self.send_command(action, payload) 78 | return self.read_response() 79 | 80 | def set_home(self): 81 | """ 82 | Set all units to their home position 83 | """ 84 | return self.send_command_with_response(self.ACT_SET_HOME, []) 85 | 86 | def set_positions(self, positions): 87 | """ 88 | Set all units with sequential addressing 89 | positions: list of positions for units starting at address 0 90 | """ 91 | return self.send_command_with_response(self.ACT_SET_SEQ, positions) 92 | 93 | def set_positions_addressed(self, positions): 94 | """ 95 | Set all units with explicit addressing 96 | positions: dict of format {address: position} 97 | """ 98 | pos_map = [item for k in positions for item in (k, positions[k])] 99 | return self.send_command_with_response(self.ACT_SET_ADDR, pos_map) 100 | 101 | def update(self): 102 | """ 103 | Start the update of all units 104 | """ 105 | return self.send_command_with_response(self.ACT_UPDATE, []) 106 | 107 | def d_set_module_data(self, module_data): 108 | # Compatibility function for SplitFlapDisplay class 109 | self.set_positions_addressed(dict(module_data)) 110 | 111 | def d_update(self): 112 | # Compatibility function for SplitFlapDisplay class 113 | self.update() 114 | -------------------------------------------------------------------------------- /pyfis/krone/k8200_pst.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2019 - 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .exceptions import CommunicationError 22 | from ..utils.base_serial import BaseSerialPort 23 | from ..utils.utils import int_to_bcd 24 | 25 | 26 | class Krone8200PST: 27 | """ 28 | Controls the PST bus in a Krone 8200 split-flap display. 29 | """ 30 | 31 | def __init__(self, port, nmi_backend, nmi_channel, nmi_invert, debug = False, exclusive = True): 32 | """ 33 | nmi_backend: GPIO Backend instance to control the NMI pin of the PST. 34 | This pin is used to stop a module from spinning in case 35 | the selected position can not be found. 36 | See the supported backends in pyfis.gpio_backends. 37 | nmi_channel: Channel to use for the NMI signal on the selected GPIO backend 38 | nmi_invert: Whether the NMI signal is active-low (False) or active-high (True) 39 | """ 40 | 41 | self.nmi_backend = nmi_backend 42 | self.nmi_channel = nmi_channel 43 | self.nmi_invert = nmi_invert 44 | self.nmi_backend.setup_channel(self.nmi_channel, self.nmi_backend.MODE_OUT) 45 | self.nmi_backend.set_output(self.nmi_channel, self.nmi_backend.STATE_LOW if self.nmi_invert else self.nmi_backend.STATE_HIGH) 46 | self.debug = debug 47 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 48 | self.port = port 49 | else: 50 | self.port = serial.Serial(port, baudrate=2400, stopbits=2, timeout=1.0, exclusive=exclusive) 51 | 52 | def debug_message(self, message): 53 | """ 54 | Turn a message into a readable form 55 | """ 56 | result = "" 57 | for byte in message: 58 | result += "{:02X} ".format(byte) 59 | return result 60 | 61 | def send_raw_message(self, message): 62 | if self.debug: 63 | print("TX: " + self.debug_message(message)) 64 | for byte in message: 65 | self.port.write(bytearray([byte])) 66 | time.sleep(0.05) 67 | 68 | def set_home(self): 69 | """ 70 | Set all units to their home position 71 | """ 72 | return self.send_raw_message([0x1B]) 73 | 74 | def set_unit(self, address, position): 75 | """ 76 | Set a given unit to a given position 77 | """ 78 | return self.send_raw_message([0x3A, address, int_to_bcd(position)]) 79 | 80 | def update(self): 81 | """ 82 | Update units (cause them to actually turn) 83 | """ 84 | return self.send_raw_message([0x1C]) 85 | 86 | def reset(self): 87 | """ 88 | Reset all unit controllers 89 | """ 90 | return self.send_raw_message([0x1A]) 91 | 92 | def stop_all(self): 93 | """ 94 | Stop all modules from rotating by asserting NMI 95 | """ 96 | self.nmi_backend.set_output(self.nmi_channel, self.nmi_backend.STATE_HIGH if self.nmi_invert else self.nmi_backend.STATE_LOW) 97 | time.sleep(0.05) 98 | self.nmi_backend.set_output(self.nmi_channel, self.nmi_backend.STATE_LOW if self.nmi_invert else self.nmi_backend.STATE_HIGH) 99 | time.sleep(0.05) 100 | 101 | def d_set_module_data(self, module_data): 102 | # Compatibility function for SplitFlapDisplay class 103 | for addr, pos in module_data: 104 | self.set_unit(addr, pos) 105 | 106 | def d_update(self): 107 | # Compatibility function for SplitFlapDisplay class 108 | self.update() 109 | -------------------------------------------------------------------------------- /pyfis/aegmis/mis1_board.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import math 19 | import serial 20 | import time 21 | 22 | from .mis1_text import MIS1TextDisplay 23 | from ..utils.base_serial import BaseSerialPort 24 | 25 | 26 | class MIS1Board: 27 | """ 28 | A large board using multiple GCUs 29 | """ 30 | 31 | def __init__(self, port, start_address = 1, num_rows = 1, rows_per_gcu = 8, baudrate = 9600, exclusive = True, debug = False): 32 | """ 33 | port: Serial port for the GCU bus 34 | start_address: Address of the first GCU, usually 1 35 | num_rows: How many rows of text the board has 36 | rows_per_gcu: How many rows are controlled per GCU 37 | baudrate: GCU baudrate 38 | exclusive: Whether to lock the serial port for exclusive access 39 | debug: Enable debug output 40 | """ 41 | self.start_address = start_address 42 | self.debug = debug 43 | self.num_rows = num_rows 44 | self.rows_per_gcu = rows_per_gcu 45 | 46 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 47 | self.port = port 48 | else: 49 | self.port = serial.Serial(port, baudrate=baudrate, bytesize=8, parity="E", stopbits=1, exclusive=exclusive) 50 | 51 | self.num_gcus = math.ceil(num_rows / rows_per_gcu) 52 | self.gcus = [] 53 | self.gcu_outputs = [] 54 | for i in range(self.start_address, self.start_address + self.num_gcus): 55 | self.gcus.append(MIS1GCUDisplay(self.port, i, debug=self.debug)) 56 | self.gcu_outputs.append([0] * 8) 57 | 58 | def write_row(self, page, row, col, text): 59 | """ 60 | Write text on specified page, row and column of the board (both starting at 0) 61 | """ 62 | gcu_index = row // self.rows_per_gcu 63 | gcu_row = row % self.rows_per_gcu 64 | self.gcus[gcu_index].simple_text(page, gcu_row, col, text) 65 | 66 | def show_page(self, page): 67 | """ 68 | Show the given page 69 | """ 70 | for gcu in self.gcus: 71 | gcu.set_page(page) 72 | 73 | def show_pages(self, pages): 74 | """ 75 | Show the given pages. Structure: 76 | [(page_num, page_duration), (page_num, page_duration)] 77 | where page_duration is as follows: 78 | 0 = 0.0 s (invalid) 79 | 1 = 0.5 s 80 | 2 = 1.0 s 81 | ... 82 | 254 = 127.0 s 83 | 255 = 127.5 s (maximum) 84 | """ 85 | for gcu in self.gcus: 86 | gcu.set_pages(pages) 87 | 88 | def write_text(self, page, start_row, start_col, text): 89 | """ 90 | Write the given multiline text to the board on the given page 91 | and starting at the given row and column 92 | """ 93 | lines = text.splitlines() 94 | for row in range(start_row, min(self.num_rows, start_row + len(lines))): 95 | self.write_row(page, row, start_col, lines[row - start_row]) 96 | 97 | def set_blinker(self, row, state): 98 | """ 99 | On boards with blinkers (like airport info boards), set the blinker 100 | for the given row active or inactive. This assumes the blinkers are 101 | controlled by the GPIOs of the GCU in ascending order. 102 | """ 103 | gcu_index = row // self.rows_per_gcu 104 | gcu_row = row % self.rows_per_gcu 105 | self.gcu_outputs[gcu_index][gcu_row] = 1 if state else 0 106 | 107 | def update_blinkers(self): 108 | """ 109 | Update the GPIOs according to the internal blinker states 110 | """ 111 | for i, gcu in enumerate(self.gcus): 112 | gcu.set_outputs(self.gcu_outputs[i]) 113 | -------------------------------------------------------------------------------- /pyfis/splitflap_display/gui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | HAS_TKINTER = False 19 | try: 20 | import tkinter as tk 21 | import tkinter.font as tkFont 22 | HAS_TKINTER = True 23 | except ModuleNotFoundError: 24 | pass 25 | 26 | from .fields import * 27 | 28 | 29 | """ 30 | This module can build a GUI based on a SplitFlapDisplay instance. 31 | This can be used to easily build a manual control tool for a display. 32 | """ 33 | 34 | class SplitFlapGUI: 35 | def __init__(self, display, parent): 36 | if not HAS_TKINTER: 37 | raise RuntimeError("Tkinter could not be loaded. Please make sure the tkinter module exists.") 38 | 39 | self.display = display 40 | self.parent = parent 41 | self.field_widgets = {} 42 | self.frame = tk.Frame(parent) 43 | self.build_gui() 44 | 45 | @staticmethod 46 | def _set_optionmenu_width(widget, choices): 47 | f = tkFont.nametofont(widget.cget("font")) 48 | zerowidth = f.measure("0") 49 | w = round(max([f.measure(i) for i in choices]) / zerowidth) 50 | widget.config(width=w) 51 | 52 | @staticmethod 53 | def _validate_entry(allowed_chars, max_length, text): 54 | if len(text) > max_length: 55 | return False 56 | if allowed_chars: 57 | for char in text: 58 | if char not in (allowed_chars + [" "]): 59 | return False 60 | return True 61 | 62 | def build_gui(self): 63 | fields = self.display.get_fields() 64 | for name, field in fields: 65 | if isinstance(field, TextField): 66 | if field.display_mapping: 67 | allowed_chars = list(set([c.lower() for c in field.display_mapping.values()] + [c.upper() for c in field.display_mapping.values()])) 68 | else: 69 | allowed_chars = None 70 | _len = field.length # Required! If you use field.length in the lambda directly, it'll give the wrong value! 71 | _validate_func = lambda text: self._validate_entry(allowed_chars, _len, text) 72 | _vcmd = (self.frame.register(_validate_func), "%P") 73 | entry = tk.Entry(self.frame, validate='key', validatecommand=_vcmd) 74 | entry.grid(column=field.x, row=field.y, columnspan=field.module_width*field.length, rowspan=field.module_height, sticky=tk.NSEW, padx=2, pady=2) 75 | self.field_widgets[name] = (entry, None, None) 76 | elif isinstance(field, CustomMapField): 77 | choices = list(field.display_mapping.values()) 78 | var = tk.StringVar(self.frame) 79 | var.set(choices[0]) 80 | opt = tk.OptionMenu(self.frame, var, *choices) 81 | opt.configure(indicatoron=False, anchor=tk.W) 82 | self._set_optionmenu_width(opt, choices) 83 | opt.grid(column=field.x, row=field.y, columnspan=field.module_width, rowspan=field.module_height, sticky=tk.NSEW, padx=2, pady=2) 84 | self.field_widgets[name] = (opt, var, choices) 85 | 86 | def update_display(self): 87 | for name, widget_data in self.field_widgets.items(): 88 | widget, var, choices = widget_data 89 | if var: 90 | getattr(self.display, name).set(var.get()) 91 | else: 92 | getattr(self.display, name).set(widget.get()) 93 | #self.display.update() 94 | 95 | def clear_display(self): 96 | self.display.clear() 97 | for name, widget_data in self.field_widgets.items(): 98 | widget, var, choices = widget_data 99 | if choices: 100 | var.set("") 101 | else: 102 | widget.delete(0, tk.END) 103 | #self.display.update() -------------------------------------------------------------------------------- /pyfis/omega/rs485.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 - 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from ..utils.base_serial import BaseSerialPort 22 | 23 | 24 | class OmegaRS485Controller: 25 | """ 26 | Controls split-flap modules using Omega's RS485 protocol. 27 | Commonly found in Swiss (SBB CFF FFS) station displays. 28 | """ 29 | 30 | def __init__(self, port, debug = False, exclusive = True): 31 | self.debug = debug 32 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 33 | self.port = port 34 | else: 35 | self.port = serial.Serial(port, baudrate=19200, timeout=1.0, exclusive=exclusive) 36 | 37 | def prepare_message(self, address, command, value): 38 | message = [0xFF, command, address] 39 | if value is not None: 40 | if type(value) in (tuple, list): 41 | message.extend(value) 42 | else: 43 | message.append(value) 44 | return message 45 | 46 | def init_communication(self): 47 | """ 48 | Initialize communication by asserting a break condition 49 | on the serial Tx line for a certain time 50 | """ 51 | self.port.break_condition = True 52 | time.sleep(0.05) 53 | self.port.break_condition = False 54 | 55 | def send_raw_message(self, message): 56 | if self.debug: 57 | print(" ".join((format(x, "02X") for x in message))) 58 | self.init_communication() 59 | self.port.write(message) 60 | 61 | def read_response(self, length): 62 | return self.port.read(length) 63 | 64 | def send_command(self, address, command, value = None): 65 | message = self.prepare_message(address, command, value) 66 | self.send_raw_message(message) 67 | 68 | def set_home(self, address): 69 | self.send_command(address, 0xC5) 70 | 71 | def set_position(self, address, position): 72 | self.send_command(address, 0xC0, position) 73 | 74 | def calibration_start(self, address): 75 | self.send_command(address, 0xCC) 76 | 77 | def calibration_step(self, address): 78 | self.send_command(address, 0xC6) 79 | 80 | def calibration_pulse(self, address): 81 | self.send_command(address, 0xC7) 82 | 83 | def calibration_finish(self, address, position): 84 | self.send_command(address, 0xCB, position) 85 | 86 | def set_address(self, address, new_address): 87 | self.send_command(address, 0xCE, new_address) 88 | 89 | def read_position(self, address): 90 | self.send_command(address, 0xD0) 91 | return self.read_response(4) 92 | 93 | def read_serial_number(self, address): 94 | self.send_command(address, 0xDF) 95 | return self.read_response(4) 96 | 97 | def d_set_module_data(self, module_data): 98 | # Compatibility function for SplitFlapDisplay class 99 | 100 | # Turn module data into blocks of contiguous addresses 101 | items = sorted(module_data, key=lambda i:i[0]) 102 | last_addr = None 103 | start_addr = None 104 | pos_block = [] 105 | for i, (addr, pos) in enumerate(items): 106 | if (last_addr is not None and addr - last_addr > 1): 107 | self.set_position(start_addr, pos_block) 108 | time.sleep(0.05) 109 | pos_block = [] 110 | if pos_block == []: 111 | start_addr = addr 112 | pos_block.append(pos) 113 | if i == len(items) - 1: 114 | self.set_position(start_addr, pos_block) 115 | time.sleep(0.05) 116 | pos_block = [] 117 | last_addr = addr 118 | 119 | def d_update(self): 120 | # Compatibility function for SplitFlapDisplay class 121 | pass 122 | -------------------------------------------------------------------------------- /pyfis/data_sources/fraport.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2022 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import datetime 19 | import requests 20 | 21 | from dateutil import parser as dt_parser 22 | 23 | 24 | class FraportAPI: 25 | def __init__(self): 26 | pass 27 | 28 | def _convert_flight_dict(self, flight, is_arrival): 29 | """ 30 | Convert a flight data dict from the Fraport API format to our format. 31 | This mostly means renaming keys. 32 | 33 | is_arrival: Used to rename arrival/departure time keys 34 | """ 35 | scheduled_arrival_key = 'sched' if is_arrival else 'schedArr' 36 | scheduled_departure_key = 'schedDep' if is_arrival else 'sched' 37 | estimated_arrival_key = 'esti' if is_arrival else 'estiArr' 38 | estimated_departure_key = 'estiDep' if is_arrival else 'esti' 39 | 40 | out = { 41 | 'aircraft_icao': flight.get('ac'), 42 | 'aircraft_registration': flight.get('reg'), 43 | 'airline_iata': flight.get('al'), 44 | 'airline_name': flight.get('alname'), 45 | 'airport_iata': flight.get('iata'), 46 | 'airport_name': flight.get('apname'), 47 | 'baggage_claims': flight.get('bag'), 48 | 'codeshares': flight.get('cs'), 49 | 'counters': flight.get('schalter'), 50 | 'duration': datetime.timedelta(minutes=flight.get('duration')) if flight.get('duration') else None, 51 | 'estimated_arrival': dt_parser.parse(flight.get(estimated_arrival_key)) if flight.get(estimated_arrival_key) else None, 52 | 'estimated_departure': dt_parser.parse(flight.get(estimated_departure_key)) if flight.get(estimated_departure_key) else None, 53 | 'exit': flight.get('ausgang'), 54 | 'flight_id': flight.get('id'), 55 | 'flight_number': flight.get('fnr'), 56 | 'flight_status': flight.get('flstatus'), # unclear 57 | 'gate': flight.get('gate'), 58 | 'hall': flight.get('halle'), 59 | 'language': flight.get('lang'), 60 | 'last_update': dt_parser.parse(flight.get('lu')) if flight.get('lu') else None, 61 | 's': flight.get('s'), # unclear 62 | 'scheduled_arrival': dt_parser.parse(flight.get(scheduled_arrival_key)) if flight.get(scheduled_arrival_key) else None, 63 | 'scheduled_departure': dt_parser.parse(flight.get(scheduled_departure_key)) if flight.get(scheduled_departure_key) else None, 64 | 'status': flight.get('status'), 65 | 'stops': flight.get('stops'), 66 | 'terminal': flight.get('terminal'), 67 | 'type': flight.get('typ'), # unclear 68 | 'via_iata': flight.get('rou'), 69 | 'via_name': flight.get('rouname') 70 | } 71 | return out 72 | 73 | def get_flights(self, flight_type='departures', count=10, lang="en", page=1, timestamp=None): 74 | """ 75 | Get flight data 76 | 77 | flight_type: departures or arrivals 78 | count: How many flights to retrieve 79 | lang: Language for status fields in response 80 | page: which page of the results to get 81 | """ 82 | get_params = { 83 | 'perpage': count, 84 | 'lang': lang, 85 | 'page': page, 86 | 'flighttype': flight_type, 87 | 'time': (timestamp or datetime.datetime.utcnow()).strftime('%Y-%m-%dT%H:%M:00.000Z') 88 | } 89 | is_arrival = flight_type == 'arrivals' 90 | resp = requests.get("https://www.frankfurt-airport.com/de/_jcr_content.flights.json/filter", params=get_params) 91 | data = resp.json() 92 | #with open("out.json", 'r') as f: 93 | # data = json.load(f) 94 | #with open("out.json", 'w') as f: 95 | # json.dump(data, f) 96 | data['flights'] = [self._convert_flight_dict(flight, is_arrival=is_arrival) for flight in data['data']] 97 | del data['data'] 98 | return data 99 | -------------------------------------------------------------------------------- /pyfis/aegmis/mis1_text.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .mis1_protocol import MIS1Protocol 22 | 23 | from ..utils import debug_hex 24 | from ..utils.base_serial import BaseSerialPort 25 | 26 | 27 | class MIS1TextDisplay(MIS1Protocol): 28 | ALIGN_LEFT = 0x00 29 | ALIGN_RIGHT = 0x01 30 | ALIGN_CENTER = 0x02 31 | ALIGN_SCROLL = 0x03 # apparently not supported 32 | 33 | ATTR_BLINK = 0x01 34 | ATTR_INVERT = 0x02 35 | ATTR_BLINK_INV = 0x08 36 | 37 | DATE_FORMAT_DISABLE = 0x00 38 | DATE_FORMAT_DDMMYY = 0x01 39 | DATE_FORMAT_MMDDYY = 0x02 40 | DATE_FORMAT_YYMMDD = 0x03 41 | 42 | TIME_FORMAT_DISABLE = 0x00 43 | TIME_FORMAT_24H = 0x01 44 | TIME_FORMAT_12H_AM_PM = 0x02 45 | TIME_FORMAT_12H = 0x03 46 | 47 | def merge_attributes(self, text): 48 | if type(text) in (tuple, list): 49 | merged = "" 50 | for t, attrs in text: 51 | merged += "\x00" + chr(attrs) + t 52 | return merged 53 | return text 54 | 55 | def simple_text(self, page, row, col, text, align = ALIGN_LEFT): 56 | text = self.merge_attributes(text) 57 | text = text.encode("CP437", errors=self.encoding_errors) 58 | data = [align, page, row, col] + list(text) 59 | return self.send_command(0x11, 0x00, data, expect_response=False) 60 | 61 | def text(self, page, row, col_start, col_end, text, align = ALIGN_LEFT): 62 | text = self.merge_attributes(text) 63 | text = text.encode("CP437", errors=self.encoding_errors) 64 | data = [align, page, row, col_start >> 8, col_start & 0xFF, col_end >> 8, col_end & 0xFF] + list(text) 65 | return self.send_command(0x15, 0x00, data, expect_response=False) 66 | 67 | def set_pages(self, pages): 68 | flat_pages = [item for sublist in pages for item in sublist] 69 | data = [0x00] + flat_pages 70 | return self.send_command(0x24, 0x00, data, expect_response=False) 71 | 72 | def set_page(self, page): 73 | return self.set_pages([(page, 255)]) 74 | 75 | def reset(self): 76 | return self.send_command(0x31, 0x00, [], expect_response=False) 77 | 78 | def set_test_mode(self, state): 79 | return self.send_command(0x32, 0x00, [1 if state else 0]) 80 | 81 | def sync(self): 82 | return self.send_command(0x34, 0x00, [], expect_response=False) 83 | 84 | def set_clock(self, year, month, day, hour, minute, second): 85 | # apparently unsupported 86 | data = list(divmod(second, 10)[::-1]) 87 | data += divmod(minute, 10)[::-1] 88 | data += divmod(hour, 10)[::-1] 89 | data += divmod(day, 10)[::-1] 90 | data += divmod(month, 10)[::-1] 91 | data += divmod(year, 10)[::-1] 92 | data += [0x00] # weekday, unused 93 | return self.send_command(0x3A, 0x00, data, expect_response=False) 94 | 95 | def set_clock_display(self, date_format, date_row, date_col_start, date_col_end, time_format, time_row, time_col_start, time_col_end): 96 | # apparently unsupported 97 | data = [date_format, date_row] 98 | data += [date_col_start >> 8, date_col_start & 0xFF] 99 | data += [date_col_end >> 8, date_col_end & 0xFF] 100 | data += [time_format, time_row] 101 | data += [time_col_start >> 8, time_col_start & 0xFF] 102 | data += [time_col_end >> 8, time_col_end & 0xFF] 103 | return self.send_command(0x3D, 0x00, data, expect_response=False) 104 | 105 | def set_outputs(self, states): 106 | # states: array of 8 bools representing outputs 0 through 7 107 | state_byte = 0x00 108 | for i in range(max(8, len(states))): 109 | if states[i]: 110 | state_byte |= (1 << i) 111 | return self.send_command(0x41, 0x00, [0x00, 0x00, state_byte], expect_response=False) 112 | -------------------------------------------------------------------------------- /pyfis/krone/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | 19 | import time 20 | 21 | 22 | def calibrate_fbm_interactive(fbm, addr): 23 | """ 24 | Interactive process for calibrating the flap count 25 | and home position on small FBM units 26 | """ 27 | print("\nStep 0: Testing home position") 28 | print("Check if calibration is necessary by moving to the home position") 29 | print("and seeing if the unit hits it correctly.") 30 | input("Press Enter to start.") 31 | fbm.set_home() 32 | print("\nWait for the unit to stop rotating!") 33 | time.sleep(3) 34 | res = "" 35 | while res not in ("Y", "N"): 36 | res = input("Was the home position reached? [Y/N]: ").upper() 37 | if res == "Y": 38 | print("\nGreat! No need to calibrate.") 39 | return 40 | 41 | print("Step 1: Flap count (BR1) calibration") 42 | print("The unit will rotate once or twice and stop.") 43 | print("There is no need to do anything while it is rotating.") 44 | input("Press Enter to start.") 45 | fbm.start_calibration_br1() 46 | print("\nWait for the unit to stop rotating!") 47 | time.sleep(5) 48 | 49 | success = False 50 | back_to_step_2 = False 51 | while not success: 52 | exit_step_2 = False 53 | while not exit_step_2: 54 | print("\nStep 2: Home position (BR2) calibration") 55 | print("The unit will start rotating.") 56 | print("As soon as the desired home position is reached, hit Enter.") 57 | print("This might take a few tries.") 58 | print("The unit should complete one more rotation after you hit Enter!") 59 | print("If the unit stops rotating as soon as you hit Enter,") 60 | print("your timing was not correct. Try again in this case.") 61 | input("Press Enter to start and get ready to hit Enter again!") 62 | fbm.start_calibration_br2() 63 | input("Press Enter if the home position is reached!") 64 | fbm.stop_calibration() 65 | print("\nWait for the unit to stop rotating!") 66 | res = "" 67 | while res not in ("Y", "N"): 68 | res = input("Was the desired position reached? [Y/N]: ").upper() 69 | if res == "Y": 70 | exit_step_2 = True 71 | 72 | print("\nStep 3: Testing home position") 73 | print("Test the calibration by moving to the home position again") 74 | print("and seeing if the unit hits it correctly.") 75 | input("Press Enter to start.") 76 | fbm.set_home() 77 | print("\nWait for the unit to stop rotating!") 78 | time.sleep(3) 79 | res = "" 80 | while res not in ("Y", "N"): 81 | res = input("Was the home position reached? [Y/N]: ").upper() 82 | if res == "N": 83 | success = False 84 | back_to_step_2 = True 85 | break 86 | elif res == "Y": 87 | back_to_step_2 = False 88 | break 89 | 90 | if back_to_step_2: 91 | continue 92 | 93 | exit_step_4 = False 94 | while not exit_step_4: 95 | print("\nStep 4: Testing characters") 96 | print("Test the calibration by entering letters") 97 | print("and seeing if the unit hits them correctly.") 98 | char = "" 99 | while len(char) != 1 or ord(char) not in range(128): 100 | char = input("Enter a character to test or nothing to finish: ").upper() 101 | if char == "": 102 | exit_step_4 = True 103 | success = True 104 | break 105 | if len(char) > 1 or ord(char) not in range(128): 106 | continue 107 | else: 108 | fbm.set_code(addr, ord(char)) 109 | time.sleep(0.1) 110 | fbm.set_all() 111 | time.sleep(0.1) 112 | res = "" 113 | while res not in ("Y", "N"): 114 | res = input("Was the desired character hit? [Y/N]: ").upper() 115 | if res == "N": 116 | exit_step_4 = True 117 | success = False 118 | 119 | fbm.set_home() 120 | print("\nGreat! The unit is now calibrated.") 121 | -------------------------------------------------------------------------------- /pyfis/aegmis/mis1_protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .exceptions import CommunicationError, DisplayError 22 | from ..utils import debug_hex 23 | from ..utils.base_serial import BaseSerialPort 24 | 25 | 26 | class MIS1Protocol: 27 | ERROR_CODES = { 28 | 0: "OK", 29 | 1: "EFONTSIZE", 30 | 2: "CLIPPED", 31 | 5: "ECHAR_ATTRIBUTE", 32 | 6: "EINIT_DISPLAY", 33 | 8: "ERANGE", 34 | 11: "EOPTION", 35 | 13: "EHWDISPLAY", 36 | 15: "ESECTOR", 37 | 16: "EPAGE", 38 | 17: "BITMAP", 39 | 19: "EBITMAP", 40 | 20: "ELINE", 41 | 24: "ESECTORNR", 42 | 25: "EPAGENR", 43 | 26: "ESECX", 44 | 27: "ESECY", 45 | 36: "EFONTNR", 46 | 38: "EFONTMISS", 47 | 57: "EALLOC", 48 | 255: "EGENERAL" 49 | } 50 | 51 | def __init__(self, port, address=1, baudrate=9600, exclusive=True, debug=False, rx_timeout=3.0, use_rts=True, encoding_errors="strict"): 52 | self.address = address 53 | self.debug = debug 54 | self.rx_timeout = rx_timeout 55 | self.use_rts = use_rts 56 | self.encoding_errors = encoding_errors 57 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 58 | self.port = port 59 | else: 60 | self.port = serial.Serial(port, baudrate=baudrate, bytesize=8, parity="E", stopbits=1, exclusive=exclusive, timeout=rx_timeout) 61 | 62 | def checksum(self, data): 63 | checksum = 0x00 64 | for i, byte in enumerate(data): 65 | checksum += byte 66 | return (checksum % 256) | 0x80 67 | 68 | def escape(self, data): 69 | escaped = [] 70 | for byte in data: 71 | if byte in (0x02, 0x03, 0x04, 0x05, 0x10): 72 | escaped += [0x10, byte] 73 | else: 74 | escaped.append(byte) 75 | return escaped 76 | 77 | def unescape(self, data): 78 | unescaped = [] 79 | escape_active = False 80 | for byte in data: 81 | if not escape_active and byte == 0x10: 82 | escape_active = True 83 | else: 84 | escape_active = False 85 | unescaped.append(byte) 86 | return unescaped 87 | 88 | def send_raw_data(self, data): 89 | if self.debug: 90 | print("TX: " + debug_hex(data, readable_ascii=False, readable_ctrl=False)) 91 | if self.use_rts: 92 | self.port.setRTS(1) 93 | self.port.write(data) 94 | if self.use_rts: 95 | time.sleep(0.1) 96 | self.port.setRTS(0) 97 | 98 | def send_raw_telegram(self, data): 99 | telegram = [0x04, (0x80 | self.address), 0x02] + self.escape(data) + [0x03] + [self.checksum(data + [0x03])] 100 | self.send_raw_data(telegram) 101 | 102 | def send_command(self, code, subcode, data, expect_response=True): 103 | self.send_raw_telegram([code, subcode] + data) 104 | if expect_response: 105 | return self.read_response(ack=False) 106 | 107 | def send_tx_request(self): 108 | self.send_raw_data([0x04, (0x80 | self.address), 0x05]) 109 | return self.read_response(ack=True) 110 | 111 | def read_response(self, ack=False): 112 | data = self.port.read(1) 113 | if not data: 114 | raise CommunicationError("No response received from display") 115 | 116 | start = data[0] 117 | if start in (0x06, 0x15): 118 | response = data 119 | else: 120 | while start != 0x02: 121 | data = self.port.read(1) 122 | if not data: 123 | raise CommunicationError("No response received from display") 124 | start = data[0] 125 | 126 | response = [start] 127 | escape_active = False 128 | while True: 129 | data = self.port.read(1) 130 | if not data: 131 | raise CommunicationError("No response received from display") 132 | byte = data[0] 133 | if not escape_active: 134 | if byte == 0x10: 135 | escape_active = True 136 | elif byte == 0x03: 137 | response.append(byte) 138 | data = self.port.read(1) 139 | if not data: 140 | raise CommunicationError("No response received from display") 141 | checksum = data[0] 142 | response.append(checksum) 143 | break 144 | else: 145 | response.append(byte) 146 | else: 147 | escape_active = False 148 | response.append(byte) 149 | 150 | if self.debug: 151 | print("RX: " + debug_hex(response, readable_ascii=False, readable_ctrl=False)) 152 | 153 | # Send ACK if required and didn't get a NAK 154 | if ack and response != b'\x15': 155 | self.send_raw_data([0x06]) 156 | return response 157 | 158 | def check_error(self, response): 159 | if len(response) < 7: 160 | return 161 | if response[1] != 0x8C: 162 | return 163 | data = self.unescape(response[3:-2]) 164 | raise DisplayError(self.ERROR_CODES.get(data[1], str(data[1]))) 165 | -------------------------------------------------------------------------------- /k9000_fbm_toolkit.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import time 4 | 5 | from pyfis.krone import Krone9000FBM 6 | from pyfis.krone.util import calibrate_fbm_interactive 7 | 8 | 9 | def read_value_table(filename): 10 | """ 11 | Read the value table in KRONE .DAT format 12 | and return it as a dict 13 | """ 14 | 15 | with open(filename, 'r', encoding='latin-1') as f: 16 | lines = [line.strip() for line in f.readlines()] 17 | 18 | table = {} 19 | for line in lines: 20 | if not line: 21 | continue 22 | try: 23 | parts = line.split(";") 24 | remainder = ";".join(parts[1:]) 25 | pos = int(parts[0]) 26 | if remainder.startswith("0x"): 27 | value = int(remainder.split(";")[0], 16) 28 | else: 29 | value = ord(remainder[0]) 30 | table[pos] = value 31 | except: 32 | continue 33 | return table 34 | 35 | 36 | def main(): 37 | parser = argparse.ArgumentParser() 38 | parser.add_argument("-p", "--port", type=str, required=True) 39 | parser.add_argument("-t", "--table", type=str, required=False) 40 | parser.add_argument("-d", "--debug", action='store_true') 41 | args = parser.parse_args() 42 | 43 | fbm = Krone9000FBM(args.port, exclusive=True, debug=args.debug) 44 | 45 | if args.table: 46 | table = read_value_table(args.table) 47 | else: 48 | table = None 49 | 50 | while True: 51 | data = input("What to do? [Code/Status/Letter/Text/Home/Wrttbl/Deltbl/caliBrate/Autotest/Exit] ").upper() 52 | fbm.port.read(fbm.port.inWaiting()) # Flush buffer 53 | if not data: 54 | continue 55 | action = data[0] 56 | if action not in ("H", "E"): 57 | if len(data) < 2: 58 | print(" No address specified!") 59 | continue 60 | if action == "L": 61 | addr = int(data[1:-1]) 62 | else: 63 | addr = int(data[1:]) 64 | if action == "C": 65 | code = fbm.read_code(addr) 66 | sys.stdout.write(" FBM Code: ") 67 | if len(code) < 1: 68 | print("No data received!") 69 | continue 70 | code = code[0] 71 | if code == 0x10: 72 | print("Undefined position") 73 | elif code == 0x20: 74 | print("Home position") 75 | elif code < 0x20: 76 | print(f"Unknown status code 0x{code:02X}") 77 | else: 78 | print(chr(code)) 79 | elif action == "S": 80 | try: 81 | fbm_status = fbm.get_status(addr) or ["OK"] 82 | print(" FBM Status:", ", ".join(fbm_status)) 83 | except: 84 | print(" FBM Status: No data received!") 85 | continue 86 | elif action == "L": 87 | if len(data) < 3: 88 | print(" No letter specified!") 89 | continue 90 | letter = data[-1] 91 | fbm.set_code(addr, ord(letter[0])) 92 | fbm.set_all() 93 | elif action == "T": 94 | text = input(" Enter text: ").upper() 95 | fbm.set_text(text, addr) 96 | fbm.set_all() 97 | elif action == "H": 98 | print(" Rotating to home position") 99 | fbm.set_home() 100 | elif action == "W": 101 | if not table: 102 | print(" No value table loaded!") 103 | continue 104 | print(" Writing value table") 105 | for pos, value in table.items(): 106 | print(f" Writing flap {pos}: {chr(value)}") 107 | resp = fbm.set_table(addr, value, pos)[0] 108 | elif action == "D": 109 | if not table: 110 | print(" No value table loaded!") 111 | continue 112 | print(" Deleting value table") 113 | resp = fbm.delete_table(addr)[0] 114 | elif action == "B": 115 | if addr != 0: 116 | print(" Calibration is only possible for address 0!") 117 | continue 118 | calibrate_fbm_interactive(fbm, addr) 119 | elif action == "A": 120 | print(" Performing test routine") 121 | 122 | def _expect_code(code): 123 | for i in range(20): 124 | time.sleep(0.5) 125 | status = ", ".join(fbm.get_status(addr) or ["OK"]) 126 | print(f" Status: {status}") 127 | if status == "OK": 128 | break 129 | elif i == 19: 130 | print(" Error: Incorrect status!") 131 | return False 132 | actual_code = fbm.read_code(addr) 133 | if len(actual_code) < 1: 134 | print(" No data received!") 135 | return False 136 | actual_code = actual_code[0] 137 | if actual_code != code: 138 | print(f" Error: FBM reports code 0x{actual_code:02X} ({actual_code}, letter {chr(actual_code)}) instead of 0x{code:02X} ({code}, letter {chr(code)})") 139 | return False 140 | return True 141 | 142 | result = False 143 | for letter in ("0", "A", "M", "Z", "="): 144 | print(f" Rotating to letter {letter}") 145 | fbm.set_code(addr, ord(letter)) 146 | fbm.set_all() 147 | result = _expect_code(ord(letter)) 148 | if not result: 149 | break 150 | else: 151 | print(f" Success") 152 | time.sleep(1) 153 | if not result: 154 | continue 155 | 156 | print(" Rotating to home position") 157 | fbm.set_home() 158 | result = _expect_code(0x20) 159 | if not result: 160 | continue 161 | else: 162 | print(f" Success") 163 | elif action == "E": 164 | break 165 | 166 | 167 | 168 | if __name__ == "__main__": 169 | main() 170 | -------------------------------------------------------------------------------- /pyfis/krone/k9000_fbm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020 - 2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from ..utils.base_serial import BaseSerialPort 22 | from ..utils.utils import _debug_print, debug_hex 23 | 24 | 25 | class Krone9000FBM: 26 | """ 27 | Controls one or several FBM (Fallblattmodul) 28 | (split flap module) boards. 29 | """ 30 | 31 | CMD_SET_ALL = 0b0001 32 | CMD_SET_HOME = 0b0010 33 | CMD_RESET = 0b0011 34 | CMD_READ_STATUS = 0b0100 35 | CMD_READ_CODE = 0b0101 36 | CMD_LOCK = 0b0110 37 | CMD_UNLOCK = 0b0111 38 | CMD_SET_CODE = 0b1000 39 | CMD_START_CALIBRATION_BR2 = 0b1001 40 | CMD_START_CALIBRATION_BR1 = 0b1010 41 | CMD_STOP_CALIBRATION = 0b1011 42 | CMD_GET_CALIBRATION_VALUES = 0b1100 43 | CMD_SET_TABLE = 0b1101 44 | CMD_DELETE_TABLE = 0b1110 45 | 46 | def _get_fbm_status(self, stat): 47 | # Return human-readable error strings 48 | # based on FBM error bits 49 | if stat & 0x08: 50 | lut = { 51 | 0b1000: "comm_error", 52 | 0b1001: "start_missing", 53 | 0b1010: "unknown_char", 54 | 0b1011: "external_rotation", 55 | 0b1100: "rotation_timeout", 56 | 0b1101: "fbm_missing", 57 | 0b1111: "rotating" 58 | } 59 | return [lut.get(stat & 0x0f, "")] 60 | else: 61 | errors = [] 62 | if stat & 0x04: 63 | errors.append("no_ac") 64 | if stat & 0x02: 65 | errors.append("no_flap_imps") 66 | if stat & 0x01: 67 | errors.append("no_home_imp") 68 | return errors 69 | 70 | def __init__(self, port, debug = False, exclusive = True, encoding_errors = "strict"): 71 | self.debug = debug 72 | self.encoding_errors = encoding_errors 73 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 74 | self.port = port 75 | else: 76 | self.port = serial.Serial(port, baudrate=4800, parity=serial.PARITY_EVEN, timeout=2.0, exclusive=exclusive) 77 | 78 | def send_command(self, command, address = None, code = None, position = None, num_response_bytes = 0): 79 | # Build base command byte 80 | cmd_bytes = [] 81 | cmd_base = 0b10010000 82 | 83 | # Address expansion bit 84 | if address is not None and address > 127: 85 | cmd_base |= 0b01000000 86 | 87 | # Code expansion bit 88 | if code is not None and (code > 127 or (position is not None and position > 127)): 89 | cmd_base |= 0b00100000 90 | 91 | # Command bits 92 | cmd_base |= command 93 | 94 | # Build the data to be sent 95 | cmd_bytes.append(cmd_base) 96 | if address is not None: 97 | cmd_bytes.append(address & 0b01111111) 98 | if code is not None: 99 | cmd_bytes.append(code & 0b01111111) 100 | if position is not None: 101 | cmd_bytes.append(position & 0b01111111) 102 | 103 | _debug_print(self.debug, "TX:", debug_hex(cmd_bytes)) 104 | 105 | # Send it 106 | self.port.write(bytearray(cmd_bytes)) 107 | 108 | # Read response 109 | if num_response_bytes > 0: 110 | response = self.port.read(num_response_bytes) 111 | _debug_print(self.debug, "RX:", debug_hex(response)) 112 | return response 113 | else: 114 | return None 115 | 116 | def set_all(self): 117 | return self.send_command(self.CMD_SET_ALL) 118 | 119 | def set_home(self): 120 | return self.send_command(self.CMD_SET_HOME) 121 | 122 | def reset(self): 123 | return self.send_command(self.CMD_RESET) 124 | 125 | def read_status(self, address): 126 | return self.send_command(self.CMD_READ_STATUS, address, num_response_bytes=1) 127 | 128 | def read_code(self, address): 129 | return self.send_command(self.CMD_READ_CODE, address, num_response_bytes=1) 130 | 131 | def lock(self, address): 132 | return self.send_command(self.CMD_LOCK, address) 133 | 134 | def unlock(self, address): 135 | return self.send_command(self.CMD_UNLOCK, address) 136 | 137 | def set_code(self, address, code): 138 | return self.send_command(self.CMD_SET_CODE, address, code) 139 | 140 | def start_calibration_br2(self): 141 | return self.send_command(self.CMD_START_CALIBRATION_BR2) 142 | 143 | def start_calibration_br1(self): 144 | return self.send_command(self.CMD_START_CALIBRATION_BR1) 145 | 146 | def stop_calibration(self): 147 | return self.send_command(self.CMD_STOP_CALIBRATION) 148 | 149 | def get_calibration_values(self, address): 150 | return self.send_command(self.CMD_GET_CALIBRATION_VALUES, address, num_response_bytes=1) 151 | 152 | def set_table(self, address, code, position): 153 | return self.send_command(self.CMD_SET_TABLE, address, position, code, num_response_bytes=1) 154 | 155 | def delete_table(self, address): 156 | return self.send_command(self.CMD_DELETE_TABLE, address, num_response_bytes=1) 157 | 158 | def set_text(self, text, start_address, length = None, descending = False): 159 | if length is not None: 160 | text = text[:length].ljust(length) 161 | for i, char in enumerate(text): 162 | address = start_address - i if descending else start_address + i 163 | self.set_code(address, ord(char.encode('iso-8859-1', errors=self.encoding_errors))) 164 | 165 | def get_status(self, addr): 166 | return self._get_fbm_status(self.read_status(addr)[0]) 167 | 168 | def d_set_module_data(self, module_data): 169 | # Compatibility function for SplitFlapDisplay class 170 | for addr, code in module_data: 171 | self.set_code(addr, code) 172 | 173 | def d_update(self): 174 | # Compatibility function for SplitFlapDisplay class 175 | self.set_all() 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What's this? 2 | pyFIS allows you to control various kinds of passenger information systems. 3 | 4 | # Supported devices 5 | This library currently supports the following devices: 6 | 7 | * Bus and train displays 8 | * IBIS devices 9 | * All standard telegrams as well as some manufacturer-specific variants 10 | * Connection via serial or TCP 11 | * LAWO's MONO system (still quite rough) 12 | * Support for sending bitmaps to some LED displays 13 | * Support for XY10 flipdot pixel control 14 | * Split-Flap displays 15 | * KRONE / MAN System 9000/8200E "FBM" split-flap modules with ZiLOG microcontroller (in combination with the "FBUE" address board) 16 | * KRONE / MAN System 9000/8200E "FBK" split-flap group controller boards 17 | * KRONE / MAN System 9000/8200E "HLST" heater and light control boards 18 | * KRONE System 8200 (doesn't require any modifications, can send commands to the integrated display controller) 19 | * KRONE System 8200 PST boards (the piggyback boards on each split-flap module) 20 | * OMEGA split-flap units with RS-485 data protocol 21 | * Other displays 22 | * AEG MIS LCD signs: 23 | * Character-based displays with a Geavision Control Unit (GCU) using the MIS1 or MIS2 protocols 24 | * Large LCD boards using multiple GCUs 25 | * Graphical displays using the ECS protocol via network or the GSC100 controller via RS-422 26 | * Oltmann VISTRA-I passenger information displays 27 | * aesys DSA (single-line text displays found at smaller German train stations) 28 | * microSYST migra industrial LED signs using TCP/IP 29 | * mobitec matrix displays using the proprietary mobitec RS485 protocol 30 | * My own "Cheetah" universal display controller 31 | * My own very basic and generic split-flap interface protocol (you can use this to interface with any split-flap type display) 32 | * My own protocol for controlling a single-line scrolling RGB text display 33 | * Data Sources 34 | * Frankfurt Airport (Fraport) API for arrivals and departures 35 | * Other features 36 | * Support for reading LAWO's font file format 37 | 38 | # The `SplitFlapDisplay` class 39 | The `SplitFlapDisplay` class is an abstraction level you can use to represent a display made up of multiple split-flap modules. It functions as a wrapper for the various display controller classes. Using this class, you can create various fields such as a `TextField`, which represents of one or more alphanumerical split-flap modules, or a `CustomMapField`, which represents split-flap modules with texts or symbols printed on the flaps. Of course, the mapping of position code to displayed value can be set according to the modules you have. 40 | 41 | It can even render the display layout as ASCII graphics in your terminal! For more details, take a look at [the example script](/examples/splitflap_display_example.py). 42 | 43 | ![ASCII rendering of display output](/images/ascii_render.png?raw=true) 44 | ![Another ASCII rendering of display output](/images/ascii_render2.png?raw=true) 45 | 46 | # Hardware description 47 | Probably most relevant is the pinout of the various devices. 48 | Here's a short, incomplete summary. 49 | 50 | ## KRONE / MAN system with ZiLOG microcontrollers 51 | This system has a ZiLOG microcontroller on every split-flap unit and separate address boards, where the units are usually plugged in. This enables easy swapping of units without having to change the address, since the address boards would be permanently mounted in the display's backplane. 52 | 53 | The address is set with DIP switches and transferred to the split-flap unit using a shift register. 54 | 55 | The split-flap units have a 10-pin connector exposing the FBM single interface: 56 | 57 | ![FBM pin numbering](/images/krone_fbm_pin_numbering.jpg?raw=true) 58 | 59 | | Pin | Function | 60 | |-----|-------------------------------------------------| 61 | | 1 | GND | 62 | | 2 | 42V AC (Live) | 63 | | 3 | VCC (9...12V DC) | 64 | | 4 | 42V AC (Neutral) | 65 | | 5 | 5V DC output for address logic | 66 | | 6 | Address shift register data | 67 | | 7 | Address shift register clock | 68 | | 8 | Tx / Data from unit (CMOS logic levels) | 69 | | 9 | Rx / Data to unit (CMOS logic levels) | 70 | | 10 | Address shift register strobe | 71 | 72 | However, this alone is rather impractical. Controlling these units in combination with the address boards is much easier. The address boards have a 20-pin connector which exposes the FBM bus interface: 73 | 74 | | Pin | Function | 75 | |----------|------------------------------------------| 76 | | 1...6 | 42V AC (Live) | 77 | | 7...12 | 42V AC (Neutral) | 78 | | 13,14,15 | VCC (9...12V DC) | 79 | | 16,18,20 | GND | 80 | | 17 | Rx / Data to units (CMOS logic levels) | 81 | | 19 | Tx / Data from units (CMOS logic levels) | 82 | 83 | If you don't have the address boards, you can order the remade version I created together with [Phalos Southpaw](http://www.phalos-werkstatt.de/), which is available [here](https://github.com/Mezgrman/Krone-FBUE)! 84 | 85 | # Reference photos 86 | In case you're not sure what kind of display you have, here are some pictures: 87 | 88 | ![KRONE / MAN split-flap unit](/images/krone_zilog.jpg?raw=true) 89 | 90 | KRONE / MAN split-flap unit with ZiLOG microcontroller. There is also a variant with a THT microcontroller, which is also compatible. 91 | 92 | ![FBUE address board](/images/krone_fbue.jpg?raw=true) 93 | 94 | KRONE / MAN "FBUE" address board for "FBM" split-flap units 95 | 96 | ![KRONE / MAN FBK board](/images/krone_fbk.jpg?raw=true) 97 | 98 | KRONE / MAN "FBK" board for controlling groups of FBM+FBUE split-flap units 99 | 100 | ![OMEGA split-flap unit](/images/omega_unit.jpg?raw=true) 101 | 102 | OMEGA split-flap unit with RS-485 and DC input, commonly found in SBB (Swiss train operator) displays 103 | 104 | 105 | 106 | # Installation 107 | The base package can be installed with `pip install pyfis`. It installs pyFIS, the `serial` package as well as `crccheck` and `crcmod` for calculating and verifying checksums. 108 | 109 | Some parts of the library have extra dependencies: 110 | * The Fraport data source and the xatLabs Cheetah module require the `requests` package for performing HTTP requests. Install with `pip install pyfis[http]`. 111 | * All graphical displays require `Pillow` for handling graphics. Install with `pip install pyfis[graphics]`. 112 | * The `rpi_gpio` backend used for KRONE 8200 split-flap displays requires the `RPi.GPIO` package to run on a Raspberry Pi. Install with `pip install pyfis[raspberrypi]`. 113 | * To install all optional dependencies, run `pip install pyfis[full]`. -------------------------------------------------------------------------------- /pyfis/krone/k9000_rs485.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .exceptions import CommunicationError, NACKError, BusyError 22 | from ..utils.base_serial import BaseSerialPort 23 | 24 | 25 | class Krone9000RS485Controller: 26 | """ 27 | Basic control scheme for the internal RS485 bus 28 | in the KRONE 9000 system. 29 | """ 30 | 31 | FLAG_ACK = 0x80 32 | 33 | CTRL_READ = 0x01 34 | CTRL_WRITE_SINGLE = 0x02 35 | CTRL_WRITE_BLOCK = 0x04 36 | 37 | STATUS_ACK = 0x06 38 | STATUS_BUSY = 0x10 39 | STATUS_NACK = 0x15 40 | 41 | MAX_CHUNK_SIZE = 128 42 | RETRY_COUNT = 10 43 | RETRY_INTERVAL = 0.2 44 | 45 | def __init__(self, port, address, timeout = 1.0, debug = False, exclusive = True): 46 | self.address = address 47 | self.debug = debug 48 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 49 | self.port = port 50 | else: 51 | self.port = serial.Serial(port, baudrate=19200, timeout=timeout, exclusive=exclusive) 52 | 53 | @staticmethod 54 | def _chunks(lst, n): 55 | """Yield successive n-sized chunks from lst.""" 56 | for i in range(0, len(lst), n): 57 | yield lst[i:i + n] 58 | 59 | def debug_print(self, data, receive = False): 60 | if self.debug: 61 | print(f"{self.BOARD_NAME:<4} {self.address} {'RX' if receive else 'TX'}: " + " ".join((format(x, "02X") for x in data))) 62 | 63 | def make_checksum(self, payload): 64 | checksum = 0x00 65 | for byte in payload: 66 | checksum ^= byte 67 | return checksum 68 | 69 | def check_checksum(self, data): 70 | checksum = self.make_checksum(data[2:-1]) 71 | return checksum == data[-1] 72 | 73 | def send_command(self, command, parameters = None, response = False, ack = True, block = False): 74 | def _chunks(lst, n): 75 | # Yield successive n-sized chunks from lst. 76 | for i in range(0, len(lst), n): 77 | yield lst[i:i + n] 78 | 79 | data = [command] 80 | if parameters is not None: 81 | if type(parameters) in (list, tuple): 82 | data.extend(parameters) 83 | else: 84 | data.append(parameters) 85 | 86 | if len(data) > self.MAX_CHUNK_SIZE and block == False: 87 | raise CommunicationError("Data too long for single-block transfer. Use multi-block mode!") 88 | 89 | if response: 90 | control = self.CTRL_READ 91 | else: 92 | if block: 93 | control = self.CTRL_WRITE_BLOCK 94 | else: 95 | control = self.CTRL_WRITE_SINGLE 96 | 97 | if ack: 98 | control |= self.FLAG_ACK 99 | 100 | for chunk_id, chunk in enumerate(_chunks(data, self.MAX_CHUNK_SIZE)): 101 | for retry in range(self.RETRY_COUNT): 102 | payload = [self.BOARD_ID, self.address, 0x00, control, chunk_id + 1] + chunk + [0x00] 103 | length = len(payload) 104 | payload[2] = length 105 | checksum = self.make_checksum(payload) 106 | cmd_bytes = [0xFF, 0xFF] + payload + [checksum] 107 | 108 | # Debug output if enabled 109 | self.debug_print(cmd_bytes) 110 | 111 | # Send it 112 | self.port.write(bytearray(cmd_bytes)) 113 | 114 | # Check status 115 | if not response: 116 | if control & self.FLAG_ACK: 117 | try: 118 | self.check_status() 119 | except BusyError: 120 | if retry >= self.RETRY_COUNT - 1: 121 | raise 122 | else: 123 | time.sleep(self.RETRY_INTERVAL) 124 | else: 125 | break 126 | else: 127 | break 128 | 129 | # Read response 130 | if response: 131 | return self.read_response() 132 | else: 133 | return None 134 | 135 | def check_status(self): 136 | status = bytearray(self.port.read(1))[0] 137 | self.debug_print([status], receive=True) 138 | if status == self.STATUS_ACK: 139 | return True 140 | elif status == self.STATUS_BUSY: 141 | raise BusyError() 142 | elif status == self.STATUS_NACK: 143 | raise NACKError() 144 | else: 145 | raise CommunicationError(f"Unknown status byte {status:02X}") 146 | 147 | def read_response(self): 148 | data = bytearray() 149 | header = bytearray(self.port.read(4)) 150 | if len(header) != 4: 151 | if len(header) == 1: 152 | if header[0] == self.STATUS_BUSY: 153 | raise BusyError() 154 | if header[0] == self.STATUS_NACK: 155 | raise NACKError() 156 | header_fmt = " ".join((format(x, "02X") for x in header)) 157 | raise CommunicationError(f"Incomplete header: {header_fmt}") 158 | if header[0] != 0xFF or header[1] != 0xFF: 159 | raise CommunicationError(f"Invalid start sequence {header[0]:02X} {header[1]:02X}") 160 | if header[2] != self.BOARD_ID: 161 | raise CommunicationError(f"Invalid board ID {header[2]:02X}") 162 | if header[3] != self.address: 163 | raise CommunicationError(f"Invalid address {header[3]:02X}") 164 | data.extend(header) 165 | 166 | length = bytearray(self.port.read(1))[0] 167 | payload = bytearray(self.port.read(length - 3)) 168 | checksum = bytearray(self.port.read(1))[0] 169 | 170 | data.append(length) 171 | data.extend(payload) 172 | data.append(checksum) 173 | 174 | self.debug_print(data, receive=True) 175 | if not self.check_checksum(data): 176 | raise CommunicationError("Checksum mismatch") 177 | return payload[2:-1] 178 | 179 | def send_heartbeat(self): 180 | cmd_bytes = [0xFF, 0xFF, self.BOARD_ID, self.address, 0x00] 181 | self.debug_print(cmd_bytes) 182 | self.port.write(bytearray(cmd_bytes)) 183 | -------------------------------------------------------------------------------- /pyfis/splitflap_display/ascii_graphics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | class AsciiGraphics: 19 | PIECES = ["━", "┃", "┏", "┗", "┓", "┛", "┣", "┳", "┫", "┻", "╋"] 20 | PIECES_2_POINTS = ["━", "┃", "┏", "┗", "┓", "┛"] 21 | PIECES_3_POINTS = ["┣", "┳", "┫", "┻"] 22 | PIECES_4_POINTS = ["╋"] 23 | PIECE_COMBINATIONS = { 24 | "━": { 25 | "┃": "╋", 26 | "┏": "┳", 27 | "┗": "┻", 28 | "┓": "┳", 29 | "┛": "┻", 30 | "┣": "╋", 31 | "┫": "╋", 32 | }, 33 | "┃": { 34 | "━": "╋", 35 | "┏": "┣", 36 | "┗": "┣", 37 | "┓": "┫", 38 | "┛": "┫", 39 | "┳": "╋", 40 | "┻": "╋", 41 | }, 42 | "┏": { 43 | "━": "┳", 44 | "┃": "┣", 45 | "┗": "┣", 46 | "┓": "┳", 47 | "┛": "╋", 48 | "┫": "╋", 49 | "┻": "╋", 50 | }, 51 | "┗": { 52 | "━": "┻", 53 | "┃": "┣", 54 | "┏": "┣", 55 | "┓": "╋", 56 | "┛": "┻", 57 | "┳": "╋", 58 | "┫": "╋", 59 | }, 60 | "┓": { 61 | "━": "┳", 62 | "┃": "┫", 63 | "┏": "┳", 64 | "┗": "╋", 65 | "┛": "┫", 66 | "┻": "╋", 67 | "┣": "╋", 68 | }, 69 | "┛": { 70 | "━": "┻", 71 | "┃": "┫", 72 | "┏": "╋", 73 | "┗": "┻", 74 | "┓": "┫", 75 | "┣": "╋", 76 | "┳": "╋", 77 | }, 78 | "┣": { 79 | "━": "╋", 80 | "┓": "╋", 81 | "┛": "╋", 82 | "┳": "╋", 83 | "┫": "╋", 84 | "┻": "╋", 85 | }, 86 | "┳": { 87 | "┃": "╋", 88 | "┛": "╋", 89 | "┗": "╋", 90 | "┣": "╋", 91 | "┫": "╋", 92 | "┻": "╋", 93 | }, 94 | "┫": { 95 | "━": "╋", 96 | "┏": "╋", 97 | "┗": "╋", 98 | "┣": "╋", 99 | "┳": "╋", 100 | "┻": "╋", 101 | }, 102 | "┻": { 103 | "┃": "╋", 104 | "┏": "╋", 105 | "┓": "╋", 106 | "┣": "╋", 107 | "┳": "╋", 108 | "┫": "╋", 109 | } 110 | } 111 | 112 | def __init__(self, width, height): 113 | self.width = width 114 | self.height = height 115 | self.clear() 116 | 117 | def render(self): 118 | output = "\n".join(["".join(row) for row in self.canvas]) 119 | return output 120 | 121 | def clear(self): 122 | self.canvas = [] 123 | for y in range(self.height): 124 | row = [] 125 | for x in range(self.width): 126 | row.append(" ") 127 | self.canvas.append(row) 128 | 129 | def get_num_points(self, piece): 130 | """ 131 | Return how many points a piece has 132 | """ 133 | if piece in self.PIECES_2_POINTS: 134 | return 2 135 | if piece in self.PIECES_3_POINTS: 136 | return 3 137 | if piece in self.PIECES_4_POINTS: 138 | return 4 139 | return 0 140 | 141 | def combine_piece(self, piece1, piece2): 142 | """ 143 | Adds piece2 to the canvas, 144 | combining it with piece1 145 | """ 146 | if piece1 in self.PIECES and piece2 not in self.PIECES: 147 | # Frame pieces take precedence, ignore anything else 148 | return piece1 149 | if piece1 not in self.PIECES and piece2 not in self.PIECES: 150 | # Just overwrite if neither piece is a frame piece 151 | return piece2 152 | if piece1 == " ": 153 | # No piece present, so just put the new one there 154 | return piece2 155 | else: 156 | combination = self.PIECE_COMBINATIONS.get(piece1, {}).get(piece2) 157 | if combination is None: 158 | combination = self.PIECE_COMBINATIONS.get(piece2, {}).get(piece1) 159 | if combination is None: 160 | # If no combination can be found, the piece with more points takes precedence 161 | # In case of equal numbers, the newer piece takes precedence 162 | if self.get_num_points(piece1) > self.get_num_points(piece2): 163 | return piece1 164 | else: 165 | return piece2 166 | return combination 167 | 168 | def draw_text(self, x, y, text, spacing = 1): 169 | for i, char in enumerate(text): 170 | self.draw_piece(x+(i*spacing), y, char) 171 | 172 | def draw_piece(self, x, y, piece): 173 | existing_piece = self.canvas[y][x] 174 | new_piece = self.combine_piece(existing_piece, piece) 175 | self.canvas[y][x] = new_piece 176 | 177 | def draw_line(self, x, y, length, direction, t_ends = False): 178 | """ 179 | The t_ends parameter controls whether a line 180 | just ends straight or in a T cross 181 | """ 182 | if length <= 0: 183 | return 184 | if direction == 'h': 185 | if t_ends: 186 | self.draw_piece(x, y, "┣") 187 | self.draw_piece(x+length-1, y, "┫") 188 | if length > 2: 189 | for i in range(length-2): 190 | self.draw_piece(x+i+1, y, "━") 191 | else: 192 | for i in range(length): 193 | self.draw_piece(x+i, y, "━") 194 | elif direction == 'v': 195 | if t_ends: 196 | self.draw_piece(x, y, "┳") 197 | self.draw_piece(x, y+length-1, "┻") 198 | if length > 2: 199 | for i in range(length-2): 200 | self.draw_piece(x, y+i+1, "┃") 201 | else: 202 | for i in range(length): 203 | self.draw_piece(x, y+i, "┃") 204 | 205 | def draw_rectangle(self, x, y, width, height): 206 | self.draw_piece(x, y, "┏") 207 | self.draw_piece(x+width-1, y, "┓") 208 | self.draw_piece(x, y+height-1, "┗") 209 | self.draw_piece(x+width-1, y+height-1, "┛") 210 | if width > 2: 211 | self.draw_line(x+1, y, width-2, 'h') 212 | self.draw_line(x+1, y+height-1, width-2, 'h') 213 | if height > 2: 214 | self.draw_line(x, y+1, height-2, 'v') 215 | self.draw_line(x+width-1, y+1, height-2, 'v') 216 | -------------------------------------------------------------------------------- /pyfis/xatlabs/cheetah.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import base64 19 | import requests 20 | 21 | from PIL import Image 22 | 23 | from ..splitflap_display import SplitFlapDisplay, TextField, CustomMapField 24 | 25 | 26 | class xatLabsCheetah: 27 | """ 28 | Controls the xatLabs Cheetah universal display controller. 29 | This uses TCP-based protocol in which the framebuffer is 30 | transmitted as a Base64 encoded string. 31 | The reason it's done this was is to allow transferring the 32 | framebuffer inside a JSON object. 33 | Cheetah supports various types of displays, including: 34 | - Pixel-based displays (Only 1bpp pixel buffer supported so far) 35 | - Character-based displays (e.g. a character LCD or alphanumerical split-flap display) 36 | - Selection-based displays (e.g. a rolling film or generic split-flap display) 37 | """ 38 | 39 | def __init__(self, host = None, display_info = None, device_info = None, encoding_errors = "strict"): 40 | self.host = host 41 | self.display_info = display_info 42 | self.device_info = device_info 43 | self.encoding_errors = encoding_errors 44 | if self.host is not None: 45 | self.load_display_info() 46 | self.load_device_info() 47 | elif self.display_info is None or self.device_info is None: 48 | raise ValueError("Either host or display_info and device_info must be given") 49 | self.init_buffers() 50 | 51 | def init_buffers(self): 52 | self.pixel_buffer = None 53 | self.text_buffer = None 54 | self.unit_buffer = None 55 | 56 | pixbuf_size = self.display_info.get('pixbuf_size') 57 | if pixbuf_size is not None: 58 | self.pixel_buffer = [0] * pixbuf_size 59 | 60 | textbuf_size = self.display_info.get('textbuf_size') 61 | if textbuf_size is not None: 62 | self.text_buffer = [0] * textbuf_size 63 | 64 | unitbuf_size = self.display_info.get('unitbuf_size') 65 | if unitbuf_size is not None: 66 | self.unit_buffer = [0] * unitbuf_size 67 | 68 | def load_display_info(self): 69 | resp = requests.get(f"{self.host}/info/display.json") 70 | self.display_info = resp.json() 71 | 72 | def load_device_info(self): 73 | resp = requests.get(f"{self.host}/info/device.json") 74 | self.device_info = resp.json() 75 | 76 | def buffer_to_base64(self, buffer): 77 | buf = bytearray(buffer) 78 | return base64.b64encode(buf).decode('ascii') 79 | 80 | def update_pixel_buffer(self, image): 81 | self.pixel_buffer = [0] * len(self.pixel_buffer) 82 | if type(image) in (list, tuple): 83 | buf = image[:len(self.pixel_buffer)] 84 | else: 85 | if not isinstance(image, Image.Image): 86 | image = Image.open(image) 87 | pixbuf_type = self.display_info.get('pixbuf_type') 88 | frame_width = self.display_info.get('frame_width_pixel') 89 | frame_height = self.display_info.get('frame_height_pixel') 90 | image_width, image_height = image.size 91 | if pixbuf_type == '1bpp': 92 | image = image.convert('L') 93 | pixels = image.load() 94 | buf = [] 95 | for x in range(image_width): 96 | for y in range(0, image_height, 8): 97 | byte = 0 98 | for bit in range(8): 99 | if y + bit < image_height: 100 | byte = (byte >> 1) | ((1 if pixels[x, y + bit] > 127 else 0) << 7) 101 | else: 102 | byte >>= 1 103 | buf.append(byte) 104 | else: 105 | raise NotImplementedError(f"{pixbuf_type} pixel buffer not yet supported") 106 | for i in range(len(self.pixel_buffer)): 107 | if i < len(buf): 108 | self.pixel_buffer[i] = buf[i] 109 | else: 110 | self.pixel_buffer[i] = 0 111 | 112 | def update_text_buffer(self, text): 113 | characters = text.encode('iso-8859-1', errors=self.encoding_errors) 114 | for i in range(len(self.text_buffer)): 115 | if i < len(characters): 116 | self.text_buffer[i] = characters[i] 117 | else: 118 | self.text_buffer[i] = 0 119 | 120 | def update_unit_buffer(self, module_data): 121 | self.unit_buffer = [0] * len(self.unit_buffer) 122 | for pos, val in module_data.items(): 123 | self.unit_buffer[pos] = int(val) 124 | 125 | def set_brightness(self, brightness): 126 | if not self.display_info.get('brightness_control'): 127 | raise NotImplementedError("Display does not support brightness control") 128 | assert brightness in range(0, 256) 129 | if self.host is not None: 130 | requests.post(f"{self.host}/canvas/brightness.json", json={'brightness': brightness}) 131 | 132 | def set_image(self, image): 133 | if self.display_info.get('pixbuf_size') is None: 134 | raise NotImplementedError("Display does not have a pixel buffer") 135 | self.update_pixel_buffer(image) 136 | if self.host is not None: 137 | buffer_b64 = self.buffer_to_base64(self.pixel_buffer) 138 | requests.post(f"{self.host}/canvas/buffer/pixel", data=buffer_b64) 139 | 140 | def set_text(self, text): 141 | if self.display_info.get('textbuf_size') is None: 142 | raise NotImplementedError("Display does not have a text buffer") 143 | self.update_text_buffer(text) 144 | if self.host is not None: 145 | buffer_b64 = self.buffer_to_base64(self.text_buffer) 146 | requests.post(f"{self.host}/canvas/buffer/text", data=buffer_b64) 147 | 148 | def d_set_module_data(self, module_data): 149 | # Compatibility function for SplitFlapDisplay class 150 | self.update_unit_buffer(dict(module_data)) 151 | 152 | def d_update(self): 153 | # Compatibility function for SplitFlapDisplay class 154 | if self.display_info.get('unitbuf_size') is None: 155 | raise NotImplementedError("Display does not have a unit buffer") 156 | if self.host is not None: 157 | buffer_b64 = self.buffer_to_base64(self.unit_buffer) 158 | requests.post(f"{self.host}/canvas/buffer/unit", data=buffer_b64) 159 | 160 | def get_splitflap_display(self): 161 | if self.display_info.get('type') != 'selection': 162 | raise NotImplementedError("Only selection displays support get_splitflap_display") 163 | config = self.display_info.get('config', {}) 164 | unit_data = config.get('units') 165 | if not unit_data: 166 | raise NotImplementedError("Display does not provide layout data") 167 | map_data = config.get('maps') 168 | if not map_data: 169 | raise NotImplementedError("Display does not provide mapping data") 170 | 171 | display = SplitFlapDisplay.from_json(config, self) 172 | return display 173 | -------------------------------------------------------------------------------- /pyfis/xatlabs/rgb_dsa.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021-2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from pprint import pprint 22 | 23 | from .exceptions import CommunicationError 24 | from ..utils.base_serial import BaseSerialPort 25 | 26 | 27 | class xatLabsRGBDSAController: 28 | """ 29 | Protocol implementation for a custom built replacement 30 | for the ubiquitous orange LED signs in DB stations. 31 | Uses 8x8 WS2812 matrix boards and an Arduino-based controller. 32 | Custom serial protocol is implemented here. 33 | """ 34 | 35 | CMD_SET_TEXT = 0xA0 36 | CMD_SET_BRIGHTNESS = 0xA1 37 | CMD_DELETE_TEXT = 0xA2 38 | 39 | SYNC = 0xCC 40 | 41 | ERR_TIMEOUT = 0xE0 42 | ERR_UNKNOWN_CMD = 0xE1 43 | ERR_PAYLOAD_TOO_LARGE = 0xE2 44 | ERR_GENERIC = 0xEE 45 | 46 | SUCCESS = 0xFF 47 | 48 | NO_CLEAR = 0b00001000 49 | 50 | ALIGN_LEFT = 0b00000100 51 | ALIGN_CENTER = 0b00000110 52 | ALIGN_RIGHT = 0b00000010 53 | 54 | SCROLL = 0b00000001 55 | 56 | def __init__(self, port, debug = False, exclusive = True, no_dtr = False, encoding_errors = "strict"): 57 | self.debug = debug 58 | self.encoding_errors = encoding_errors 59 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 60 | self.port = port 61 | else: 62 | self.port = serial.Serial() 63 | self.port.port = port 64 | self.port.baudrate = 115200 65 | self.port.timeout = 2.0 66 | self.port.exclusive = exclusive 67 | if no_dtr: 68 | self.port.setDTR(False) 69 | self.port.open() 70 | 71 | def debug_message(self, message): 72 | """ 73 | Turn a message into a readable form 74 | """ 75 | result = "" 76 | for byte in message: 77 | if True or byte in range(0, 32) or byte >= 127: 78 | result += "{:02X}".format(byte) 79 | else: 80 | result += chr(byte) 81 | result += " " 82 | return result 83 | 84 | def sync(self): 85 | """ 86 | Wait for a sync byte from the serial port 87 | """ 88 | # Flush buffer first since we must not read old sync bytes 89 | self.port.read(self.port.inWaiting()) 90 | sync = None 91 | while not sync: 92 | if self.debug: 93 | print("Syncing") 94 | sync = self.port.read(1) 95 | if self.debug: 96 | print("Synced") 97 | if sync[0] == self.SYNC: 98 | return True 99 | else: 100 | raise CommunicationError("Sync: Unexpected byte 0x{:02X}".format(sync[0])) 101 | 102 | def read_response(self): 103 | """ 104 | Read the response 105 | """ 106 | response = [self.SYNC] 107 | while response and response[0] == self.SYNC: 108 | response = self.port.read(1) 109 | if not response: 110 | raise CommunicationError("Timeout (no response)") 111 | if self.debug: 112 | print("RX: " + self.debug_message(response)) 113 | if response[0] == self.ERR_TIMEOUT: 114 | raise CommunicationError("Timeout (device received incomplete message)") 115 | if response[0] == self.ERR_UNKNOWN_CMD: 116 | raise CommunicationError("Unknown command") 117 | if response[0] == self.ERR_PAYLOAD_TOO_LARGE: 118 | raise CommunicationError("Payload too large") 119 | if response[0] == self.ERR_GENERIC: 120 | raise CommunicationError("Generic error") 121 | if response[0] == self.SUCCESS: 122 | return True 123 | return False 124 | 125 | def send_command(self, action, payload): 126 | data = [0xFF, action] + payload 127 | if self.debug: 128 | print("TX: " + self.debug_message(data)) 129 | self.port.write(bytearray(data)) 130 | 131 | def send_command_with_response(self, action, payload): 132 | """ 133 | Send a command and retrieve the response data 134 | """ 135 | self.send_command(action, payload) 136 | return self.read_response() 137 | 138 | def set_text(self, slot, text, attrs, duration): 139 | """ 140 | Set text with color config. 141 | 142 | slot: Which text slot to use 143 | text: The text to be displayed (either str or list, see below) 144 | attrs: Combination of attributes (NO_CLEAR, ALIGN, SCROLL) 145 | duration: Duration for the text to be displayed in ms (Only relevant for non-scrolling texts) 146 | 147 | text list structure: 148 | [ 149 | { 150 | "text": "Hello ", 151 | "color": "ff0000" 152 | }, 153 | { 154 | "text": "World!", 155 | "red": 255, 156 | "green": 0, 157 | "blue": 128 158 | } 159 | ] 160 | """ 161 | payload = [] 162 | 163 | if type(text) in (list, tuple): 164 | _text = "".join(d['text'] for d in text).encode("CP437", errors=self.encoding_errors) 165 | _segments = [] 166 | pos = 0 167 | for i, t in enumerate(text): 168 | seg = {} 169 | seg['start'] = pos 170 | seg['end'] = pos + len(t['text']) 171 | if "color" in t: 172 | append = True 173 | seg['red'] = int(t['color'][0:2], 16) 174 | seg['green'] = int(t['color'][2:4], 16) 175 | seg['blue'] = int(t['color'][4:6], 16) 176 | elif "red" in t and "green" in t and "blue" in t: 177 | append = True 178 | seg['red'] = t['red'] 179 | seg['green'] = t['green'] 180 | seg['blue'] = t['blue'] 181 | else: 182 | append = False 183 | if append: 184 | _segments.append(seg) 185 | pos += len(t['text']) 186 | else: 187 | _text = str(text).encode("CP437", errors=self.encoding_errors) 188 | _segments = [] 189 | 190 | payload.extend([slot, len(text) >> 8, len(_text) & 0xFF, attrs, duration >> 8, duration & 0xFF]) 191 | payload.extend(_text) 192 | payload.extend([len(_segments)]) 193 | for seg in _segments: 194 | payload.extend([0x01, 0x07, seg['start'] >> 8, seg['start'] & 0xFF, seg['end'] >> 8, seg['end'] & 0xFF, seg['red'], seg['green'], seg['blue']]) 195 | payload = [len(payload) >> 8, len(payload) & 0xFF] + payload 196 | 197 | return self.send_command_with_response(self.CMD_SET_TEXT, payload) 198 | 199 | def set_brightness(self, brightness): 200 | """ 201 | Set display brightness (0 to 255) 202 | """ 203 | return self.send_command_with_response(self.CMD_SET_BRIGHTNESS, [brightness]) 204 | 205 | def delete_text(self, slot): 206 | """ 207 | Delete the text in the specified slot 208 | """ 209 | return self.send_command_with_response(self.CMD_DELETE_TEXT, [slot]) 210 | -------------------------------------------------------------------------------- /pyfis/utils/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021-2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import csv 19 | import itertools 20 | 21 | 22 | def high16(value): 23 | # Get high byte of a 16-bit value 24 | return value >> 8 25 | 26 | def low16(value): 27 | # Get low byte of a 16-bit value 28 | return value & 0xFF 29 | 30 | def int_to_bcd(value): 31 | # Turn a positive integer into its hexadecimal BCD representation. 32 | # E.g. 37 => 0x37 33 | result = 0x00 34 | value_str = str(value) 35 | for pos, char in enumerate(value_str[::-1]): 36 | result += int(char) * 16**pos 37 | return result 38 | 39 | 40 | def _debug_print(debug, *args, **kwargs): 41 | if debug: 42 | print(*args, **kwargs) 43 | 44 | def debug_hex(message, readable_ascii = False, readable_ctrl = False): 45 | """ 46 | Turn a message into a readable form 47 | """ 48 | 49 | CTRL_CHARS = { 50 | 0x02: "STX", 51 | 0x03: "ETX", 52 | 0x04: "EOT", 53 | 0x05: "ENQ", 54 | 0x10: "DLE", 55 | 0x15: "NAK", 56 | 0x17: "ETB" 57 | } 58 | 59 | result = [] 60 | for byte in message: 61 | if readable_ctrl and byte in CTRL_CHARS: 62 | result.append(CTRL_CHARS[byte]) 63 | elif readable_ascii and byte not in range(0, 32) and byte != 127: 64 | result.append(chr(byte)) 65 | else: 66 | result.append("{:02X}".format(byte)) 67 | return " ".join(result) 68 | 69 | 70 | def vias_in_route(route, vias): 71 | # Check if the given vias are all present in the given route in the right order 72 | # If an entry in vias is a list, all of its items will be considered to be aliases of each other 73 | if not vias: 74 | return False 75 | i = 0 76 | j = 0 77 | while i < len(route) and j < len(vias): 78 | if type(vias[j]) in (tuple, list): 79 | for alias in vias[j]: 80 | if route[i] == alias: 81 | j += 1 82 | break 83 | else: 84 | if route[i] == vias[j]: 85 | j += 1 86 | i += 1 87 | return j == len(vias) 88 | 89 | 90 | def get_vias(route, weights, *via_groups, check_dashes=True, debug=False): 91 | # Get the ideal combination of vias based on split-flap modules 92 | num_groups = len(via_groups) 93 | 94 | # Go through all via groups and take note of possible candidates 95 | via_candidates = [] 96 | for group in via_groups: 97 | group_candidates = [] 98 | for pos, entry in group.items(): 99 | if vias_in_route(route, entry['stations']): 100 | group_candidates.append(pos) 101 | via_candidates.append(group_candidates) 102 | _debug_print(debug, "Via candidates:") 103 | _debug_print(debug, via_candidates) 104 | 105 | # Check all combinations to see if the order makes sense 106 | combinations = itertools.product(*via_candidates) 107 | valid_combinations = [] 108 | _debug_print(debug, "\nVia candidates with sensible order:") 109 | for combination in combinations: 110 | stations = [] 111 | for group, pos in enumerate(combination): 112 | stations.extend(via_groups[group][pos]['stations']) 113 | if vias_in_route(route, stations): 114 | _debug_print(debug, combination, stations) 115 | valid_combinations.append(combination) 116 | 117 | # If check_dashes is True, check if the starts and endings are compatible, 118 | # i.e. if the first segment ends on a dash, the next one 119 | # cannot start with one. 120 | if check_dashes: 121 | valid_dash_combinations = [] 122 | _debug_print(debug, "\nCandidates after check_dashes:") 123 | for combination in valid_combinations: 124 | valid = True 125 | prev_text = None 126 | for group, pos in enumerate(combination): 127 | text = via_groups[group][pos]['text'].strip() 128 | if group > 0: 129 | if prev_text and text and prev_text.endswith("-") == text.startswith("-"): 130 | _debug_print(debug, "Excluded: ", prev_text, text) 131 | valid = False 132 | break 133 | prev_text = text 134 | if valid: 135 | _debug_print(debug, combination) 136 | valid_dash_combinations.append(combination) 137 | valid_combinations = valid_dash_combinations 138 | 139 | # Build the texts of all valid combinations 140 | # and remove combinations that contain double entries 141 | final_combinations = [] 142 | for combination in valid_combinations: 143 | text_stations = [] 144 | for group, pos in enumerate(combination): 145 | text_stations.extend([s.strip() for s in via_groups[group][pos]['text'].split(" - ") if s.strip()]) 146 | if len(set(text_stations)) == len(text_stations): 147 | # No double entries detected 148 | final_combinations.append([combination, text_stations]) 149 | 150 | # Calculate the total weight of each combinations 151 | for i, entry in enumerate(final_combinations): 152 | combination, text_stations = entry 153 | weight = 0 154 | for text_station in text_stations: 155 | weight += weights.get(text_station, 1) 156 | final_combinations[i].append(weight) 157 | final_combinations.sort(key=lambda c: c[2], reverse=True) 158 | 159 | _debug_print(debug, "\nFinal combinations (Score, Positions, Text):") 160 | for entry in final_combinations: 161 | _debug_print(debug, entry[2], entry[0], " - ".join(entry[1])) 162 | _debug_print(debug, "") 163 | 164 | if final_combinations: 165 | return final_combinations[0][0] 166 | else: 167 | return None 168 | 169 | def vias_from_csv(filename): 170 | # Build the dict required for get_vias from a CSV file 171 | vias = {} 172 | with open(filename, newline='', encoding='utf-8') as f: 173 | reader = csv.reader(f, delimiter=';', quotechar='"') 174 | for i, row in enumerate(reader): 175 | if i == 0 or not row[1]: 176 | continue 177 | vias[int(row[0])] = { 178 | 'text': row[1], 179 | 'stations': [[subentry.strip() for subentry in entry.split(",")] for entry in row[2:] if entry] 180 | } 181 | return vias 182 | 183 | def map_from_csv(filename): 184 | # Build the dict required for SplitFlapDisplay from a CSV file. 185 | # CSV format: column 0 = flap position, column 1 = destination as printed on the flap 186 | _map = {} 187 | with open(filename, newline='', encoding='utf-8') as f: 188 | reader = csv.reader(f, delimiter=';', quotechar='"') 189 | for i, row in enumerate(reader): 190 | if i == 0 or not row[1]: 191 | continue 192 | _map[int(row[0])] = row[1] 193 | return _map 194 | 195 | def alternatives_map_from_csv(filename): 196 | # Build the dict required for an alternative station name mapping from a CSV file. 197 | # CSV format: column 0 = flap position (irrelevant), column 1 = destination as printed on the flap, 198 | # column 2 = comma separated list of alternative station names that map to this flap 199 | _map = {} 200 | with open(filename, newline='', encoding='utf-8') as f: 201 | reader = csv.reader(f, delimiter=';', quotechar='"') 202 | for i, row in enumerate(reader): 203 | if i == 0 or not row[1]: 204 | continue 205 | for station_name in row[2].split(","): 206 | if station_name.strip() and station_name.strip() != row[1]: 207 | _map[station_name.strip()] = row[1] 208 | return _map 209 | -------------------------------------------------------------------------------- /pyfis/mobitec/matrix.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | 20 | from ..utils import debug_hex, high16, low16 21 | 22 | 23 | class MobitecMatrix: 24 | CMD_TEXT = 0xA2 25 | CMD_TEST = 0xA4 26 | CMD_EFFECT_TEXT = 0xA5 27 | CMD_VERSION_INFO = 0xA6 28 | 29 | ATTR_DISPLAY_WIDTH = 0xD0 30 | ATTR_DISPLAY_HEIGHT = 0xD1 31 | ATTR_POS_X = 0xD2 32 | ATTR_POS_Y = 0xD3 33 | ATTR_FONT = 0xD4 34 | ATTR_SCROLL_PARAMS = 0xD5 35 | 36 | EFFECT_NONE = 0x00 # No effect 37 | EFFECT_SCROLL_RTL_ONCE = 0x01 # Scroll from right to left until left area border is reached 38 | EFFECT_SCROLL_LTR_ONCE = 0x02 # Scroll from left to right until right area border is reached 39 | EFFECT_SCROLL_BTT_ONCE = 0x03 # Scroll from bottom to top until area upper border is reached 40 | EFFECT_SCROLL_TTB_ONCE = 0x04 # Scroll from top to bottom until area bottom border is reached 41 | EFFECT_SCROLL_RTL = 0x05 # Continuously scroll from right to left 42 | EFFECT_SCROLL_LTR = 0x06 # Continuously scroll from left to right 43 | EFFECT_SCROLL_BTT = 0x07 # Continuously scroll from bottom to top 44 | EFFECT_SCROLL_TTB = 0x08 # Continuously scroll from top to bottom 45 | EFFECT_BLINK = 0x09 # Blinking text 46 | EFFECT_SCROLL_RTL_CENTER = 0x0A # Scroll from right to left until text is horizontally centered 47 | EFFECT_SCROLL_LTR_CENTER = 0x0B # Scroll from left to right until text is horizontally centered 48 | EFFECT_SCROLL_BTT_CENTER = 0x0C # Scroll from bottom to top until text is vertically centered 49 | EFFECT_SCROLL_TTB_CENTER = 0x0D # Scroll from top to bottom until text is vertically centered 50 | EFFECT_SCROLL_L_R_CENTER = 0x10 # Scroll left to right and right to left simultaneously until horizontally centered 51 | EFFECT_EXPLODE = 0x11 # Exploding 52 | EFFECT_CENTER_SCROLL_RTL_CENTER = 0x12 # Centered text and then same text scrolled to centered from right 53 | 54 | def __init__(self, port, address, exclusive=True, debug=False, encoding_errors="strict"): 55 | self.port = port 56 | self.address = address 57 | self.debug = debug 58 | self.exclusive = exclusive 59 | self.encoding_errors = encoding_errors 60 | self.open() 61 | 62 | def open(self): 63 | if isinstance(self.port, serial.Serial): 64 | self.device = port 65 | self.port = self.device.port 66 | else: 67 | self.device = serial.Serial(self.port, 68 | baudrate=4800, bytesize=8, parity='N', stopbits=1, timeout=1.0, exclusive=self.exclusive) 69 | 70 | def close(self): 71 | self.device.close() 72 | 73 | def make_checksum(self, data): 74 | checksum_bytes = bytearray() 75 | checksum = sum(data) % 0x100 76 | if checksum == 0xFF: 77 | checksum_bytes.append(0xFE) 78 | checksum_bytes.append(0x01) 79 | elif checksum == 0xFE: 80 | checksum_bytes.append(0xFE) 81 | checksum_bytes.append(0x00) 82 | else: 83 | checksum_bytes.append(checksum) 84 | return checksum_bytes 85 | 86 | def make_command_frame(self, data): 87 | frame = bytearray() 88 | frame.append(0xFF) 89 | frame.append(self.address) 90 | frame += data 91 | checksum_data = bytearray([self.address]) + data 92 | frame += self.make_checksum(checksum_data) 93 | frame.append(0xFF) 94 | return frame 95 | 96 | def send_frame(self, frame): 97 | if self.debug: 98 | print("TX: " + debug_hex(frame, readable_ascii=False, readable_ctrl=False)) 99 | self.device.write(frame) 100 | 101 | def make_static_text_field(self, text, x = 0, y = 0, font = None): 102 | """ 103 | 0xDA can be used inside the text to turn on inversion, 0xDB turns it off. 104 | """ 105 | data = [] 106 | data += [self.ATTR_POS_X, x] 107 | data += [self.ATTR_POS_Y, y] 108 | if font is not None: 109 | data += [self.ATTR_FONT, font] 110 | data += text.encode("latin-1", errors=self.encoding_errors) 111 | return data 112 | 113 | def make_effect_text_field(self, text, text_area, effect, effect_cycles = 0, effect_time = 0, effect_speed = 0, x = 0, y = 0, font = None): 114 | """ 115 | text_area: 4-tuple or list in the form: (Upper left X, Upper left Y, Lower right X, Lower right Y) 116 | Note: Lower right coordinates are EXCLUDING the given point 117 | effect: See EFFECT_* definitions at the top of this class 118 | effect_cycles: EITHER number of scroll / blink cycles OR 0 to use total time instead of number of cycles 119 | effect_time: IF effect_cycles is 0: Total time (in seconds) to show the effect, 0 means indefinitely 120 | ELSE: Time (in seconds) between scroll text disappearing and reappearing 121 | No effect if effect_cycles is NOT 0 and effect is EFFECT_BLINK. 122 | effect_speed: IF effect is EFFECT_BLINK: Perios (in seconds) for blinking (i.e. 1x on + 1x off) 123 | ELSE: Scroll speed in pixels per second 124 | """ 125 | data = [] 126 | data += [self.ATTR_SCROLL_PARAMS] 127 | data += text_area 128 | data += [effect, effect_cycles, effect_time, effect_speed] 129 | data += [self.ATTR_POS_X, x] 130 | data += [self.ATTR_POS_Y, y] 131 | if font is not None: 132 | data += [self.ATTR_FONT, font] 133 | data += text.encode("latin-1", errors=self.encoding_errors) 134 | return data 135 | 136 | def send_texts(self, texts, display_width = None, display_height = None, use_effects = False): 137 | """ 138 | texts has to be a list or tuple of dicts as follows: 139 | { 140 | "text": "Hello world", 141 | "duration": 3, [optional, see below] 142 | "x": 10, 143 | "y": 0, 144 | "font": 0x65, 145 | 146 | [below entries are only required if use_effects is True - see make_effect_text_field] 147 | "area": (0, 0, 144, 16), 148 | "effect": EFFECT_SCROLL_RTL, 149 | "effect_cycles": 3, 150 | "effect_time": 1, 151 | "effect_speed": 50 152 | } 153 | duration is in seconds. 154 | NOTE: There seems to be something weird going on with the duration parameter. 155 | If it is added after each text, the duration is correctly set for each text, 156 | but there will be a blank text with the same duration as the first text inbetween. 157 | To circumvent this, omit the duration parameter in the last text. 158 | This will cause it to have the same duration as the first text, however. 159 | It seems this is a necessary tradeoff. 160 | This also allows you to specify multiple texts without a duration. If you do this, 161 | all the texts without a duration up to and including the next one WITH a duration 162 | will be shown together. 163 | """ 164 | data = [self.CMD_EFFECT_TEXT if use_effects else self.CMD_TEXT] 165 | if display_width is not None: 166 | data += [self.ATTR_DISPLAY_WIDTH, display_width] 167 | if display_height is not None: 168 | data += [self.ATTR_DISPLAY_HEIGHT, display_height] 169 | num_texts = len(texts) 170 | 171 | for text in texts: 172 | if use_effects: 173 | data += self.make_effect_text_field(text['text'], text['area'], text['effect'], text['effect_cycles'], text['effect_time'], text['effect_speed'], text['x'], text['y'], text['font']) 174 | else: 175 | data += self.make_static_text_field(text['text'], text['x'], text['y'], text['font']) 176 | if 'duration' in text: 177 | data += [0xB0, text['duration']] 178 | 179 | frame = self.make_command_frame(bytearray(data)) 180 | self.send_frame(frame) 181 | 182 | def send_static_text(self, text, x = 0, y = 0, font = None, display_width = None, display_height = None): 183 | texts = [{ 184 | 'text': text, 185 | 'x': x, 186 | 'y': y, 187 | 'font': font 188 | }] 189 | self.send_texts(texts, display_width, display_height, use_effects=False) 190 | 191 | def send_effect_text(self, text, text_area, effect, effect_cycles = 0, effect_time = 0, effect_speed = 0, x = 0, y = 0, font = None, display_width = None, display_height = None): 192 | texts = [{ 193 | 'text': text, 194 | 'x': x, 195 | 'y': y, 196 | 'font': font, 197 | 'area': text_area, 198 | 'effect': effect, 199 | 'effect_cycles': effect_cycles, 200 | 'effect_time': effect_time, 201 | 'effect_speed': effect_speed 202 | }] 203 | self.send_texts(texts, display_width, display_height, use_effects=True) 204 | 205 | def show_version_info(self): 206 | frame = self.make_command_frame(bytearray([self.CMD_VERSION_INFO])) 207 | self.send_frame(frame) 208 | 209 | def echo_byte(self, byte): 210 | # Causes the display to send back the specified byte for testing communication 211 | frame = self.make_command_frame(bytearray([self.CMD_TEST, 0x00, byte])) 212 | self.send_frame(frame) 213 | return self.device.read(1) -------------------------------------------------------------------------------- /pyfis/krone/k9000_fbk.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2020 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | 20 | from itertools import groupby 21 | 22 | from .k9000_rs485 import Krone9000RS485Controller 23 | from .exceptions import CommunicationError 24 | 25 | 26 | class Krone9000FBK(Krone9000RS485Controller): 27 | """ 28 | Controls a FBK (Fallblatt-Buskopfkarte) 29 | (split-flap group control) board. 30 | """ 31 | 32 | BOARD_ID = 0xB2 33 | BOARD_NAME = "FBK" 34 | 35 | FLAG_START_IMMEDIATELY = 0x80 36 | FLAG_FBM_COMMAND = 0x40 37 | FLAG_ENABLE_COMPRESSION = 0x20 38 | 39 | CMD_GET_FBK_STATUS = 0x01 40 | CMD_GET_LINE_INIT_DATA = 0x02 41 | CMD_GET_LINE_DATA = 0x03 42 | CMD_SET_BLINKER = 0x0B 43 | CMD_SET_FBM_VALUE_TABLE = 0x0E 44 | CMD_LOCK_FBK = 0xC6 45 | CMD_UNLOCK_FBK = 0xC7 46 | 47 | CMD_FBM_START = 0x00 | FLAG_START_IMMEDIATELY 48 | CMD_GET_FBM_STATUS = 0x03 | FLAG_FBM_COMMAND 49 | CMD_GET_FBM_CONTENT = 0x04 | FLAG_FBM_COMMAND 50 | CMD_CLEAR_FBM = 0x05 | FLAG_FBM_COMMAND 51 | CMD_LOCK_FBM = 0x06 | FLAG_FBM_COMMAND | FLAG_START_IMMEDIATELY 52 | CMD_UNLOCK_FBM = 0x07 | FLAG_FBM_COMMAND | FLAG_START_IMMEDIATELY 53 | CMD_SET_FBM_CODES_SEQ = 0x08 | FLAG_FBM_COMMAND 54 | CMD_SET_FBM_CODES_ADDR = 0x09 | FLAG_FBM_COMMAND 55 | 56 | def _get_fbm_status(self, stat): 57 | # Return human-readable error strings 58 | # based on FBM error bits 59 | if stat & 0x08: 60 | lut = { 61 | 0b1000: "comm_error", 62 | 0b1001: "start_missing", 63 | 0b1010: "unknown_char", 64 | 0b1011: "external_rotation", 65 | 0b1100: "rotation_timeout", 66 | 0b1101: "fbm_missing", 67 | 0b1111: "rotating" 68 | } 69 | return [lut.get(stat & 0x0f, "")] 70 | else: 71 | errors = [] 72 | if stat & 0x04: 73 | errors.append("no_ac") 74 | if stat & 0x02: 75 | errors.append("no_flap_imps") 76 | if stat & 0x01: 77 | errors.append("no_home_imp") 78 | return errors 79 | 80 | def _rle(self, data): 81 | # Simple run-length encoding 82 | rle_data = [] 83 | for k,i in groupby(data): 84 | run = list(i) 85 | if(len(run) > 2): 86 | while len(run) > 128: 87 | rle_data.extend([(len(run) - 1) | 0x80, k]) 88 | run = run[128:] 89 | rle_data.extend([(len(run) - 1) | 0x80, k]) 90 | else: 91 | rle_data.extend(run) 92 | return rle_data 93 | 94 | def get_status(self): 95 | # Get the status of the FBK board 96 | payload = self.send_command(self.CMD_GET_FBK_STATUS, response=True) 97 | stat = payload[1] 98 | status = { 99 | 'comm_err': bool(stat & 0x40), 100 | 'reset': bool(stat & 0x20), 101 | 'locked': bool(stat & 0x10), 102 | 'fbm_err': bool(stat & 0x08), 103 | 'blinker_err': bool(stat & 0x04), 104 | 'fbm_start_missing': bool(stat & 0x02), 105 | 'sw_ver': f"{payload[2]}.{payload[3]}" 106 | } 107 | return status 108 | 109 | def get_fbm_ids(self): 110 | # Get a list of all connected FBM IDs 111 | payload = self.send_command(self.CMD_GET_LINE_INIT_DATA, response=True) 112 | module_data = payload[1:] 113 | modules_present = [] 114 | addr = 0 115 | for byte in module_data: 116 | for bit in range(7): 117 | if byte & (1 << bit): 118 | modules_present.append(addr) 119 | addr += 1 120 | if addr > 255: 121 | break 122 | return modules_present 123 | 124 | def get_fbm_statuses(self): 125 | # True means okay, False means FBM error or not present 126 | # (see get_fbm_ids to get a list of present FBMs) 127 | payload = self.send_command(self.CMD_GET_LINE_DATA, response=True) 128 | module_data = payload[1:] 129 | module_statuses = {} 130 | addr = 0 131 | for byte in module_data: 132 | for bit in range(7): 133 | module_statuses[addr] = bool(byte & (1 << bit)) 134 | addr += 1 135 | if addr > 255: 136 | break 137 | return module_statuses 138 | 139 | def set_blinker(self, state): 140 | # Set the blinker associated with this FBK on or off 141 | return self.send_command(self.CMD_SET_BLINKER, 0x31 if state else 0x30) 142 | 143 | def set_fbm_value_table(self, addr, table): 144 | # Set the internal mapping of character code to flap position 145 | # on the selected FBM 146 | # table is a list of characters to be mapped to flaps, 147 | # starting at flap 0 148 | parameters = [addr] + table 149 | self.send_command(self.CMD_SET_FBM_VALUE_TABLE, parameters) 150 | # Special case here: The FBK sends the number of flap codes transferred 151 | # as a single byte after it has finished sending the data to the FBM. 152 | # As this is the only case in which this sort of response occurs, 153 | # we are just handling it manually here. 154 | num_xferred = b"" 155 | tries = 0 156 | # Try for roughly five seconds (exact value depends on the port timeout) 157 | while num_xferred == b"": 158 | if tries >= 5.0 / self.port.timeout: 159 | raise CommunicationError("Timeout while waiting for result of FBM value table transfer") 160 | num_xferred = self.port.read(1) 161 | tries += 1 162 | self.debug_print(bytearray(num_xferred), receive=True) 163 | # Return the number of transferred flap codes 164 | return ord(num_xferred) 165 | 166 | def lock(self): 167 | # Lock the entire FBK 168 | return self.send_command(self.CMD_LOCK_FBK) 169 | 170 | def unlock(self): 171 | # Unlock the entire FBK 172 | return self.send_command(self.CMD_UNLOCK_FBK) 173 | 174 | def start_fbm(self): 175 | return self.send_command(self.CMD_FBM_START) 176 | 177 | def get_detailed_fbm_statuses(self, addrs): 178 | # Get detailed FBM status information for up to 10 FBMs. 179 | # addrs is a list of FBM IDs to be queried 180 | payload = self.send_command(self.CMD_GET_FBM_STATUS, addrs, response=True) 181 | module_statuses = {} 182 | data = payload[1:] 183 | for i in range(0, len(data), 2): 184 | addr = data[i] 185 | stat = data[i + 1] 186 | module_statuses[addr] = { 187 | 'home_pos': bool(stat & 0x40), 188 | 'reset': bool(stat & 0x20), 189 | 'locked': bool(stat & 0x10), 190 | 'status': self._get_fbm_status(stat & 0x0f) 191 | } 192 | return module_statuses 193 | 194 | def get_all_detailed_fbm_statuses(self): 195 | # Automatically read the list of connected FBM IDs 196 | # and query all of them for detailed status information 197 | module_statuses = {} 198 | for addrs in self._chunks(self.get_fbm_ids(), 10): 199 | module_statuses.update(self.get_detailed_fbm_statuses(addrs)) 200 | return module_statuses 201 | 202 | def get_fbm_contents(self, addrs): 203 | # Get the currently displayed character for up to 10 FBMs. 204 | # addrs is a list of FBM IDs to be queried 205 | payload = self.send_command(self.CMD_GET_FBM_CONTENT, addrs, response=True) 206 | module_contents = {} 207 | data = payload[1:] 208 | for i in range(0, len(data), 2): 209 | addr = data[i] 210 | char = data[i + 1] 211 | module_contents[addr] = chr(char) 212 | return module_contents 213 | 214 | def get_all_fbm_contents(self): 215 | # Automatically read the list of connected FBM IDs 216 | # and query all of them for their content 217 | module_contents = {} 218 | for addrs in self._chunks(self.get_fbm_ids(), 10): 219 | module_contents.update(self.get_fbm_contents(addrs)) 220 | return module_contents 221 | 222 | def clear_fbm(self, addrs = None, immediate = True): 223 | # Clear all or the selected FBMs 224 | cmd = self.CMD_CLEAR_FBM 225 | if immediate: 226 | cmd |= self.FLAG_START_IMMEDIATELY 227 | return self.send_command(cmd, addrs) 228 | 229 | def lock_fbm(self, addrs = None): 230 | # Lock all or the selected FBMs 231 | return self.send_command(self.CMD_LOCK_FBM, addrs) 232 | 233 | def unlock_fbm(self, addrs = None): 234 | # Unlock all or the selected FBMs 235 | return self.send_command(self.CMD_UNLOCK_FBM, addrs) 236 | 237 | def set_fbm_codes_seq(self, codes, immediate = False, compress = False): 238 | # Set FBM character codes to be displayed 239 | # sequentially (without explicit addressing) 240 | cmd = self.CMD_SET_FBM_CODES_SEQ 241 | if immediate: 242 | cmd |= self.FLAG_START_IMMEDIATELY 243 | if compress: 244 | cmd |= self.FLAG_ENABLE_COMPRESSION 245 | codes = self._rle(codes) 246 | return self.send_command(cmd, codes) 247 | 248 | def set_fbm_codes_addr(self, codes, immediate = False): 249 | # Set FBM character codes to be displayed 250 | # with explicit addressing 251 | cmd = self.CMD_SET_FBM_CODES_ADDR 252 | if immediate: 253 | cmd |= self.FLAG_START_IMMEDIATELY 254 | return self.send_command(cmd, codes) 255 | 256 | def d_set_module_data(self, module_data): 257 | # Compatibility function for SplitFlapDisplay class 258 | for chunk in self._chunks(module_data, 50): 259 | self.set_fbm_codes_addr([i for s in chunk for i in s]) 260 | 261 | def d_update(self): 262 | # Compatibility function for SplitFlapDisplay class 263 | self.start_fbm() 264 | -------------------------------------------------------------------------------- /pyfis/aegmis/mis1_matrix.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from PIL import Image 22 | 23 | from .mis1_protocol import MIS1Protocol 24 | 25 | from ..utils import debug_hex, high16, low16 26 | from ..utils.base_serial import BaseSerialPort 27 | 28 | 29 | class MIS1MatrixDisplay(MIS1Protocol): 30 | def set_config(self, lcd_module, num_lcds, x, y, id, board_timeout, fr_freq, fps, is_master, protocol_timeout, response_delay): 31 | # board_timeout: in seconds 32 | # protocol_timeout and response_delay: in milliseconds 33 | data = [ 34 | high16(lcd_module), 35 | low16(lcd_module), 36 | num_lcds, 37 | high16(x), 38 | low16(x), 39 | high16(y), 40 | low16(y), 41 | high16(id), 42 | low16(id), 43 | high16(board_timeout * 2), 44 | low16(board_timeout * 2), 45 | fr_freq, 46 | fps, 47 | 1 if is_master else 0, 48 | high16(protocol_timeout), 49 | low16(protocol_timeout), 50 | high16(response_delay), 51 | low16(response_delay), 52 | 0x00, 53 | 0x00 54 | ] 55 | return self.send_command(0x02, 0x00, data) 56 | 57 | def set_input_config(self, id, mask_byte, row_codes): 58 | data = [id, mask_byte] 59 | data.extend(row_codes) 60 | return self.send_command(0x04, 0x00, data) 61 | 62 | def text(self, board, page, font, x, y, width, text): 63 | data = [ 64 | board, 65 | page, 66 | font, 67 | high16(x), 68 | low16(x), 69 | high16(y), 70 | low16(y), 71 | high16(width), 72 | low16(width) 73 | ] 74 | data.extend(bytearray(text, 'ascii')) 75 | return self.send_command(0x19, 0x00, data) 76 | 77 | def check_text_width(self, font, text): 78 | data = [0x00, font] 79 | data.extend(bytearray(text, 'ascii')) 80 | return self.send_command(0x1A, 0x00, data) 81 | 82 | def set_font_spacing(self, font_spacings): 83 | data = font_spacings 84 | return self.send_command(0x1F, 0x00, data) 85 | 86 | def set_pages(self, pages): 87 | flat_pages = [item for sublist in pages for item in sublist] 88 | data = [0x00, 0x00] 89 | data.extend(flat_pages) 90 | return self.send_command(0x24, 0x00, data) 91 | 92 | def set_page(self, page): 93 | return self.set_pages([(page, 255)]) 94 | 95 | def copy_page(self, source, destination): 96 | data = [0x00, source, destination] 97 | return self.send_command(0x26, 0x00, data) 98 | 99 | def echo(self, number): 100 | data = [high16(number), low16(number)] 101 | return self.send_command(0x2A, 0x00, data) 102 | 103 | def set_pixel(self, page, x, y, state): 104 | data = [ 105 | 0x00, 106 | page, 107 | high16(x), 108 | low16(x), 109 | high16(y), 110 | low16(y), 111 | 1 if state else 0 112 | ] 113 | return self.send_command(0x2B, 0x00, data) 114 | 115 | def fill_area(self, page, x, y, width, height, state): 116 | data = [ 117 | 0x00, 118 | page, 119 | high16(x), 120 | low16(x), 121 | high16(y), 122 | low16(y), 123 | high16(width), 124 | low16(width), 125 | high16(height), 126 | low16(height), 127 | 1 if state else 0 128 | ] 129 | return self.send_command(0x2D, 0x00, data) 130 | 131 | def delete_scroll_sector(self, sector, page): 132 | data = [0x00, sector, page] 133 | return self.send_command(0x2E, 0x00, data) 134 | 135 | def delete_page(self, page): 136 | data = [0x00, page] 137 | return self.send_command(0x2F, 0x00, data) 138 | 139 | def reset(self): 140 | return self.send_command(0x31, 0x00, []) 141 | 142 | def set_test_mode(self, state): 143 | return self.send_command(0x32, 0x00, [1 if state else 0]) 144 | 145 | def sync(self): 146 | return self.send_command(0x34, 0x00, []) 147 | 148 | def get_defective_rows(self): 149 | return self.send_command(0x35, 0x00, []) 150 | 151 | def get_config(self): 152 | return self.send_command(0x38, 0x00, []) 153 | 154 | def get_font_info(self): 155 | return self.send_command(0x39, 0x00, []) 156 | 157 | def partial_page_update(self, page, x, y, height, width): 158 | data = [ 159 | 0x00, 160 | 0x00, 161 | page, 162 | high16(x), 163 | low16(x), 164 | high16(y), 165 | low16(y), 166 | high16(height), 167 | low16(height), 168 | high16(width), 169 | low16(width) 170 | ] 171 | return self.send_command(0x3A, 0x00, data) 172 | 173 | def get_id(self): 174 | return self.send_command(0x3E, 0x00, []) 175 | 176 | def set_outputs(self, states): 177 | # states: array of 8 bools representing outputs 0 through 7 178 | state_byte = 0x00 179 | for i in range(max(8, len(states))): 180 | if states[i]: 181 | state_byte |= (1 << i) 182 | return self.send_command(0x41, 0x00, [0x00, 0x00, state_byte]) 183 | 184 | def read_inputs(self, id): 185 | data = [id] 186 | return self.send_command(0x42, 0x00, data) 187 | 188 | def set_vlcd(self, voltage): 189 | ones = int(voltage) 190 | decimals = round((voltage % 1) * 100) 191 | data = [ones, decimals] 192 | return self.send_command(0x61, 0x00, data) 193 | 194 | def get_firmware_revision(self): 195 | return self.send_command(0x65, 0x00, []) 196 | 197 | def get_temperature(self): 198 | return self.send_command(0x6A, 0x00, []) 199 | 200 | def get_vlcd(self): 201 | return self.send_command(0x6E, 0x00, []) 202 | 203 | def image_data(self, page, x, y, width, pixels): 204 | data = [ 205 | 0x00, 206 | 0x00, 207 | page, 208 | high16(x), 209 | low16(x), 210 | high16(y), 211 | low16(y), 212 | high16(width), 213 | low16(width) 214 | ] 215 | data.extend(pixels) 216 | return self.send_command(0x73, 0x00, data) 217 | 218 | def scroll_image_data(self, sector, page, y, pixels): 219 | data = [ 220 | 0x00, 221 | 0x00, 222 | sector, 223 | page, 224 | high16(y), 225 | low16(y) 226 | ] 227 | data.extend(pixels) 228 | return self.send_command(0x74, 0x00, data) 229 | 230 | def create_scroll_area(self, sector, page, x, y, width, height, data_width): 231 | data = [ 232 | 0x00, 233 | 0x00, 234 | sector, 235 | page, 236 | high16(x), 237 | low16(x), 238 | high16(y), 239 | low16(y), 240 | high16(width), 241 | low16(width), 242 | high16(height), 243 | low16(height), 244 | high16(data_width), 245 | low16(data_width) 246 | ] 247 | return self.send_command(0x75, 0x00, data) 248 | 249 | def set_flash_cycle(self, cycle_time): 250 | data = [cycle_time] 251 | return self.send_command(0x7C, 0x00, data) 252 | 253 | def become_slave(self): 254 | return self.send_command(0x7D, 0x00, []) 255 | 256 | def become_master(self): 257 | return self.send_command(0x7E, 0x00, []) 258 | 259 | def dummy(self): 260 | return self.send_command(0x7F, 0x00, []) 261 | 262 | def image(self, page, x, y, image): 263 | if not isinstance(image, Image.Image): 264 | image = Image.open(image) 265 | image = image.convert('L') 266 | pixels = image.load() 267 | width, height = image.size 268 | for y_offset in range(height): 269 | _y = y + y_offset 270 | pixel_data = [] 271 | byte = 0x00 272 | x_bit = 7 273 | for x_offset in range(width): 274 | if pixels[x_offset, y_offset] > 127: 275 | byte |= (1 << x_bit) 276 | if x_bit == 0 or x_offset == width - 1: 277 | x_bit = 7 278 | pixel_data.append(byte) 279 | byte = 0x00 280 | else: 281 | x_bit -= 1 282 | self.image_data(page, x, _y, width, pixel_data) 283 | 284 | def scroll_image(self, sector, page, x, y, scroll_width, image, extra_whitespace=0): 285 | if not isinstance(image, Image.Image): 286 | image = Image.open(image) 287 | image = image.convert('L') 288 | pixels = image.load() 289 | width, height = image.size 290 | 291 | self.create_scroll_area(sector, page, x, y, scroll_width, height, width + extra_whitespace) 292 | for i in range(10): 293 | response = self.send_tx_request() 294 | self.check_error(response) 295 | 296 | for y_offset in range(height): 297 | _y = y + y_offset 298 | pixel_data = [] 299 | byte = 0x00 300 | x_bit = 7 301 | for x_offset in range(width): 302 | if pixels[x_offset, y_offset] > 127: 303 | byte |= (1 << x_bit) 304 | if x_bit == 0 or x_offset == width - 1: 305 | x_bit = 7 306 | pixel_data.append(byte) 307 | byte = 0x00 308 | else: 309 | x_bit -= 1 310 | self.scroll_image_data(sector, page, y_offset, pixel_data) 311 | 312 | def animation(self, sector_start, page, x, y, image): 313 | # Splits the animation into 1-pixel wide scroll sectors 314 | # that scroll through a spatial representation of the animation 315 | if not isinstance(image, Image.Image): 316 | image = Image.open(image) 317 | width, height = image.size 318 | 319 | orig_frames = [] 320 | try: 321 | while True: 322 | orig_frames.append(image.convert('L').load()) 323 | # Next frame 324 | image.seek(image.tell() + 1) 325 | except EOFError: 326 | pass 327 | 328 | num_frames = len(orig_frames) 329 | scroll_frames = [] 330 | for i in range(width): 331 | scroll_frame = Image.new('L', (num_frames, height), 'black') 332 | scroll_pixels = scroll_frame.load() 333 | for _x, frame in enumerate(orig_frames): 334 | for _y in range(height): 335 | scroll_pixels[_x, _y] = frame[i, _y] 336 | scroll_frames.append(scroll_frame) 337 | 338 | for _x, scroll_frame in enumerate(scroll_frames): 339 | self.scroll_image(sector_start + _x, page, x + _x, y, 1, scroll_frame) 340 | -------------------------------------------------------------------------------- /pyfis/splitflap_display/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2019 - 2025 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | class BaseField: 19 | # This is needed so Form.get_fields() will know what to include 20 | _is_field = True 21 | 22 | def __init__(self, start_address = None, length = 1, descending = False, 23 | text_align = 'left', 24 | address_mapping = None, display_mapping = None, 25 | x = 0, y = 0, module_width = 1, module_height = 1, home_pos = 0): 26 | """ 27 | start_address: the address of the first module in this field 28 | length: How many modules make up this field 29 | descending: If using start_address, select descending addresses 30 | text_align: Alignment of the text (left, center, right) 31 | address_mapping: If modules have non-sequential addresses, the list of 32 | addresses corresponding to the digits in this field 33 | display_mapping: Optional mapping of split-flap card numbers to 34 | displayed text or symbols for all modules in this field 35 | x: Horizontal offset of the field in multiples of the smallest unit size 36 | y: Vertical offset of the field in multiples of the smallest unit size 37 | module_width: Width of the modules making up the field in multiples 38 | of the smallest unit size 39 | module_height: Height of the modules making up the field in multiples 40 | of the smallest unit size 41 | home_pos: ID of the home position. Should be 0, but is different in some cases 42 | """ 43 | if start_address is None and address_mapping is None: 44 | raise AttributeError("Either start_address or address_mapping must be present") 45 | if type(length) is not int or length <= 0: 46 | raise ValueError("length must be a positive integer") 47 | if start_address is not None: 48 | if start_address not in range(256): 49 | raise ValueError("start_address must be an int between 0 and 255") 50 | if descending: 51 | if start_address - length < 0: 52 | raise ValueError("Field is too long for given start address") 53 | else: 54 | if start_address + length > 256: 55 | raise ValueError("Field is too long for given start address") 56 | if address_mapping is not None: 57 | if len(address_mapping) != length: 58 | raise ValueError("Length of address_mapping doesn't match field length") 59 | self.start_address = start_address 60 | self.length = length 61 | self.descending = descending 62 | if text_align not in ('left', 'center', 'right'): 63 | raise ValueError("text_align must be left, center or right") 64 | self.text_align = text_align 65 | if address_mapping is not None: 66 | self.address_mapping = address_mapping 67 | else: 68 | if self.descending: 69 | self.address_mapping = list(range(start_address, start_address-length, -1)) 70 | else: 71 | self.address_mapping = list(range(start_address, start_address+length)) 72 | self.display_mapping = display_mapping 73 | if display_mapping is not None: 74 | self.inverse_display_mapping = {v: k for k, v in display_mapping.items()} 75 | else: 76 | self.inverse_display_mapping = None 77 | self.x = x 78 | self.y = y 79 | self.module_width = module_width 80 | self.module_height = module_height 81 | self.home_pos = home_pos 82 | self.value = " " * self.length 83 | self.mirrors = [] 84 | 85 | def set(self, value): 86 | self.value = value 87 | 88 | def get(self): 89 | return self.value 90 | 91 | def clear(self): 92 | self.value = " " * self.length 93 | 94 | def get_single_module_data(self, pos): 95 | raise NotImplementedError 96 | 97 | def get_module_data(self): 98 | module_data = [] 99 | for i in range(self.length): 100 | module_data.append(self.get_single_module_data(i)) 101 | return module_data 102 | 103 | def get_ascii_render_parameters(self): 104 | """ 105 | Calculate the parameters needed to render the field as ASCII graphics 106 | """ 107 | parameters = { 108 | 'x': self.x * 2, 109 | 'y': self.y * 2, 110 | 'width': self.length * 2 * self.module_width + 1, 111 | 'height': 2 * self.module_height + 1, 112 | 'spacing': 2 * self.module_width, 113 | 'text_spacing': 2 * self.module_width, 114 | 'x_offset': self.module_width, 115 | 'y_offset': self.module_height, 116 | 'text_max_length': 2 * self.module_width - 1, 117 | } 118 | return parameters 119 | 120 | def add_mirror(self, field): 121 | """ 122 | Add a field to the list of mirror fields 123 | """ 124 | if field not in self.mirrors: 125 | self.mirrors.append(field) 126 | 127 | def remove_mirror(self, field): 128 | """ 129 | Remove a field from the list of mirror fields 130 | """ 131 | while field in self.mirrors: 132 | self.mirrors.remove(field) 133 | 134 | def update_mirrors(self): 135 | """ 136 | Update all mirror fields of this field 137 | """ 138 | for field in self.mirrors: 139 | if type(self.value) is list: 140 | field.value = self.value.copy() 141 | else: 142 | field.value = self.value 143 | 144 | 145 | class MirrorField(BaseField): 146 | """ 147 | This special field is set up so it mirrors an existing field. 148 | """ 149 | def __init__(self, source_field, *args, **kwargs): 150 | if not isinstance(source_field, BaseField): 151 | raise ValueError("source_field must be an instance of a Field subclass") 152 | if 'start_address' not in kwargs: 153 | kwargs['start_address'] = source_field.start_address 154 | if 'length' not in kwargs: 155 | kwargs['length'] = source_field.length 156 | if 'descending' not in kwargs: 157 | kwargs['descending'] = source_field.descending 158 | if 'text_align' not in kwargs: 159 | kwargs['text_align'] = source_field.text_align 160 | if 'display_mapping' not in kwargs: 161 | kwargs['display_mapping'] = source_field.display_mapping 162 | if 'x' not in kwargs: 163 | kwargs['x'] = source_field.x 164 | if 'y' not in kwargs: 165 | kwargs['y'] = source_field.y 166 | if 'module_width' not in kwargs: 167 | kwargs['module_width'] = source_field.module_width 168 | if 'module_height' not in kwargs: 169 | kwargs['module_height'] = source_field.module_height 170 | if 'home_pos' not in kwargs: 171 | kwargs['home_pos'] = source_field.home_pos 172 | super().__init__(*args, **kwargs) 173 | self.source_field = source_field 174 | source_field.add_mirror(self) 175 | 176 | def set(self, value): 177 | pass 178 | 179 | def get(self): 180 | return self.source_field.get() 181 | 182 | def clear(self): 183 | pass 184 | 185 | def get_single_module_data(self, pos): 186 | addr, code, dummy_x, dummy_y = self.source_field.get_single_module_data(pos) 187 | x = self.x + pos * self.module_width 188 | return self.address_mapping[pos], code, x, self.y 189 | 190 | def get_module_data(self): 191 | module_data = [] 192 | for i in range(self.length): 193 | module_data.append(self.get_single_module_data(i)) 194 | return module_data 195 | 196 | def get_ascii_render_parameters(self): 197 | """ 198 | Get the base parameters from the source field, 199 | but change the x and y values to allow for different placement 200 | """ 201 | parameters = self.source_field.get_ascii_render_parameters() 202 | parameters.update({ 203 | 'x': self.x * 2, 204 | 'y': self.y * 2, 205 | }) 206 | return parameters 207 | 208 | 209 | class TextField(BaseField): 210 | def __init__(self, *args, value = "", upper_only = True, encoding_errors = "strict", **kwargs): 211 | super().__init__(*args, **kwargs) 212 | self.upper_only = upper_only 213 | self.encoding_errors = encoding_errors 214 | self.set(value) 215 | 216 | def set(self, value): 217 | if type(value) is not str: 218 | raise ValueError("value must be str") 219 | if self.upper_only: 220 | value = value.upper() 221 | self.value = value[:self.length] 222 | if self.text_align == 'left': 223 | self.value = self.value.ljust(self.length) 224 | elif self.text_align == 'center': 225 | self.value = self.value.center(self.length) 226 | elif self.text_align == 'right': 227 | self.value = self.value.rjust(self.length) 228 | self.update_mirrors() 229 | 230 | def get_single_module_data(self, pos): 231 | """ 232 | Returns the split-flap module address and code for the given position 233 | in the field with the current field value 234 | """ 235 | if pos >= self.length: 236 | raise ValueError("pos must be inside field boundaries") 237 | addr = self.address_mapping[pos] 238 | char = self.value[pos] 239 | if self.display_mapping is not None: 240 | code = self.inverse_display_mapping.get(char, self.home_pos) 241 | else: 242 | code = ord(char.encode('iso-8859-1', errors=self.encoding_errors)) 243 | x = self.x + pos * self.module_width 244 | return addr, code, x, self.y 245 | 246 | 247 | class CustomMapField(BaseField): 248 | def __init__(self, display_mapping, *args, value = [], **kwargs): 249 | super().__init__(*args, display_mapping=display_mapping, **kwargs) 250 | self.value = [""] * self.length 251 | self.set(value) 252 | 253 | def set(self, value): 254 | if type(value) not in (list, tuple): 255 | value = [value] * self.length 256 | value = value[:self.length] + [""] * (self.length - len(value)) 257 | for i, module_value in enumerate(value): 258 | if module_value not in self.inverse_display_mapping: 259 | self.value[i] = "" 260 | else: 261 | self.value[i] = module_value 262 | self.update_mirrors() 263 | 264 | def clear(self): 265 | self.value = [""] * self.length 266 | self.update_mirrors() 267 | 268 | def get_single_module_data(self, pos): 269 | """ 270 | Returns the split-flap module address and code for the given position 271 | in the field with the current field value 272 | """ 273 | if pos >= self.length: 274 | raise ValueError("pos must be inside field boundaries") 275 | addr = self.address_mapping[pos] 276 | display_value = self.value[pos] 277 | code = self.inverse_display_mapping.get(display_value, self.home_pos) 278 | x = self.x + pos * self.module_width 279 | return addr, code, x, self.y 280 | 281 | def get_ascii_render_parameters(self): 282 | parameters = super().get_ascii_render_parameters() 283 | parameters['x_offset'] = 1 284 | parameters['text_spacing'] = 1 285 | return parameters -------------------------------------------------------------------------------- /pyfis/lawo/lawo_font.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2016 - 2021 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import argparse 19 | import crccheck 20 | import math 21 | 22 | from PIL import Image, ImageDraw, ImageFont 23 | from pprint import pprint 24 | 25 | 26 | class LawoFont: 27 | """ 28 | LAWO font files, typically named FONTNAME.FXX, where XX is the glyph height 29 | """ 30 | 31 | def __init__(self): 32 | self.name = None 33 | self.change_signature = None 34 | self.file_size = None 35 | self.file_name = None 36 | self.glyph_h = None 37 | self.baseline = None 38 | self.min_char = None 39 | self.max_char = None 40 | self.char_spacing = None 41 | self.preview_text = None 42 | self.num_blocks = None 43 | self.glyph_metadata = None 44 | self.glyph_data = None 45 | self.num_glyphs = None 46 | self.widest_glyph = None 47 | self.narrowest_glyph = None 48 | self.charset = None 49 | 50 | @staticmethod 51 | def _read_c_str(data): 52 | result = "" 53 | for byte in data: 54 | if byte == 0x00: 55 | return result 56 | else: 57 | result += bytes([byte]).decode('cp1252') 58 | return result 59 | 60 | @staticmethod 61 | def _read_until_double_null(data): 62 | result = "" 63 | for i, byte in enumerate(data): 64 | if byte == 0x00 and i < len(data) - 1 and data[i+1] == 0x00: 65 | return result 66 | else: 67 | result += bytes([byte]).decode('cp1252') 68 | return result 69 | 70 | @staticmethod 71 | def _chunks(lst, n): 72 | for i in range(0, len(lst), n): 73 | yield lst[i:i + n] 74 | 75 | def read_file(self, file): 76 | with open(file, 'rb') as f: 77 | data = f.read() 78 | 79 | self.name = self._read_c_str(data[6:14]).strip() 80 | self.change_signature = data[16] << 8 | data[7] # Changes with every file change 81 | self.file_size = data[20] << 8 | data[21] 82 | self.file_name = self._read_c_str(data[32:45]) 83 | self.glyph_h = data[45] 84 | self.baseline = data[46] 85 | self.min_char = data[47] 86 | self.max_char = data[48] 87 | self.char_spacing = data[52] 88 | self.preview_text = self._read_c_str(data[56:60]) 89 | self.num_blocks = data[60] << 8 | data[61] # A block is a column of bytes with a length equal to the glyph height 90 | self.glyph_metadata = dict(zip(range(self.min_char, self.max_char+1), [None]*(self.max_char-self.min_char+1))) 91 | self.glyph_data = dict(zip(range(self.min_char, self.max_char+1), [None]*(self.max_char-self.min_char+1))) 92 | 93 | extra_data_start = 70 + 3 * (self.max_char - self.min_char + 1) 94 | if data[extra_data_start] == 0x00: 95 | # There is no extra data block, just skip the two 0x00 bytes 96 | glyph_data_block_start = extra_data_start + 2 97 | else: 98 | # There is an extra data block 99 | # Read it and skip the null terminator and the two 0x00 bytes 100 | self.extra_data = self._read_until_double_null(data[extra_data_start:]) 101 | glyph_data_block_start = extra_data_start + len(self.extra_data) + 3 102 | 103 | self.num_glyphs = 0 104 | self.widest_glyph = 0 105 | self.narrowest_glyph = 255 106 | self.charset = "" 107 | for c in range(self.min_char, self.max_char+1): 108 | i = 70 + 3 * (c - self.min_char) 109 | self.glyph_metadata[c] = { 110 | 'glyph_w': data[i], 111 | 'offset': data[i+1] << 8 | data[i+2] # Offset from start of glyph data block in bits 112 | } 113 | if data[i] > 0: 114 | self.num_glyphs += 1 115 | self.charset += bytes([c]).decode('cp1252') 116 | if data[i] > self.widest_glyph: 117 | self.widest_glyph = data[i] 118 | if data[i] < self.narrowest_glyph: 119 | self.narrowest_glyph = data[i] 120 | 121 | for c in range(self.min_char, self.max_char+1): 122 | width = self.glyph_metadata[c]['glyph_w'] 123 | glyph_start = glyph_data_block_start + self.glyph_metadata[c]['offset'] // 8 124 | i = glyph_start 125 | glyph_data = [] 126 | for y in range(self.glyph_h): 127 | for x_byte in range(math.ceil(width / 8)): 128 | glyph_data.append(data[i + x_byte]) 129 | i += self.num_blocks 130 | self.glyph_data[c] = glyph_data 131 | 132 | def print_info(self): 133 | print("\n".join([f"Name: {self.name}", 134 | f"File Name: {self.file_name}", 135 | f"File Size: {self.file_size} Bytes", 136 | f"Change Sig: {self.change_signature}", 137 | f"Glyph Height: {self.glyph_h} px", 138 | f"Glyph Baseline: {self.baseline} px", 139 | f"Glyph Spacing: {self.char_spacing} px", 140 | f"Widest Glyph: {self.widest_glyph} px", 141 | f"Narrowest Glyph: {self.narrowest_glyph} px", 142 | f"Lowest Character: {self.min_char} ({bytes([self.min_char]).decode('cp1252')})", 143 | f"Highest Character: {self.max_char} ({bytes([self.max_char]).decode('cp1252')})", 144 | f"Preview Text: {self.preview_text}", 145 | f"# Glyphs: {self.num_glyphs}", 146 | f"# Data Blocks: {self.num_blocks}", 147 | f"Character Set: {self.charset}"])) 148 | 149 | def get_glyph_width(self, code): 150 | if code not in self.glyph_metadata: 151 | return 0 152 | return self.glyph_metadata[code]['glyph_w'] 153 | 154 | def render_glyph(self, code): 155 | if code not in self.glyph_metadata: 156 | return None 157 | glyph_metadata = self.glyph_metadata[code] 158 | glyph_data = self.glyph_data[code] 159 | width = glyph_metadata['glyph_w'] 160 | height = self.glyph_h 161 | if width == 0: 162 | return None 163 | img = Image.new('L', (width, height), 0) 164 | px = img.load() 165 | i = 0 166 | for y in range(height): 167 | for x_byte in range(math.ceil(width / 8)): 168 | byte = glyph_data[i] 169 | for x_bit in range(8): 170 | x = x_byte * 8 + 7 - x_bit 171 | if x >= width: 172 | continue 173 | px[x,y] = 255 if (byte & (1 << x_bit)) else 0 174 | i += 1 175 | return img 176 | 177 | def render_glyph_table(self, x_spacing=5, x_offset=25, y_spacing=5, row_min_height=12, num_cols=16): 178 | num_chars = self.max_char - self.min_char + 1 179 | 180 | # Calculate the displayed table range based on the font's character range 181 | # This new range is sure to leave no half filled rows 182 | table_range_min = self.min_char - (self.min_char % num_cols) 183 | table_range_max = self.max_char - (self.max_char % num_cols) + num_cols - 1 184 | 185 | row_list = range(table_range_min, table_range_max + 1, num_cols) 186 | num_rows = len(row_list) 187 | row_height = max(row_min_height, self.glyph_h) + y_spacing 188 | 189 | # Calculate widths of each column based on maximum glyph width in that column 190 | # taking into account spacings 191 | col_widths = {} 192 | for row, char_code_base in enumerate(row_list): 193 | for col in range(num_cols): 194 | char_code = char_code_base + col 195 | if char_code < 0 or char_code > 255: 196 | continue 197 | if col not in col_widths or self.glyph_metadata.get(char_code, {'glyph_w': 0})['glyph_w'] + x_spacing + x_offset > col_widths[col]: 198 | col_widths[col] = self.glyph_metadata[char_code]['glyph_w'] + x_spacing + x_offset 199 | 200 | # Calculate X start positions of each column 201 | x_tmp = 0 202 | col_offsets = {} 203 | for col, width in sorted(col_widths.items(), key=lambda i: i[0]): 204 | col_offsets[col] = x_tmp 205 | x_tmp += width 206 | 207 | # Calculate total glyph table dimensions 208 | width = sum(col_widths.values()) 209 | height = num_rows * row_height 210 | 211 | # Create image 212 | table = Image.new('L', (width, height), 0) 213 | draw = ImageDraw.Draw(table) 214 | font = ImageFont.truetype("arial.ttf", row_min_height) 215 | 216 | # Render grid, skipping the first row / column 217 | for row, char_code_base in list(enumerate(row_list))[1:]: 218 | y = row * row_height 219 | draw.line((0, y, width - 1, y), 255, 1) 220 | 221 | for col in range(1, num_cols): 222 | x = col_offsets[col] 223 | draw.line((x, 0, x, height - 1), 255, 1) 224 | 225 | # Render glyphs 226 | for row, char_code_base in enumerate(row_list): 227 | for col in range(num_cols): 228 | char_code = char_code_base + col 229 | x_base = col_offsets[col] 230 | y_base = row * row_height 231 | draw.text((x_base + 3, y_base), str(char_code), 255, font) 232 | glyph = self.render_glyph(char_code) 233 | if glyph: 234 | table.paste(glyph, (x_base + x_offset + math.ceil(x_spacing / 2), y_base + math.ceil(y_spacing / 2))) 235 | 236 | return table 237 | 238 | def render_text(self, text): 239 | chars = bytes(text, 'cp1252', 'ignore') 240 | width = 0 241 | for code in chars: 242 | width += self.get_glyph_width(code) + self.char_spacing 243 | width -= self.char_spacing 244 | 245 | img = Image.new('L', (width, self.glyph_h), 0) 246 | x = 0 247 | for code in chars: 248 | glyph = self.render_glyph(code) 249 | img.paste(glyph, (x, 0)) 250 | x += self.get_glyph_width(code) + self.char_spacing 251 | return img 252 | 253 | 254 | if __name__ == "__main__": 255 | parser = argparse.ArgumentParser("Tool for using LAWO font files") 256 | parser.add_argument("-f", "--file", type=str, required=True, help="Font file") 257 | parser.add_argument("-i", "--info", action='store_true', help="Show font info") 258 | parser.add_argument("-sg", "--show-glyph", type=int, required=False, help="Show glyph with given code") 259 | parser.add_argument("-gt", "--glyph-table", action='store_true', help="Show glyph table") 260 | parser.add_argument("-rt", "--render-text", type=str, required=False, help="Render given text") 261 | parser.add_argument("-o", "--output", type=str, required=False, help="Save output images to file instead of showing") 262 | args = parser.parse_args() 263 | 264 | font = LawoFont() 265 | font.read_file(args.file) 266 | 267 | if args.info: 268 | font.print_info() 269 | 270 | if args.render_text is not None: 271 | img = font.render_text(args.render_text) 272 | if args.output: 273 | img.save(args.output) 274 | else: 275 | img.show() 276 | elif args.glyph_table: 277 | img = font.render_glyph_table() 278 | if args.output: 279 | img.save(args.output) 280 | else: 281 | img.show() 282 | elif args.show_glyph is not None: 283 | img = font.render_glyph(args.show_glyph) 284 | if img: 285 | if args.output: 286 | img.save(args.output) 287 | else: 288 | img.show() 289 | else: 290 | print("Glyph is empty") 291 | -------------------------------------------------------------------------------- /pyfis/krone/k8200.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2019 - 2023 Julian Metzler 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | """ 17 | 18 | import serial 19 | import time 20 | 21 | from .exceptions import CommunicationError 22 | from ..utils.base_serial import BaseSerialPort 23 | 24 | 25 | class Krone8200Display: 26 | """ 27 | Controls split-flap displays using the Krone 8200 system. 28 | This can be used to control an entire Krone 8200 platform display 29 | without any modifications. 30 | """ 31 | 32 | STX = 0x02 33 | ETX = 0x03 34 | EOT = 0x04 35 | ENQ = 0x05 36 | DLE = 0x10 37 | NAK = 0x15 38 | ETB = 0x17 39 | PAD = 0x7F 40 | ACK0 = (DLE, 0x30) 41 | ACK1 = (DLE, 0x31) 42 | WABT = (DLE, 0x3F) 43 | 44 | SIDE_BOTH = 0 45 | SIDE_A = 1 46 | SIDE_B = 2 47 | 48 | DEBUG_CHARS = { 49 | STX: "STX", 50 | ETX: "ETX", 51 | EOT: "EOT", 52 | ENQ: "ENQ", 53 | DLE: "DLE", 54 | NAK: "NAK", 55 | ETB: "ETB", 56 | PAD: "PAD" 57 | } 58 | 59 | def __init__(self, port, address, debug = False, exclusive = True, is_board = False): 60 | """ 61 | Note on the is_board parameter: If this is True, pyFIS will not add 62 | any PAD bytes to the messages. This is - for some reason - required 63 | to control "board" style displays, i.e. displays using a firmware 64 | designed for large boards with lots of lines. 65 | However, this firmware has been used even for two-line displays! 66 | """ 67 | self.debug = debug 68 | self.is_board = is_board 69 | if isinstance(port, serial.Serial) or isinstance(port, BaseSerialPort): 70 | self.port = port 71 | else: 72 | self.port = serial.Serial(port, baudrate=2400, timeout=1.0, exclusive=exclusive) 73 | # To enable receiving responses (DTR coupled to Rx via optocoupler) 74 | self.port.setDTR(1) 75 | # Set Rx address 76 | self.rx_address = address 77 | # Tx address is 1 byte less 78 | self.tx_address = (address[0]-1, address[1]-1) 79 | 80 | def make_parity(self, byte): 81 | result = byte 82 | num_ones = 0 83 | for n in range(8): 84 | if (byte >> n) & 1: 85 | num_ones += 1 86 | if num_ones % 2 != 0: 87 | result |= 0x80 88 | else: 89 | result &= 0x7F 90 | return result 91 | 92 | def make_lrc(self, data): 93 | lrc = 0x7F 94 | for b in data: 95 | lrc ^= b 96 | return lrc 97 | 98 | def debug_message(self, message): 99 | """ 100 | Turn a message into a readable form 101 | """ 102 | result = "" 103 | for byte in message: 104 | byte &= 0x7F 105 | if byte in self.DEBUG_CHARS: 106 | result += self.DEBUG_CHARS[byte] 107 | elif byte in range(0, 32) or byte == 127: 108 | result += "<{:02X}>".format(byte) 109 | else: 110 | result += chr(byte) 111 | result += " " 112 | return result 113 | 114 | def read_response(self): 115 | """ 116 | Read the response from the addressed station 117 | """ 118 | timeout = 0.0 119 | while not self.port.inWaiting(): 120 | time.sleep(0.1) 121 | timeout += 0.1 122 | if timeout >= 3.0: 123 | raise CommunicationError("No response received from display") 124 | response = self.port.read(self.port.inWaiting()) 125 | 126 | while True: 127 | time.sleep(0.1) 128 | in_waiting = self.port.inWaiting() 129 | if not in_waiting: 130 | break 131 | response += self.port.read(in_waiting) 132 | 133 | if self.debug: 134 | print("RX: " + self.debug_message(response)) 135 | 136 | if not response: 137 | raise CommunicationError("No response received from display") 138 | 139 | response = [byte & 0x7F for byte in response] # Strip checksum bit; TODO: Actually check it 140 | 141 | if response[0] != self.PAD: 142 | raise CommunicationError("First byte of response should be PAD, was " + self.debug_message(response[0:1])) 143 | 144 | if len(response) >= 2 and response[1] == self.NAK: 145 | raise CommunicationError("NAK response") 146 | 147 | return response[1:] # Strip leading PAD 148 | 149 | def read_response_and_handle_wait(self, tx=False): 150 | response = self.read_response() 151 | wait_count = 0 152 | while self.check_response_wait(response): 153 | wait_count += 1 154 | if wait_count >= 3: 155 | self.send_end_comm() 156 | raise CommunicationError("Maximum wait retries exceeded") 157 | time.sleep(3) 158 | if tx: 159 | self.send_raw_message([self.PAD, self.EOT, self.PAD, self.tx_address[0], self.tx_address[1], self.ENQ, self.PAD]) 160 | else: 161 | self.send_raw_message([self.PAD, self.EOT, self.PAD, self.rx_address[0], self.rx_address[1], self.ENQ, self.PAD]) 162 | response = self.read_response() 163 | return response 164 | 165 | def send_raw_message(self, message): 166 | for i, byte in enumerate(message): 167 | message[i] = self.make_parity(byte) 168 | if self.debug: 169 | print("TX: " + self.debug_message(message)) 170 | self.port.write(bytearray(message)) 171 | 172 | def send_rx_request(self): 173 | if self.is_board: 174 | self.send_raw_message([self.EOT, self.rx_address[0], self.rx_address[1], self.ENQ]) 175 | else: 176 | self.send_raw_message([self.PAD, self.EOT, self.PAD, self.rx_address[0], self.rx_address[1], self.ENQ, self.PAD]) 177 | time.sleep(0.2) 178 | response = self.read_response_and_handle_wait(tx=False) 179 | return self.check_response_ack(response) 180 | 181 | def send_tx_request(self): 182 | if self.is_board: 183 | self.send_raw_message([self.EOT, self.tx_address[0], self.tx_address[1], self.ENQ]) 184 | else: 185 | self.send_raw_message([self.PAD, self.EOT, self.PAD, self.tx_address[0], self.tx_address[1], self.ENQ, self.PAD]) 186 | time.sleep(0.5) 187 | response = self.read_response_and_handle_wait(tx=True) 188 | return response 189 | 190 | def check_response_wait(self, response): 191 | # Return True if the response is a "wait" sequence 192 | if tuple(response) == self.WABT: 193 | return True 194 | return False 195 | 196 | def check_response_ack(self, response): 197 | if tuple(response) not in (self.ACK0, self.ACK1): 198 | return False 199 | return True 200 | 201 | def send_message(self, message): 202 | """ 203 | Send a message. Requires the station to be addressed with send_comm_request before. 204 | """ 205 | data = [self.STX] + list(map(ord, message)) + [self.ETX] 206 | cmd = [self.PAD] + data 207 | cmd.append(self.make_lrc(data[1:])) 208 | cmd.append(self.PAD) 209 | self.send_raw_message(cmd) 210 | 211 | def send_end_comm(self): 212 | self.send_raw_message([self.PAD, self.EOT, self.PAD]) 213 | 214 | def send_ack0(self): 215 | self.send_raw_message([self.PAD, self.DLE, 0x30, self.PAD]) 216 | 217 | def send_command(self, address, side, command): 218 | """ 219 | Send a simple command 220 | """ 221 | if not self.send_rx_request(): 222 | return False 223 | self.send_message("{address:>02}{side:>01}{command}".format(address=address, side=side, command=command)) 224 | time.sleep(0.2) 225 | 226 | response = self.read_response_and_handle_wait(tx=False) 227 | if not self.check_response_ack(response): 228 | return False 229 | self.send_end_comm() 230 | return True 231 | 232 | def send_command_with_response(self, address, side, command): 233 | """ 234 | Send a command and retrieve the response data 235 | """ 236 | if not self.send_command(address, side, command): 237 | return None 238 | return self.send_tx_request() 239 | 240 | def set_home(self, address = 1, side = 0): 241 | """ 242 | Set all units to their home position 243 | """ 244 | return self.send_command(address, side, "R") 245 | 246 | def set_positions(self, positions, auto_update = True, address = 1, side = 0): 247 | """ 248 | Set all units with sequential addressing 249 | positions: list of positions for units starting at address 1 250 | """ 251 | command = "C" + "".join(["{:>02}".format(p) for p in positions]) 252 | if auto_update: 253 | command += "@A" 254 | return self.send_command(address, side, command) 255 | 256 | def set_positions_addressed(self, positions, auto_update = True, address = 1, side = 0): 257 | """ 258 | Set all units with explicit addressing 259 | positions: dict of format {address: position} 260 | """ 261 | command = "E" + "".join(["{a:>02}{p:>02}".format(a=a, p=p) for a, p in positions.items()]) 262 | if auto_update: 263 | command += "@A" 264 | return self.send_command(address, side, command) 265 | 266 | def update(self, address = 1, side = 0): 267 | """ 268 | Update units (cause them to actually turn) 269 | """ 270 | return self.send_command(address, side, "A") 271 | 272 | def set_light(self, unit, state, auto_update = True, address = 1, side = 0): 273 | """ 274 | Set backlight (if supported) 275 | unit: unit address which controls the light 276 | state: 1 or 0 277 | """ 278 | command = "Z{:02d}{:02d}".format(unit, state) 279 | if auto_update: 280 | command += "@A" 281 | return self.send_command(address, side, command) 282 | 283 | def set_blinker(self, unit, state, auto_update = True, address = 1, side = 0): 284 | """ 285 | Set blinker (if supported) 286 | unit: unit address which controls the blinkers 287 | state: 0 (lights off), 1 (light 1 on), 2 (light 2 on), 3 (both lights on) 288 | Needs to be followed by a set_light command 289 | """ 290 | command = "B{:02d}{:02d}".format(unit, state) 291 | if auto_update: 292 | command += "@A" 293 | return self.send_command(address, side, command) 294 | 295 | def restart(self, address = 1, side = 0): 296 | """ 297 | Restart the controller 298 | """ 299 | return self.send_command(address, side, "Y") 300 | 301 | def lock_units(self, units, address = 1, side = 0): 302 | """ 303 | Lock the specified units (cause them to ignore input) 304 | units: unit addresses to be locked 305 | """ 306 | command = "S" + "".join(["{:>02}".format(u) for u in units]) 307 | return self.send_command(address, side, command) 308 | 309 | def unlock_units(self, units, address = 1, side = 0): 310 | """ 311 | Unlock the specified units (cause them to accept input) 312 | units: unit addresses to be unlocked 313 | """ 314 | command = "F" + "".join(["{:>02}".format(u) for u in units]) 315 | return self.send_command(address, side, command) 316 | 317 | def read_status(self, units, address = 1, side = 0): 318 | """ 319 | Read the status of the specified units. 320 | units: unit addresses to be read 321 | """ 322 | command = "M" + "".join(["{:>02}".format(u) for u in units]) 323 | return self.send_command_with_response(address, side, command) 324 | 325 | def read_positions(self, units, address = 1, side = 0): 326 | """ 327 | Read the positions of the specified units. 328 | units: unit addresses to be read 329 | """ 330 | command = "L" + "".join(["{:>02}".format(u) for u in units]) 331 | return self.send_command_with_response(address, side, command) 332 | 333 | def d_set_module_data(self, module_data): 334 | # Compatibility function for SplitFlapDisplay class 335 | # TODO: Handle side and address? 336 | self.set_positions_addressed(dict(module_data), auto_update=True) 337 | 338 | def d_update(self): 339 | # Compatibility function for SplitFlapDisplay class 340 | pass 341 | --------------------------------------------------------------------------------