├── 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 | 
44 | 
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 | 
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 | 
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 | 
93 |
94 | KRONE / MAN "FBUE" address board for "FBM" split-flap units
95 |
96 | 
97 |
98 | KRONE / MAN "FBK" board for controlling groups of FBM+FBUE split-flap units
99 |
100 | 
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 |
--------------------------------------------------------------------------------