From: Benjamin Braatz Date: Fri, 22 Jan 2021 09:26:47 +0000 (+0100) Subject: Put dependencies in this repository and rename X-Git-Url: http://git.graph-it.com/?a=commitdiff_plain;h=c46607a2df871b5809af7762fe3409133b1b1c9c;p=graphit%2Fschaltschrank.git Put dependencies in this repository and rename --- diff --git a/conf.json b/conf.json index f3d1757..ad32949 100644 --- a/conf.json +++ b/conf.json @@ -52,5 +52,5 @@ [ "E4-5", "T2-13" ], [ "E4-6", "T2-14" ], [ "E4-7", "T2-15" ], [ "E4-8", "T2-16" ] ] } ], "modbus": - { "serial device": "/dev/serial1", + { "serial device": "/dev/serial0", "slave id": 1 } } diff --git a/controlpi.service b/controlpi.service deleted file mode 100644 index 535996c..0000000 --- a/controlpi.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Control Pi Service -Wants=network-online.target -After=network-online.target - -[Service] -WorkingDirectory=/home/pi -Environment=PYTHONUNBUFFERED=1 -ExecStart=/home/pi/controlpi/bin/python -m graphit_controlpi.main conf.json web/ - -[Install] -WantedBy=multi-user.target diff --git a/gevent/__init__.py b/gevent/__init__.py new file mode 100644 index 0000000..d2148e1 --- /dev/null +++ b/gevent/__init__.py @@ -0,0 +1,4 @@ +__all__ = ['EventEmitterInterface', 'EventEmitterMixin'] + +from .interface import EventEmitterInterface +from .mixin import EventEmitterMixin diff --git a/gevent/interface.py b/gevent/interface.py new file mode 100644 index 0000000..939f1e4 --- /dev/null +++ b/gevent/interface.py @@ -0,0 +1,25 @@ +import abc +from typing import Hashable, Callable + + +class EventEmitterInterface(abc.ABC): + @abc.abstractmethod + def on(self, event: Hashable, callback: Callable) -> Hashable: + ''' + Registers the given callback for the given event. + Returns handle to unregister the given callback. + ''' + + @abc.abstractmethod + def off(self, handle: Hashable) -> bool: + ''' + Unregisters a previously registered callback by the given handle. + Returns True on success. + ''' + + @abc.abstractmethod + def _emit(self, event: Hashable, *args, **kwargs) -> None: + ''' + Emits the given event by calling all callbacks registered for this + event. + ''' diff --git a/gevent/mixin.py b/gevent/mixin.py new file mode 100644 index 0000000..dfc618b --- /dev/null +++ b/gevent/mixin.py @@ -0,0 +1,64 @@ +import uuid +from typing import Hashable, Callable, MutableMapping, Mapping + +from .interface import EventEmitterInterface + +EvMap = MutableMapping[Hashable, Hashable] +CbMap = MutableMapping[Hashable, MutableMapping[Hashable, Callable]] + + +class EventEmitterMixin(EventEmitterInterface): + def on(self, event: Hashable, callback: Callable) -> Hashable: + events: EvMap + callbacks: CbMap + try: + events = self._eventEmitterMixinEvents + callbacks = self._eventEmitterMixinCallbacks + except AttributeError: + self._eventEmitterMixinEvents: EvMap = {} + self._eventEmitterMixinCallbacks: CbMap = {} + events = self._eventEmitterMixinEvents + callbacks = self._eventEmitterMixinCallbacks + + if event not in callbacks: + callbacks[event] = {} + + handle = uuid.uuid4() + while handle in events: + handle = uuid.uuid4() + + events[handle] = event + callbacks[event][handle] = callback + + return handle + + def off(self, handle: Hashable) -> bool: + try: + events = self._eventEmitterMixinEvents + callbacks = self._eventEmitterMixinCallbacks + except AttributeError: + return False + + if handle not in events: + return False + event = events[handle] + + del events[handle] + del callbacks[event][handle] + + if not callbacks[event]: + del callbacks[event] + + return True + + def _emit(self, event: Hashable, *args, **kwargs) -> None: + try: + callbacks = self._eventEmitterMixinCallbacks + except AttributeError: + return + + if event not in callbacks: + return + + for callback in callbacks[event].values(): + callback(*args, **kwargs) diff --git a/gmodbus/__init__.py b/gmodbus/__init__.py new file mode 100644 index 0000000..46a4c07 --- /dev/null +++ b/gmodbus/__init__.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +__all__ = ('DatatypesProtocol') + + +import random +import struct + +from typing import Mapping + +from .transport import ClientInterface + + +class DatatypesProtocol(): + + def __init__(self, client: ClientInterface, coils, registers) -> None: + self.__client = client + self.__coils = coils + self.__registers = registers + + async def read_coil(self, coil: int) -> bool: + assert coil in self.__coils, 'unknown coil' + + res = await self.__client.read_coils(coil - 1, 1) + return res[0] + + async def write_coil(self, coil: int, value: bool) -> None: + assert coil in self.__coils, 'unknown coil' + + await self.__client.write_single_coil(coil - 1, value) + + async def read_register(self, register: int) -> float: + assert register in self.__registers, 'unknown register' + + rw = self.__registers[register]['rw'] + assert rw is 'r' or rw is 'rw', 'register is not readable' + + rType = self.__registers[register]['type'] + rConf = self.__registers[register]['conf'] + if rType == 'uint16': + return await self.__read_uint16_register(register, **rConf) + elif rType == 'uint32': + return await self.__read_uint32_register(register, **rConf) + elif rType == 'int32': + return await self.__read_int32_register(register, **rConf) + elif rType == 'enum': + return await self.__read_enum_register(register, **rConf) + else: + assert False, 'unknown register type' + + + async def write_register(self, register: int, value: int) -> None: + assert register in self.__registers, 'unknown register' + + rw = self.__registers[register]['rw'] + assert rw is 'w' or rw is 'rw', 'register is not writable' + + rType = self.__registers[register]['type'] + rConf = self.__registers[register]['conf'] + if rType == 'uint16': + await self.__write_uint16_register(register, value, **rConf) + elif rType == 'uint32': + await self.__write_uint32_register(register, value, **rConf) + elif rType == 'int32': + await self.__write_int32_register(register, value, **rConf) + elif rType == 'enum': + await self.__write_enum_register(register, value, ** rConf) + else: + assert False, 'unknown register type' + + async def __read_uint16_register(self, register: int, min: int, max: int, unit: str, scale: int)-> float: + res = await self.__client.read_holding_registers(register-1, 1) + res = res[0] / (1/scale) + + assert res >= min and res <= max + return res + + async def __write_uint16_register(self, register: int, value: float, min: int, max: int, unit: str, scale: int) -> None: + assert value >= min and value <= max + + await self.__client.write_multiple_registers(register-1, [int(value * (1/scale))]) + + async def __read_uint32_register(self, register: int, min: int, max: int, unit: str, scale: int) -> float: + res = await self.__client.read_holding_registers(register-1, 2) + res = struct.pack('>HH', res[0], res[1]) + res = struct.unpack('>L', res) + res = res[0] / (1/scale) + + assert res >= min and res <= max + return res + + async def __write_uint32_register(self, register: int, value: float, min: int, max: int, unit: str, scale: int) -> None: + assert value >= min and value <= max + + req = int(value * (1/scale)) + req = struct.pack('>L', req) + req = struct.unpack('>HH', req) + + await self.__client.write_multiple_registers(register-1, [x for x in req]) + + async def __read_int32_register(self, register: int, min: int, max: int, unit: str, scale: int) -> float: + res = await self.__client.read_holding_registers(register-1, 2) + + res = struct.pack('>HH', res[0], res[1]) + res = struct.unpack('>l', res) + res = res[0] / (1/scale) + + assert res >= min and res <= max + return res + + async def __write_int32_register(self, register: int, value: float, min: int, max: int, unit: str, scale: int) -> None: + assert value >= min and value <= max + + req = int(value * (1/scale)) + req = struct.pack('>l', req) + req = struct.unpack('>HH', req) + + await self.__client.write_multiple_registers(register-1, [x for x in req]) + + async def __read_enum_register(self, register: int, values: Mapping[int, str]) -> int: + res = await self.__client.read_holding_registers(register - 1, 1) + res = res[0] + + assert res in values + return res + + async def __write_enum_register(self, register: int, value: int, values: Mapping[int, str]) -> None: + assert value in values + + await self.__client.write_multiple_registers(register-1, [value]) + + async def loopback_test(self, testvalue: int = None) -> None: + if testvalue is not None: + assert testvalue >= 0 and testvalue < 2 ^ 16 + else: + testvalue = random.randrange(2 ^ 16) + + await self.__client.loopback_test(testvalue) diff --git a/gmodbus/hitachi.py b/gmodbus/hitachi.py new file mode 100644 index 0000000..2fb7372 --- /dev/null +++ b/gmodbus/hitachi.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- + +__all__ = ('SJP1Fu') + + +from . import DatatypesProtocol, ClientInterface + + +class SJP1Fu(): + + def __init__(self, client: ClientInterface) -> None: + self.__protocol = DatatypesProtocol(client, SJP1FU_COILS, SJP1FU_REGISTERS) + + async def set_frequency(self, frequency: int) -> None: + # Page 532 / 14-41 + await self.__protocol.write_register(10502, frequency) + + async def get_frequency(self) -> int: + # Page 532 / 14-41 + return int(await self.__protocol.read_register(10502)) + + async def start_inverter(self) -> None: + # Page 518 / 14-27 + await self.__protocol.write_coil(1, True) + + async def stop_inverter(self) -> None: + # Page 518 / 14-27 + await self.__protocol.write_coil(1, False) + + @property + async def inverter_active(self) -> bool: + return await self.__protocol.read_coil(1) + + +SJP1FU_COILS = { + 1: { + 'name': 'Operation Command', + 'rw': 'rw', + 'values': [ 'Stop', 'Run' ] + } +} + + +SJP1FU_REGISTERS = { + 10001: { + 'code': 'dA-01', + 'name': 'Output frequency monitor', + 'rw': 'r', + 'type': 'uint16', + 'conf': { + 'min': 0, + 'max': 590, + 'unit': 'Hz', + 'scale': 0.01, + }, + }, + + 10003: { + 'code': 'dA-03', + 'name': 'Operation direction monitor', + 'rw': 'r', + 'type': 'enum', + 'conf': { + 'values': { + 0: 'Stop', + 1: 'Zero-speed out', + 2: 'Forward run', + 3: 'Reverse run', + } + } + }, + + 10502: { + 'name': 'Set Frequency', + 'rw': 'rw', + 'type': 'int32', + 'conf': { + 'min': -590, + 'max': 590, + 'unit': 'Hz', + 'scale': 0.01, + }, + }, + + 11010: { + 'code': 'FA-10', + 'name': 'Acceleration time (monitor + setting)', + 'rw': 'rw', + 'type': 'uint32', + 'conf': { + 'min': 0, + 'max': 3600, + 'unit': 's', + 'scale': 0.01, + }, + }, + 11012: { + 'code': 'FA-12', + 'name': 'Deceleration time (monitor + setting)', + 'rw': 'rw', + 'type': 'uint32', + 'conf': { + 'min': 0, + 'max': 3600, + 'unit': 's', + 'scale': 0.01, + }, + }, + + 12501: { + 'code': 'AF101', + 'name': 'First DC braking selection', + 'rw': 'rw', + 'type': 'enum', + 'conf': { + 'values': { +# 0: 'Stop', +# 1: 'Zero-speed out', +# 2: 'Forward run', +# 3: 'Reverse run', + } + } + }, +} diff --git a/gmodbus/transport.py b/gmodbus/transport.py new file mode 100644 index 0000000..8959145 --- /dev/null +++ b/gmodbus/transport.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +__all__ = ('ClientInterface', 'SerialPort', 'SerialClient') + + +import os +import abc +import fcntl +import struct +import asyncio + +from typing import List + +from umodbus.client.serial import rtu +from umodbus.functions import expected_response_pdu_size_from_request_pdu +from umodbus.client.serial.redundancy_check import add_crc + + +class ClientInterface(): + + @abc.abstractmethod + async def read_coils(self, starting_address: int, quantity: int) -> List[bool]: + ... + + @abc.abstractmethod + async def read_discrete_inputs(self, starting_address: int, quantity: int) -> List[bool]: + ... + + @abc.abstractmethod + async def read_holding_registers(self, starting_address: int, quantity: int) -> List[int]: + ... + + @abc.abstractmethod + async def read_input_registers(self, starting_address: int, quantity: int) -> List[int]: + ... + + @abc.abstractmethod + async def write_single_coil(self, address: int, value: bool) -> None: + ... + + @abc.abstractmethod + async def write_single_register(self, address: int, value: int) -> None: + ... + + @abc.abstractmethod + async def write_multiple_coils(self, starting_address: int, values: List[bool]) -> None: + ... + + @abc.abstractmethod + async def write_multiple_registers(self, starting_address: int, values: List[int]) -> None: + ... + + @abc.abstractmethod + async def loopback_test(self, value: int) -> int: + ... + + +class SerialPort(): + + def __init__(self, filename: str) -> None: + self.__file = os.open(filename, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + + self.__read_lock = asyncio.Lock() + self.__write_lock = asyncio.Lock() + + fcntl.fcntl(self.__file, fcntl.F_SETFL, os.O_NONBLOCK) + + async def __read(self, size: int = 1) -> bytes: + assert size > 0 + + loop = asyncio.get_event_loop() + future = loop.create_future() + + loop.add_reader(self.__file, lambda: future.set_result(os.read(self.__file, size))) + future.add_done_callback(lambda _f: loop.remove_reader(self.__file)) + + return await future + + async def read(self, size: int = 1) -> bytes: + assert size > 0 + + async with self.__read_lock: + buffer = b"" + while size > 0: + data = await self.__read(size) + if data is b"": + return buffer + + buffer += data + size -= len(data) + + return buffer + + async def __write(self, data: bytes) -> int: + assert len(data) > 0 + + loop = asyncio.get_event_loop() + future = loop.create_future() + + loop.add_writer(self.__file, lambda: future.set_result(os.write(self.__file, data))) + future.add_done_callback(lambda _f: loop.remove_writer(self.__file)) + + return await future + + async def write(self, data: bytes) -> int: + assert len(data) > 0 + + async with self.__write_lock: + size = 0 + while len(data) > 0: + written = await self.__write(data) + if written is 0: + return size + + data = data[written:] + size += written + + return size + + +class SerialClient(ClientInterface): + + def __init__(self, port: SerialPort, slave_id: int) -> None: + self.__port = port + self.__slave_id = slave_id + + async def read_coils(self, starting_address: int, quantity: int) -> List[bool]: + response = await self.__send_message(rtu.read_coils(self.__slave_id, starting_address, quantity)) + response = [bool(x) for x in response] + + return response + + async def read_discrete_inputs(self, starting_address: int, quantity: int) -> List[bool]: + response = await self.__send_message(rtu.read_discrete_inputs(self.__slave_id, starting_address, quantity)) + response = [bool(x) for x in response] + + return response + + async def read_holding_registers(self, starting_address: int, quantity: int) -> List[int]: + return await self.__send_message(rtu.read_holding_registers(self.__slave_id, starting_address, quantity)) + + async def read_input_registers(self, starting_address: int, quantity: int) -> List[int]: + return await self.__send_message(rtu.read_input_registers(self.__slave_id, starting_address, quantity)) + + async def write_single_coil(self, address: int, value: bool) -> None: + await self.__send_message(rtu.write_single_coil(self.__slave_id, address, int(value))) + + async def write_single_register(self, address: int, value: int) -> None: + await self.__send_message(rtu.write_single_register(self.__slave_id, address, value)) + + async def write_multiple_coils(self, starting_address: int, values: List[bool]) -> None: + await self.__send_message(rtu.write_multiple_coils(self.__slave_id, starting_address, [int(x) for x in values])) + + async def write_multiple_registers(self, starting_address: int, values: List[int]) -> None: + await self.__send_message(rtu.write_multiple_registers(self.__slave_id, starting_address, values)) + + async def loopback_test(self, value: int) -> int: + assert 0 <= value < 2 ^ 16 + + req = struct.pack('>BBHH', 1, 8, 0, value) + req = add_crc(req) + + await self.__port.write(req) + + res = await self.__port.read(len(req)) + assert req == res + + return value + + async def __send_message(self, message: bytes) -> List[int]: + await self.__port.write(message) + + # Check exception ADU (which is shorter than all other responses) first. + exception_adu_size = 5 + response_error_adu = await self.__port.read(exception_adu_size) + rtu.raise_for_exception_adu(response_error_adu) + + expected_response_size = \ + expected_response_pdu_size_from_request_pdu(message[1:-2]) + 3 + response_remainder = await self.__port.read(expected_response_size - exception_adu_size) + + if len(response_remainder) < expected_response_size - exception_adu_size: + raise ValueError + + result = rtu.parse_response_adu(response_error_adu + response_remainder, message) + + if not isinstance(result, list): + return [result] + + return result diff --git a/gpin/__init__.py b/gpin/__init__.py new file mode 100644 index 0000000..34d0b74 --- /dev/null +++ b/gpin/__init__.py @@ -0,0 +1,13 @@ +__all__ = ['PinInterface', + 'InvertingPin', 'SwitchPin', 'GuardedPin', 'TimerPin', + 'AggregatePinInterface', 'AbstractAggregatePin', + 'OrAggregatePin', 'AndAggregatePin', + 'GPIOInputPin', 'GPIOOutputPin', + 'PCF8574Input', 'PCF8574Output'] + +from .interface import PinInterface +from .composition import InvertingPin, SwitchPin, GuardedPin, TimerPin,\ + AggregatePinInterface, AbstractAggregatePin,\ + OrAggregatePin, AndAggregatePin +from .gpio import GPIOInputPin, GPIOOutputPin +from .pcf8574 import PCF8574Input, PCF8574Output diff --git a/gpin/composition.py b/gpin/composition.py new file mode 100644 index 0000000..30d12f3 --- /dev/null +++ b/gpin/composition.py @@ -0,0 +1,196 @@ +import abc +import asyncio +from typing import Sequence +import gevent + +from .interface import PinInterface + + +class InvertingPin(PinInterface, gevent.EventEmitterMixin): + ''' Wraps and inverts a pin ''' + + def __init__(self, pin: PinInterface) -> None: + self.__pin = pin + + def _onChange(value: bool): + self._emit('change', not value) + self.__pin.on('change', _onChange) + + @property + def value(self) -> bool: + return not self.__pin.value + + @value.setter + def value(self, value: bool) -> None: + self.__pin.value = not value + + @property + def settable(self) -> bool: + return self.__pin.settable + + +class SwitchPin(PinInterface, gevent.EventEmitterMixin): + ''' Turns a Push-Button into a Switch ''' + + def __init__(self, pin: PinInterface, value: bool = False) -> None: + self.__pin = pin + self.__value = value + + def _onChange(value: bool): + if value: + self.value = not self.value + self.__pin.on('change', _onChange) + + @property + def value(self) -> bool: + return self.__value + + @value.setter + def value(self, value: bool) -> None: + if value != self.__value: + self.__value = value + self._emit('change', self.__value) + + @property + def settable(self) -> bool: + return True + + +class GuardedPin(PinInterface, gevent.EventEmitterMixin): + ''' Wraps a pin and a guard ''' + + def __init__(self, wrapped: PinInterface, guard: PinInterface) -> None: + self.__wrapped = wrapped + self.__guard = guard + + def _onChange(value: bool): + if self.__guard.value: + return + self._emit('change', value) + self.__wrapped.on('change', _onChange) + + @property + def value(self) -> bool: + return self.__wrapped.value + + @value.setter + def value(self, value: bool) -> None: + if self.__guard.value: + return + self.__wrapped.value = value + + @property + def settable(self) -> bool: + return self.__wrapped.settable + + +class TimerPin(PinInterface, gevent.EventEmitterMixin): + ''' The TimerPin unsets itself after a given delay ''' + + def __init__(self, delay: float) -> None: + self.__delay = delay + + self.__value = False + self.__handle = None + + @property + def value(self) -> bool: + return self.__value + + @value.setter + def value(self, value: bool) -> None: + if self.__value != value: + self.__value = value + self.__switch() + + self._emit('change', self.__value) + + @property + def settable(self) -> bool: + return True + + def __switch(self): + def _trigger(): + self.value = False + + if self.__value: + loop = asyncio.get_running_loop() + self.__handle = loop.call_later(self.__delay, _trigger) + elif self.__handle: + self.__handle.cancel() + self.__handle = None + + +class AggregatePinInterface(PinInterface): + ''' A pin that aggregates other pins ''' + + @property + @abc.abstractmethod + def children(self) -> Sequence[PinInterface]: + ''' The pins ''' + + +class AbstractAggregatePin(AggregatePinInterface, + gevent.EventEmitterMixin): + ''' An abstract pin aggregate ''' + + def __init__(self, children: Sequence[PinInterface]) -> None: + assert children, 'AggregatePin needs at least one child!' + + self.__children = tuple(children) + self.__value = self._calculate(self.__children) + + def _onChange(_value: bool): + value = self.__value + self.__value = self._calculate(self.__children) + if value != self.__value: + self._emit('change', self.__value) + for pin in self.__children: + pin.on('change', _onChange) + + @property + def value(self) -> bool: + return self.__value + + @value.setter + def value(self, value: bool) -> None: + raise NotImplementedError() + + @property + def settable(self) -> bool: + return False + + @property + def children(self) -> Sequence[PinInterface]: + return self.__children + + @abc.abstractmethod + def _calculate(self, children: Sequence[PinInterface]) -> bool: + ''' Calculate the aggregated value ''' + raise NotImplementedError() + + +class OrAggregatePin(AbstractAggregatePin): + ''' A pin that aggregates with the 'or' function. ''' + + def __init__(self, children: Sequence[PinInterface]) -> None: + AbstractAggregatePin.__init__(self, children) + + def _calculate(self, children: Sequence[PinInterface]) -> bool: + value = False + for child in children: + value = value or child.value + return value + + +class AndAggregatePin(AbstractAggregatePin): + ''' A pin that aggregates with the 'and' function. ''' + + def __init__(self, children: Sequence[PinInterface]) -> None: + AbstractAggregatePin.__init__(self, children) + + def _calculate(self, children: Sequence[PinInterface]) -> bool: + value = True + for child in children: + value = value and child.value + return value diff --git a/gpin/gpio.py b/gpin/gpio.py new file mode 100644 index 0000000..5945b01 --- /dev/null +++ b/gpin/gpio.py @@ -0,0 +1,65 @@ +import asyncio +import pigpio +import gevent + +from .pigpio import get_pigpio_pi +from .interface import PinInterface + + +class GPIOInputPin(PinInterface, gevent.EventEmitterMixin): + def __init__(self, pin: int, glitch: int = 5000, up: bool = False) -> None: + self._pin = pin + pi = get_pigpio_pi() + pi.set_mode(self._pin, pigpio.INPUT) + pi.set_glitch_filter(self._pin, glitch) + pi.set_pull_up_down(self._pin, + pigpio.PUD_UP if up else pigpio.PUD_DOWN) + + def _onLoopChange(value: bool): + if self._value != value: + self._value = value + self._emit('change', self._value) + + loop = asyncio.get_running_loop() + + def _onGpioChange(pin: int, level: int, _tick: int): + if self._pin == pin and level < 2: + loop.call_soon_threadsafe(_onLoopChange, bool(level)) + + pi.callback(self._pin, pigpio.EITHER_EDGE, _onGpioChange) + self._value = bool(pi.read(self._pin)) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool) -> None: + raise NotImplementedError() + + @property + def settable(self) -> bool: + return False + + +class GPIOOutputPin(PinInterface, gevent.EventEmitterMixin): + def __init__(self, pin: int) -> None: + self._pin = pin + pi = get_pigpio_pi() + pi.set_mode(self._pin, pigpio.OUTPUT) + self._value = bool(pi.read(self._pin)) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool) -> None: + if self._value != value: + self._value = value + get_pigpio_pi().write(self._pin, int(value)) + self._emit('change', self._value) + + @property + def settable(self) -> bool: + return True diff --git a/gpin/interface.py b/gpin/interface.py new file mode 100644 index 0000000..c1c8130 --- /dev/null +++ b/gpin/interface.py @@ -0,0 +1,23 @@ +import abc +import gevent + + +class PinInterface(gevent.EventEmitterInterface): + ''' Emits change(bool) ''' + + @property + @abc.abstractmethod + def value(self) -> bool: + ''' Get current pin value ''' + raise NotImplementedError() + + @value.setter + def value(self, value: bool) -> None: + ''' Set the pin value ''' + raise NotImplementedError() + + @property + @abc.abstractmethod + def settable(self) -> bool: + ''' Is the pin settable? ''' + raise NotImplementedError() diff --git a/gpin/pcf8574.py b/gpin/pcf8574.py new file mode 100644 index 0000000..9e737e9 --- /dev/null +++ b/gpin/pcf8574.py @@ -0,0 +1,136 @@ +import gevent +from typing import Callable + +from .pigpio import get_pigpio_pi +from .interface import PinInterface + +PCF_ADDRESSES = tuple(range(32, 40)) + tuple(range(56, 64)) + + +def emitDiff(emit: Callable, oldValues: int, newValues: int): + assert isinstance(oldValues, int), 'oldValues must be an integer' + assert oldValues >= 0 and oldValues <= 255,\ + 'oldValues must be >= 0 and <= 255' + assert isinstance(newValues, int), 'newValues must be an integer' + assert newValues >= 0 and newValues <= 255,\ + 'newValues must be >= 0 and <= 255' + for i in range(0, 8): + mask = 1 << i + if mask & oldValues != mask & newValues: + emit('change', i, not bool(mask & newValues)) + + +class PCF8574Input(gevent.EventEmitterMixin): + def __init__(self, address: int, interrupt: PinInterface) -> None: + assert address in PCF_ADDRESSES, 'Invalid PCF8574(A) I²C address' + self._address = address + self._interrupt = interrupt + pi = get_pigpio_pi() + self._handle = pi.i2c_open(1, self._address) + self._values = pi.i2c_read_byte(self._handle) + self._pins = tuple(PCF8574InputPin(self, i) for i in range(0, 8)) + + def _onInterrupt(_value: bool): + oldValues = self._values + self._values = pi.i2c_read_byte(self._handle) + emitDiff(self._emit, oldValues, self._values) + self._int_handle = self._interrupt.on('change', _onInterrupt) + + def close(self) -> None: + self._interrupt.off(self._int_handle) + try: + get_pigpio_pi().i2c_close(self._handle) + except AttributeError: + pass + + def getPin(self, pin: int) -> PinInterface: + assert isinstance(pin, int), 'pin must be an integer' + assert pin >= 0 and pin <= 7, 'pin must be >= 0 and <= 7' + return self._pins[pin] + + def getValue(self, pin: int) -> bool: + assert isinstance(pin, int), 'pin must be an integer' + assert pin >= 0 and pin <= 7, 'pin must be >= 0 and <= 7' + return not bool(self._values & (1 << pin)) + + +class PCF8574InputPin(PinInterface, gevent.EventEmitterMixin): + def __init__(self, pcfInput: PCF8574Input, pcfPin: int) -> None: + self._input = pcfInput + self._pin = pcfPin + + def _onChange(pin: int, value: int): + if self._pin == pin: + self._emit('change', value) + self._input.on('change', _onChange) + + @property + def value(self) -> bool: + return self._input.getValue(self._pin) + + @value.setter + def value(self, value: bool) -> None: + raise NotImplementedError() + + @property + def settable(self) -> bool: + return False + + +class PCF8574Output(gevent.EventEmitterMixin): + def __init__(self, address: int) -> None: + assert address in PCF_ADDRESSES, 'Invalid PCF8574(A) I²C address' + self._address = address + pi = get_pigpio_pi() + self._handle = pi.i2c_open(1, self._address) + self._values = pi.i2c_read_byte(self._handle) + self._pins = tuple(PCF8574OutputPin(self, i) for i in range(0, 8)) + + def close(self) -> None: + try: + get_pigpio_pi().i2c_close(self._handle) + except AttributeError: + pass + + def getPin(self, pin: int) -> PinInterface: + assert isinstance(pin, int), 'pin must be an integer' + assert pin >= 0 and pin <= 7, 'pin must be >= 0 and <= 7' + return self._pins[pin] + + def getValue(self, pin: int) -> bool: + assert isinstance(pin, int), 'pin must be an integer' + assert pin >= 0 and pin <= 7, 'pin must be >= 0 and <= 7' + return not bool(self._values & (1 << pin)) + + def setValue(self, pin: int, value: bool) -> None: + assert isinstance(pin, int), 'pin must be an integer' + assert pin >= 0 and pin <= 7, 'pin must be >= 0 and <= 7' + assert isinstance(value, bool), 'value must be a bool' + value = not value + oldValues = self._values + self._values = (oldValues & (0xFF ^ (1 << pin))) | (int(value) << pin) + get_pigpio_pi().i2c_write_byte(self._handle, self._values) + emitDiff(self._emit, oldValues, self._values) + + +class PCF8574OutputPin(PinInterface, gevent.EventEmitterMixin): + def __init__(self, pcfOutput: PCF8574Output, pcfPin: int) -> None: + self._output = pcfOutput + self._pin = pcfPin + + def _onChange(pin: int, value: bool): + if self._pin == pin: + self._emit('change', value) + self._output.on('change', _onChange) + + @property + def value(self) -> bool: + return self._output.getValue(self._pin) + + @value.setter + def value(self, value: bool) -> None: + self._output.setValue(self._pin, value) + + @property + def settable(self) -> bool: + return True diff --git a/gpin/pigpio.py b/gpin/pigpio.py new file mode 100644 index 0000000..0e851c1 --- /dev/null +++ b/gpin/pigpio.py @@ -0,0 +1,9 @@ +import pigpio + +_pigpio_pi = None + +def get_pigpio_pi(): + global _pigpio_pi + if _pigpio_pi is None: + _pigpio_pi = pigpio.pi() + return _pigpio_pi diff --git a/graphit_controlpi/__init__.py b/graphit_controlpi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/graphit_controlpi/config.py b/graphit_controlpi/config.py deleted file mode 100644 index 3b44e77..0000000 --- a/graphit_controlpi/config.py +++ /dev/null @@ -1,40 +0,0 @@ -from graphit_pin import PCF8574Output, PCF8574Input, GPIOInputPin -from graphit_modbus.transport import SerialPort, SerialClient -from graphit_modbus.hitachi import SJP1Fu - - -async def process_configuration(conf, queues): - - def callback_factory(pin_name): - settable = pins[pin_name].settable - - def callback(value): - for queue in queues: - queue.put_nowait({'event': 'pinstate', 'pin': pin_name, - 'settable': settable, 'value': value, - 'changed': True}) - return callback - pins = {} - if 'i/o cards' in conf: - for card_conf in conf['i/o cards']: - card = None - if card_conf['type'] == 'output': - card = PCF8574Output(card_conf['address']) - elif card_conf['type'] == 'input': - card = PCF8574Input(card_conf['address'], - GPIOInputPin(card_conf['interrupt pin'], - up=True)) - if card is not None: - for i in range(8): - pin = card.getPin(i) - pin_names = card_conf['pins'][i] - for pin_name in pin_names: - pins[pin_name] = pin - pin.on('change', callback_factory(pin_name)) - fu = None - if 'modbus' in conf: - modbus_conf = conf['modbus'] - port = SerialPort(modbus_conf['serial device']) - client = SerialClient(port, modbus_conf['slave id']) - fu = SJP1Fu(client) - return (pins, fu) diff --git a/graphit_controlpi/main.py b/graphit_controlpi/main.py deleted file mode 100644 index d50df05..0000000 --- a/graphit_controlpi/main.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys -import json -import asyncio - -from .config import process_configuration -from .websocket import setup_websocket - - -async def setup(): - pins = {} - fu = None - queues = [] - with open(sys.argv[1]) as json_data: - conf = json.load(json_data) - (pins, fu) = await process_configuration(conf, queues) - await setup_websocket(pins, fu, queues, sys.argv[2]) - - -if __name__ == '__main__': - asyncio.get_event_loop().run_until_complete(setup()) - try: - asyncio.get_event_loop().run_forever() - except KeyboardInterrupt: - pass diff --git a/graphit_controlpi/websocket.py b/graphit_controlpi/websocket.py deleted file mode 100644 index 6d3d000..0000000 --- a/graphit_controlpi/websocket.py +++ /dev/null @@ -1,119 +0,0 @@ -import os -import functools -import socket -import json -import asyncio -import websockets -from http import HTTPStatus - - -async def process_command(command, pins, fu, queue): - if command['command'] == 'setpin': - if command['pin'] in pins and pins[command['pin']].settable: - pins[command['pin']].value = command['value'] - elif command['command'] == 'getpin': - if command['pin'] in pins: - pin = pins[command['pin']] - await queue.put({'event': 'pinstate', 'pin': command['pin'], - 'settable': pin.settable, 'value': pin.value, - 'changed': False}) - elif command['command'] == 'getallpins': - for pin_name in pins: - pin = pins[pin_name] - await queue.put({'event': 'pinstate', 'pin': pin_name, - 'settable': pin.settable, 'value': pin.value, - 'changed': False}) - elif command['command'] == 'setfrequency': - await fu.set_frequency(command['value']) - elif command['command'] == 'getfrequency': - frequency = await fu.get_frequency() - await queue.put({'event': 'frequency', 'frequency': frequency}) - elif command['command'] == 'startinverter': - await fu.start_inverter() - elif command['command'] == 'stopinverter': - await fu.stop_inverter() - elif command['command'] == 'getinverter': - active = await fu.inverter_active - await queue.put({'event': 'inverterstate', 'active': active}) - - -async def command_handler(websocket, path, pins, fu, queue): - async for message in websocket: - command = json.loads(message) - await process_command(command, pins, fu, queue) - - -async def event_handler(websocket, path, queue): - while True: - event = await queue.get() - message = json.dumps(event) - await websocket.send(message) - queue.task_done() - - -async def handler(pins, fu, queues, websocket, path): - queue = asyncio.Queue() - queues.append(queue) - command_task = asyncio.create_task(command_handler(websocket, path, - pins, fu, queue)) - event_task = asyncio.create_task(event_handler(websocket, path, - queue)) - done, pending = await asyncio.wait( - [command_task, event_task], - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - queues.remove(queue) - - -async def process_request(server_root, path, request_headers): - if 'Upgrade' in request_headers: - return - if path == '/': - path = '/index.html' - response_headers = [ - ('Server', 'graphit_controlpi websocket server'), - ('Connection', 'close'), - ] - server_root = os.path.realpath(os.path.join(os.getcwd(), server_root)) - full_path = os.path.realpath(os.path.join(server_root, path[1:])) - if os.path.commonpath((server_root, full_path)) != server_root or \ - not os.path.exists(full_path) or not os.path.isfile(full_path): - return HTTPStatus.NOT_FOUND, [], b'404 NOT FOUND' - mime_type = 'application/octet-stream' - extension = full_path.split(".")[-1] - if extension == 'html': - mime_type = 'text/html' - elif extension == 'js': - mime_type = 'text/javascript' - elif extension == 'css': - mime_type = 'text/css' - response_headers.append(('Content-Type', mime_type)) - body = open(full_path, 'rb').read() - response_headers.append(('Content-Length', str(len(body)))) - return HTTPStatus.OK, response_headers, body - - -async def get_ip(): - ip = None - while not ip: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - s.connect(('10.255.255.255', 1)) - ip = s.getsockname()[0] - except Exception: - ip = None - await asyncio.sleep(0.1) - finally: - s.close() - return ip - - -async def setup_websocket(pins, fu, queues, server_root): - parameterised_handler = functools.partial(handler, pins, fu, queues) - parameterised_process_request = functools.partial(process_request, server_root) - hostname = await get_ip() - await websockets.serve(parameterised_handler, hostname, 80, - process_request=parameterised_process_request) - print(f"Serving on ws://{hostname}:80") diff --git a/modbus_test.py b/modbus_test.py new file mode 100644 index 0000000..1cf1ba2 --- /dev/null +++ b/modbus_test.py @@ -0,0 +1,35 @@ +import sys +import json +import asyncio +import websockets + + +async def test_commands(websocket): + commands = [{'command': 'getfrequency'}, + {'command': 'setfrequency', 'value': 100}, + {'command': 'getfrequency'}, + {'command': 'getinverter'}, + {'command': 'startinverter'}, + {'command': 'getinverter'}, + {'command': 'stopinverter'}, + {'command': 'getinverter'}] + for command in commands: + message = json.dumps(command) + await websocket.send(message) + print(f"Sent Command: {message}") + await asyncio.sleep(1) + + +async def receive_events(websocket): + async for message in websocket: + print(f"Recvd. Event: {message}") + + +async def main(hostname): + async with websockets.connect(f"ws://{hostname}") as websocket: + command_task = asyncio.create_task(test_commands(websocket)) + event_task = asyncio.create_task(receive_events(websocket)) + await command_task + +if __name__ == '__main__': + asyncio.run(main(sys.argv[1])) diff --git a/schaltschrank.service b/schaltschrank.service new file mode 100644 index 0000000..04fb234 --- /dev/null +++ b/schaltschrank.service @@ -0,0 +1,12 @@ +[Unit] +Description=Schaltschrank Service +Wants=network-online.target +After=network-online.target + +[Service] +WorkingDirectory=/home/pi +Environment=PYTHONUNBUFFERED=1 +ExecStart=/home/pi/schaltschrank/bin/python -m schaltschrank.main conf.json web/ + +[Install] +WantedBy=multi-user.target diff --git a/schaltschrank/__init__.py b/schaltschrank/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schaltschrank/config.py b/schaltschrank/config.py new file mode 100644 index 0000000..d358d06 --- /dev/null +++ b/schaltschrank/config.py @@ -0,0 +1,40 @@ +from gpin import PCF8574Output, PCF8574Input, GPIOInputPin +from gmodbus.transport import SerialPort, SerialClient +from gmodbus.hitachi import SJP1Fu + + +async def process_configuration(conf, queues): + + def callback_factory(pin_name): + settable = pins[pin_name].settable + + def callback(value): + for queue in queues: + queue.put_nowait({'event': 'pinstate', 'pin': pin_name, + 'settable': settable, 'value': value, + 'changed': True}) + return callback + pins = {} + if 'i/o cards' in conf: + for card_conf in conf['i/o cards']: + card = None + if card_conf['type'] == 'output': + card = PCF8574Output(card_conf['address']) + elif card_conf['type'] == 'input': + card = PCF8574Input(card_conf['address'], + GPIOInputPin(card_conf['interrupt pin'], + up=True)) + if card is not None: + for i in range(8): + pin = card.getPin(i) + pin_names = card_conf['pins'][i] + for pin_name in pin_names: + pins[pin_name] = pin + pin.on('change', callback_factory(pin_name)) + fu = None + if 'modbus' in conf: + modbus_conf = conf['modbus'] + port = SerialPort(modbus_conf['serial device']) + client = SerialClient(port, modbus_conf['slave id']) + fu = SJP1Fu(client) + return (pins, fu) diff --git a/schaltschrank/main.py b/schaltschrank/main.py new file mode 100644 index 0000000..d50df05 --- /dev/null +++ b/schaltschrank/main.py @@ -0,0 +1,24 @@ +import sys +import json +import asyncio + +from .config import process_configuration +from .websocket import setup_websocket + + +async def setup(): + pins = {} + fu = None + queues = [] + with open(sys.argv[1]) as json_data: + conf = json.load(json_data) + (pins, fu) = await process_configuration(conf, queues) + await setup_websocket(pins, fu, queues, sys.argv[2]) + + +if __name__ == '__main__': + asyncio.get_event_loop().run_until_complete(setup()) + try: + asyncio.get_event_loop().run_forever() + except KeyboardInterrupt: + pass diff --git a/schaltschrank/websocket.py b/schaltschrank/websocket.py new file mode 100644 index 0000000..6d3d000 --- /dev/null +++ b/schaltschrank/websocket.py @@ -0,0 +1,119 @@ +import os +import functools +import socket +import json +import asyncio +import websockets +from http import HTTPStatus + + +async def process_command(command, pins, fu, queue): + if command['command'] == 'setpin': + if command['pin'] in pins and pins[command['pin']].settable: + pins[command['pin']].value = command['value'] + elif command['command'] == 'getpin': + if command['pin'] in pins: + pin = pins[command['pin']] + await queue.put({'event': 'pinstate', 'pin': command['pin'], + 'settable': pin.settable, 'value': pin.value, + 'changed': False}) + elif command['command'] == 'getallpins': + for pin_name in pins: + pin = pins[pin_name] + await queue.put({'event': 'pinstate', 'pin': pin_name, + 'settable': pin.settable, 'value': pin.value, + 'changed': False}) + elif command['command'] == 'setfrequency': + await fu.set_frequency(command['value']) + elif command['command'] == 'getfrequency': + frequency = await fu.get_frequency() + await queue.put({'event': 'frequency', 'frequency': frequency}) + elif command['command'] == 'startinverter': + await fu.start_inverter() + elif command['command'] == 'stopinverter': + await fu.stop_inverter() + elif command['command'] == 'getinverter': + active = await fu.inverter_active + await queue.put({'event': 'inverterstate', 'active': active}) + + +async def command_handler(websocket, path, pins, fu, queue): + async for message in websocket: + command = json.loads(message) + await process_command(command, pins, fu, queue) + + +async def event_handler(websocket, path, queue): + while True: + event = await queue.get() + message = json.dumps(event) + await websocket.send(message) + queue.task_done() + + +async def handler(pins, fu, queues, websocket, path): + queue = asyncio.Queue() + queues.append(queue) + command_task = asyncio.create_task(command_handler(websocket, path, + pins, fu, queue)) + event_task = asyncio.create_task(event_handler(websocket, path, + queue)) + done, pending = await asyncio.wait( + [command_task, event_task], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + queues.remove(queue) + + +async def process_request(server_root, path, request_headers): + if 'Upgrade' in request_headers: + return + if path == '/': + path = '/index.html' + response_headers = [ + ('Server', 'graphit_controlpi websocket server'), + ('Connection', 'close'), + ] + server_root = os.path.realpath(os.path.join(os.getcwd(), server_root)) + full_path = os.path.realpath(os.path.join(server_root, path[1:])) + if os.path.commonpath((server_root, full_path)) != server_root or \ + not os.path.exists(full_path) or not os.path.isfile(full_path): + return HTTPStatus.NOT_FOUND, [], b'404 NOT FOUND' + mime_type = 'application/octet-stream' + extension = full_path.split(".")[-1] + if extension == 'html': + mime_type = 'text/html' + elif extension == 'js': + mime_type = 'text/javascript' + elif extension == 'css': + mime_type = 'text/css' + response_headers.append(('Content-Type', mime_type)) + body = open(full_path, 'rb').read() + response_headers.append(('Content-Length', str(len(body)))) + return HTTPStatus.OK, response_headers, body + + +async def get_ip(): + ip = None + while not ip: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(('10.255.255.255', 1)) + ip = s.getsockname()[0] + except Exception: + ip = None + await asyncio.sleep(0.1) + finally: + s.close() + return ip + + +async def setup_websocket(pins, fu, queues, server_root): + parameterised_handler = functools.partial(handler, pins, fu, queues) + parameterised_process_request = functools.partial(process_request, server_root) + hostname = await get_ip() + await websockets.serve(parameterised_handler, hostname, 80, + process_request=parameterised_process_request) + print(f"Serving on ws://{hostname}:80") diff --git a/setup.py b/setup.py index d0cbf30..3ba7f44 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r") as readme_file: setuptools.setup( name="graphit-controlpi", - version="0.2.3", + version="0.2.4", author="Graph-IT GmbH", author_email="info@graph-it.com", description="Main Module for Machine Control Pi", @@ -18,7 +18,8 @@ setuptools.setup( ], install_requires=[ "websockets", - "graphit-pin @ git+git://git.graph-it.com/graphit/pin-py.git", + "pigpio", + "umodbus", ], classifiers=[ "Programming Language :: Python",