make install work also for rpi boxes

- in addition to to_system, we have now to_<box> with every
  type of box
- install is not needed to run under sudo anymore
This commit is contained in:
2025-04-04 15:36:03 +02:00
parent 2945855b39
commit e344108e51
11 changed files with 343 additions and 90 deletions

View File

@ -1,13 +1,19 @@
# Installation tools for APUs and Raspberry Pi boxes
The APU is a Linux box to be used as a control box at LIN sample environment
Supports teh following boxes used at LIN sample environment:
- bare-apu: Linux box
- controlbox: from FRM2
- ionopimax: PLC from Sfera Labs with compute module 3
- ionopi: PLC from Sfrea labs with Raspberry Pi 3
- dual-eth-rpi: dual ethernet Raspberry Pi with compute module 4
`cfg/` contains the configuration files for all boxes
`to_system/` contains the files to be installed in the system
We have also the Raspberry based PLC Iono Pi Max
and other Raspberry Pi based boxes.
`to_<box>/` system files specific for a box (box names as in above list)
## config files
@ -16,9 +22,12 @@ filename: cfg/(hostname)_(hexdigits).cfg
* (hostname) the hostname to be given on startup
* (hexdigits) the last six digits of the ethernet adress of the first interface on the APU (leftmost plug)
content of the config files:
### example content of a config files for a control box:
```
[BOX]
type=controlbox
[NETWORK]
eth0=192.168.127.254 # leftmost socket: connect here a moxa with factory settings
eth1=192.168.2.2 # connected device will get this ip via DHCP
@ -38,6 +47,19 @@ port=5000 # the port for the frappy server
startup_text=startup...|HOST|ADDR # startup text, 3 lines separated with |
```
the [BOX] section is optional for controlbox and bare-apu
### example content of a config files for a control box:
```
[BOX]
type=ionopi
[NETWORK]
eth0=wan
```
## network configuration
The example above fits the most cases.

View File

@ -12,37 +12,36 @@ if bytes == str:
import sys
import os
import filecmp
import shutil
import re
import types
import socket
from pathlib import Path
from subprocess import Popen, PIPE
from ipaddress import IPv4Interface
from os.path import join, getmtime, exists, basename
from os.path import getmtime
if os.geteuid() != 0:
exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.")
def exit():
print('please restart sudo ./install.py again')
sys.exit()
try:
import serial
except ImportError:
if 'yes'.startswith(input('install pyserial? [y]')):
os.system('pip3 install --break-system-packages pyserial')
os.system('sudo pip3 install --break-system-packages pyserial')
serial = None
try:
from utils import BoxInfo, check_service, unix_cmd, change_firewall
from utils import BoxInfo, check_service, unix_cmd, change_firewall, UndefinedConfigFile
except ImportError:
if 'yes'.startswith(input('install netifaces? [y]')):
os.system('pip3 install --break-system-packages netifaces')
os.system('sudo pip3 install --break-system-packages netifaces')
serial = None
if serial is None:
print('please restart sudo ./install.py again')
print('please restart ./install.py again')
exit()
@ -51,36 +50,62 @@ TOOLS = BoxInfo.TOOLS
more_info = False
DEL = '__to_delete__'
STARTUP_TEXT = f'{TOOLS}/startup_display.txt'
STARTUP_TEXT = TOOLS / 'startup_display.txt'
COMMENT = "; please refer to README.md for help"
CONFIG_TEMPLATE = f"""[NETWORK]
{COMMENT}
TEMPLATES = {}
TEMPLATES['bare-apu'] = f"""{COMMENT}
[BOX]
type=bare-apu
[NETWORK]
eth0=wan
eth1=192.168.2.2
eth2=192.168.3.3
eth3=192.168.127.254
[ROUTER]
{COMMENT}
3001=192.168.127.254:3001
"""
CONTROLBOX_TEMPLATE = f"""[NETWORK]
{COMMENT}
eth0=dhcp
TEMPLATES['controlbox'] = f"""{COMMENT}
[BOX]
type=controlbox
[NETWORK]
eth0=wan
eth1=192.168.1.1
eth2=192.168.2.2
eth3=192.168.3.3
[DISPLAY]
{COMMENT}
line0=startup...
line1=HOST
line2=ADDR
"""
TEMPLATES['dual-eth-rpi'] = f"""{COMMENT}
[BOX]
type=dual-eth-rpi
[NETWORK]
eth0=wan
eth1=192.168.1.1
"""
GENERIC_TEMPLATE = f"""{COMMENT}
[BOX]
type=%s
[NETWORK]
eth0=wan
"""
DHCP_HEADER = """
default-lease-time 600;
max-lease-time 7200;
@ -133,14 +158,22 @@ box = BoxInfo()
box.hostname_changed = False
dhcp_server_cfg = []
TO_SYSTEM = [f'{TOOLS}/to_system', f'{TOOLS}/{box.typ}_system']
TO_SYSTEM = [TOOLS / 'to_system', TOOLS / f'{box.typ}_system']
def do_cmd(command):
unix_cmd(command, doit)
def do_cmd(*command):
unix_cmd(*command, execute=doit) # with sudo
show.dirty = True
def copyfiles(srcdir, dstdir, files):
unix_cmd('cp', *(str(srcdir / f) for f in files), str(dstdir))
def deletefiles(srcdir, files):
unix_cmd('rm', '-f', *(str(srcdir / f) for f in files))
def frappy(cfg=None, port=None, requirements='', **kwds):
if not cfg:
return None
@ -173,8 +206,8 @@ def router(firewall=False, **opts):
if not opts:
return None
try:
os.remove(join(TO_SYSTEM[0], 'etc/nftables.conf'))
with open(f'{TOOLS}/requirements.txt') as f:
os.remove(TO_SYSTEM[0] / 'etc/nftables.conf')
with open(TOOLS / 'requirements.txt') as f:
pip_requirements['root']['tools'] = f.read()
except FileNotFoundError:
pass
@ -202,20 +235,23 @@ def display(**opts):
def pip():
for user, requirements in pip_requirements.items():
if user == 'root':
tmpname = join('/root', 'pip_requirements.tmp')
pipcmd = 'pip3 install -r %s' % tmpname
tmpname = '/root/pip_requirements.tmp'
pipcmd = ['sudo pip3 install -r', tmpname]
else:
tmpname = join('/home', user, 'pip_requirements.tmp')
pipcmd = 'sudo --user %s pip3 install --user --break-system-packages -r %s' % (user, tmpname)
tmpname = f'/home/{user}/pip_requirements.tmp'
pipcmd = ['pip3 install --user --break-system-packages -r', tmpname]
filename = tmpname.replace('.tmp', '.txt')
content = ''.join('# --- for %s ---\n%s\n' % kv for kv in requirements.items())
if content:
if write_when_new(filename, content, True):
if doit:
os.rename(filename, tmpname)
if os.system(pipcmd) == 0:
if os.system(' '.join(pipcmd)) == 0:
os.rename(tmpname, filename)
else:
os.remove(tmpname)
else:
os.remove(tmpname)
else:
print(pipcmd)
# unix_cmd(pipcmd, doit, stdout=None)
@ -225,7 +261,7 @@ def pip():
SERVICES = dict(router=router, frappy=frappy, display=display)
def write_when_new(filename, content, ignore_reduction=False):
def write_when_new(filename, content, as_root=False, ignore_reduction=False):
if content is None:
lines = []
else:
@ -237,6 +273,9 @@ def write_when_new(filename, content, ignore_reduction=False):
old = fil.read()
except FileNotFoundError:
old = None
#except Exception as e:
# print('can not read', filename, '-> do not check')
# return False
if old == content:
return False
if ignore_reduction:
@ -249,10 +288,12 @@ def write_when_new(filename, content, ignore_reduction=False):
return False
if doit:
if lines:
with open(filename, 'w') as fil:
with tempfile.NamedTemporaryFile('w') as fil:
fil.write(content)
fil.flush()
unix_cmd('cp', fil.name, filename, sudo=as_root)
else:
os.remove(filename)
unix_cmd('rm', '-f', filename, sudo=as_root)
elif more_info:
print('.' * 80)
print('changes in', filename)
@ -314,7 +355,7 @@ def replace_in_file(filename, pat, repl):
if not success:
raise ValueError(f'{pat} not in {filename}')
write_when_new(filename, newcontent)
write_when_new(filename, newcontent, as_root=True)
return success[0]
@ -356,17 +397,17 @@ def create_if(name, cfg):
def walk(action):
for rootpath in TO_SYSTEM:
if not exists(rootpath):
if not rootpath.exists():
continue
os.chdir(rootpath)
for dirpath, _, files in os.walk('.'):
syspath = dirpath[1:] # remove leading '.'
action.dirpath = dirpath
syspath = Path(dirpath[1:]) # remove leading '.' -> absolute path
action.dirpath = Path(dirpath)
action.syspath = syspath
if files:
match, mismatch, missing = filecmp.cmpfiles(dirpath, syspath, files)
if mismatch:
newer = [f for f in mismatch if getmtime(join(syspath, f)) > getmtime(join(dirpath, f))]
newer = [f for f in mismatch if getmtime(syspath / f) > getmtime(dirpath / f)]
if newer:
action.newer(newer)
if len(newer) < len(mismatch):
@ -375,12 +416,12 @@ def walk(action):
if missing:
if DEL in missing:
missing.remove(DEL)
with open(join(dirpath, DEL)) as fil:
with open(dirpath / DEL) as fil:
to_delete = []
for fname in fil:
fname = fname.strip()
if fname and exists(join(syspath, fname)):
if exists(join(dirpath, fname)):
if fname and exists(syspath / fname):
if (dirpath / fname).exists():
print('ERROR: %s in %s, but also in repo -> ignored' % (fname, DEL))
else:
to_delete.append(fname)
@ -390,9 +431,8 @@ def walk(action):
class Walker:
def __init__(self):
self.dirpath = None
self.syspath = None
dirpath = None
syspath = None
class Show(Walker):
@ -403,7 +443,7 @@ class Show(Walker):
if more_info:
for f in files:
if f.endswith(more_info):
print('diff %s %s' % (join(self.dirpath, f), join(self.syspath, f)))
print('diff %s %s' % (self.dirpath / f, self.syspath / f))
print('.' * 80)
else:
print('%s %s:\n %s' % (title, self.syspath, ' '.join(files)))
@ -412,7 +452,7 @@ class Show(Walker):
self.dirty = True
if more_info:
for f in files:
print('cat %s' % join(dirpath, f))
print('cat %s' % (dirpath / f))
print('.' * 80)
else:
print('%s %s:\n %s' % (title, self.syspath, ' '.join(files)))
@ -432,41 +472,65 @@ class Show(Walker):
class Do(Walker):
def newer(self, files):
for file in files:
shutil.copy(join(self.syspath, file), join(self.dirpath, file))
copyfiles(self.syspath, self.dirpath, files)
def older(self, files):
for file in files:
shutil.copy(join(self.dirpath, file), join(self.syspath, file))
older = newer
def missing(self, files):
self.older(files)
missing = newer
def delete(self, files):
for file in files:
os.remove(join(self.syspath, file))
deletefiles(self.syspath, files)
def handle_config():
dhcp_server_cfg.clear()
try:
config = box.read_config()
except UndefinedConfigFile as e:
print(e)
cfgfile = box.cfgfile
newhostname = box.hostname
if not cfgfile:
template = CONFIG_TEMPLATE
if cfgfile:
if box.hwtype != 'apu':
try:
typ = config.get('BOX', {}).get('type')
if typ:
box.typ = typ
except ValueError:
pass
else:
if box.typ == 'cm4':
print('WARNING: missing type in BOX section')
print('This is a cm4 so guess its a dual-eth-rpi')
box.typ = 'dual-eth-rpi'
elif box.typ == 'cm3':
print('WARNING: missing type in BOX section')
print('This is a cm3 so guess its a ionopimax')
box.typ = 'ionopimax'
else:
raise ValueError(f'unknown type={typ} defined in BOX section')
else:
if box.typ == 'apu':
# determine if display is present
disp = serial.Serial('/dev/ttyS1', baudrate=115200, timeout=1)
disp.write(b'\x1b\x1b\x01\xf3')
display_available = disp.read(8)[0:4] == b'\x1b\x1b\x05\xf3'
if display_available:
typ = 'controlbox'
template = CONTROLBOX_TEMPLATE
box.typ = 'controlbox'
else:
typ = 'bare apu'
else:
typ = 'rpi'
print('no cfg file found for this', typ,
box.typ = 'bare-apu'
elif box.typ == 'cm4':
box.typ = 'dual-eth-rpi'
print('This is a cm4, so guess its a dual-eth-rpi - please check type in cfg file')
elif box.typ == 'cm3':
print('This is a cm3 so guess its a ionopimax - please check type in cfg file')
box.typ = 'ionopimax'
template = TEMPLATES.get(box.typ)
if template is None:
box.typ = box.typ or 'unknown-box'
template = GENERIC_TEMPLATE % box.typ
print('no cfg file found for this', box.typ,
f'with id {box.id:06x} (hostname={box.hostname})')
newhostname = input('enter host name: ')
if not newhostname:
@ -483,7 +547,7 @@ def handle_config():
newhostname = basename(cfgfile).rpartition('_')[0]
if doit:
print('bash sethostname.sh')
os.system(f'bash {TOOLS}/sethostname.sh')
unix_cmd('bash', f'{TOOLS}/sethostname.sh')
else:
if cfgfile:
print('replace host name %r by %r' % (box.hostname, newhostname))
@ -498,12 +562,14 @@ def handle_config():
netcfg = config['NETWORK']
for name in netcfg:
if name not in box.macaddr:
print(name)
print(box.macaddr)
print(f'{name} is not a valid network interface name')
raise RuntimeError('network interface name system does not match')
for ifname in box.macaddr:
content = create_if(ifname, netcfg.get(ifname, 'off'))
# content = '\n'.join('%s=%s' % kv for kv in content.items())
todo = write_when_new(f'/etc/network/interfaces.d/{ifname}', content)
todo = write_when_new(f'/etc/network/interfaces.d/{ifname}', content, as_root=True)
if todo and not more_info:
print('change', ifname)
show.dirty = True
@ -522,7 +588,7 @@ def handle_config():
content.append(' range %s %s;\n' % rng)
content.append('}\n')
content = ''.join(content)
todo = write_when_new('/etc/dhcp/dhcpd.conf', content)
todo = write_when_new('/etc/dhcp/dhcpd.conf', content, as_root=True)
if todo:
print('change dhcpd.conf')
to_start['isc-dhcp-server'] = 'restart'
@ -532,7 +598,7 @@ def handle_config():
r'INTERFACESv4="(.*)"', ' '.join([n for n in box.macaddr if n != box.main_if]))
except FileNotFoundError:
if 'yes'.startswith(input('install isc-dhcp-server? [y]')):
os.system('apt-get install isc-dhcp-server')
unix_cmd('apt-get install isc-dhcp-server')
exit()
elif doit:
unix_cmd('systemctl stop isc-dhcp-server')
@ -560,7 +626,7 @@ def handle_config():
to_start[service] = 'enable'
elif not active:
to_start[service] = 'restart'
if write_when_new(f'/etc/systemd/system/{service}.service', servicecfg):
if write_when_new(f'/etc/systemd/system/{service}.service', servicecfg, as_root=True):
show.dirty = True
reload_systemd = True
if servicecfg and to_start.get('service') is None:
@ -618,7 +684,7 @@ else:
doit = True
handle_config()
walk(Do())
with open(f'{TOOLS}/current', 'w') as f:
with open(TOOLS / 'current', 'w') as f:
f.write(result)
elif 'more'.startswith(answer.lower()):
more_info = True

51
to_dual-eth-rpi/boot/config.txt Executable file
View File

@ -0,0 +1,51 @@
# For more options and information see
# http://rptl.io/configtxt
# Some settings may impact device functionality. See link above for details
# Uncomment some or all of these to enable the optional hardware interfaces
dtparam=i2c_arm=off
#dtparam=i2s=on
#dtparam=spi=on
# Enable audio (loads snd_bcm2835)
dtparam=audio=on
# Additional overlays and parameters are documented
# /boot/firmware/overlays/README
# Automatically load overlays for detected cameras
camera_auto_detect=1
# Automatically load overlays for detected DSI displays
display_auto_detect=1
# Automatically load initramfs files, if found
auto_initramfs=1
# Enable DRM VC4 V3D driver
dtoverlay=vc4-kms-v3d
max_framebuffers=2
# Don't have the firmware create an initial video= setting in cmdline.txt.
# Use the kernel's default instead.
disable_fw_kms_setup=1
# Run in 64-bit mode
arm_64bit=1
# Disable compensation for displays with overscan
disable_overscan=1
# Run as fast as firmware / board allows
arm_boost=1
[cm4]
# Enable host mode on the 2711 built-in XHCI USB controller.
# This line should be removed if the legacy DWC2 controller is required
# (e.g. for USB device mode) or if USB support is not required.
otg_mode=1
[all]
# runtime clock
dtoverlay=mcp7941x

View File

@ -0,0 +1,2 @@
99-ionopimax.rules
99-ionopi.rules

51
to_ionopi/boot/config.txt Executable file
View File

@ -0,0 +1,51 @@
# For more options and information see
# http://rptl.io/configtxt
# Some settings may impact device functionality. See link above for details
# Uncomment some or all of these to enable the optional hardware interfaces
dtparam=i2c_arm=off
#dtparam=i2s=on
#dtparam=spi=on
# Enable audio (loads snd_bcm2835)
dtparam=audio=on
# Additional overlays and parameters are documented
# /boot/firmware/overlays/README
# Automatically load overlays for detected cameras
camera_auto_detect=1
# Automatically load overlays for detected DSI displays
display_auto_detect=1
# Automatically load initramfs files, if found
auto_initramfs=1
# Enable DRM VC4 V3D driver
dtoverlay=vc4-kms-v3d
max_framebuffers=2
# Don't have the firmware create an initial video= setting in cmdline.txt.
# Use the kernel's default instead.
disable_fw_kms_setup=1
# Run in 64-bit mode
arm_64bit=1
# Disable compensation for displays with overscan
disable_overscan=1
# Run as fast as firmware / board allows
arm_boost=1
[cm4]
# Enable host mode on the 2711 built-in XHCI USB controller.
# This line should be removed if the legacy DWC2 controller is required
# (e.g. for USB device mode) or if USB support is not required.
otg_mode=1
[all]
dtoverlay=ionopi
# runtime clock
dtoverlay=i2c-rtc,mcp7941x

View File

@ -0,0 +1 @@
SUBSYSTEM=="ionopi", PROGRAM="/bin/sh -c 'find -L /sys/class/ionopi/ -maxdepth 2 -exec chown root:ionopi {} \; || true'"

View File

@ -0,0 +1 @@
99-ionopimax.rules

51
to_ionopimax/boot/config.txt Executable file
View File

@ -0,0 +1,51 @@
# For more options and information see
# http://rptl.io/configtxt
# Some settings may impact device functionality. See link above for details
# Uncomment some or all of these to enable the optional hardware interfaces
dtparam=i2c_arm=off
#dtparam=i2s=on
#dtparam=spi=on
# Enable audio (loads snd_bcm2835)
dtparam=audio=on
# Additional overlays and parameters are documented
# /boot/firmware/overlays/README
# Automatically load overlays for detected cameras
camera_auto_detect=1
# Automatically load overlays for detected DSI displays
display_auto_detect=1
# Automatically load initramfs files, if found
auto_initramfs=1
# Enable DRM VC4 V3D driver
dtoverlay=vc4-kms-v3d
max_framebuffers=2
# Don't have the firmware create an initial video= setting in cmdline.txt.
# Use the kernel's default instead.
disable_fw_kms_setup=1
# Run in 64-bit mode
arm_64bit=1
# Disable compensation for displays with overscan
disable_overscan=1
# Run as fast as firmware / board allows
arm_boost=1
[cm4]
# Enable host mode on the 2711 built-in XHCI USB controller.
# This line should be removed if the legacy DWC2 controller is required
# (e.g. for USB device mode) or if USB support is not required.
otg_mode=1
[all]
dtoverlay=ionopimax
# runtime clock
dtoverlay=i2c-rtc,mcp7941x

View File

@ -0,0 +1 @@
SUBSYSTEM=="ionopimax", PROGRAM="/bin/sh -c 'find -L /sys/class/ionopimax/ -maxdepth 2 -exec chown root:ionopimax {} \; || true'"

View File

@ -0,0 +1 @@
99-ionopi.rules

View File

@ -3,6 +3,7 @@ import socket
import threading
import re
from glob import glob
from pathlib import Path
from configparser import ConfigParser
from netifaces import interfaces, ifaddresses, gateways, AF_INET, AF_LINK
from subprocess import Popen, PIPE
@ -19,13 +20,18 @@ else:
os.system(cmd)
class UndefinedConfigFile(Exception):
"""config file not found or ambiguous"""
class BoxInfo:
TOOLS = '/home/l_samenv/boxtools'
CFGPATH = f'{TOOLS}/cfg/%s_%06x.cfg'
TOOLS = Path('/home/l_samenv/boxtools')
CFGPATH = str(TOOLS / 'cfg' / '%s_%06x.cfg')
BOX_TYPES = {
'00:0d:b9': 'apu', # bare apu or control box
'b8:27:eb': 'cm3', # iono pi
'd8:3a:dd': 'cm4', # dual-eth-rpi
'b8:27:eb': 'cm3', # guess iono pi max
'e4:5f:01': 'ionopi', # guess iono pi
'd8:3a:dd': 'cm4', # guess dual-eth-rpi
}
def __init__(self):
@ -50,6 +56,7 @@ class BoxInfo:
self.id = int(''.join(addr.split(':')[-3:]), 16) & 0xffffff
self.typ = self.BOX_TYPES.get(addr[:8])
self.main_if = ifname
self.hwtype = self.typ # this is one of the values in BOX_TYPE and will not change
def get_macaddr(self):
return self.macaddr.get(self.main_if)
@ -57,9 +64,9 @@ class BoxInfo:
def read_config(self, section=None):
cfgfiles = glob(self.CFGPATH % ('*', self.id))
if len(cfgfiles) > 1:
raise ValueError('ambiguous cfgfile: %r' % cfgfiles)
raise AmbiguousConfigFile('ambiguous cfgfile: %r' % cfgfiles)
if section and not cfgfiles:
raise ValueError('no cfg file found for %s' % self.id)
raise UndefinedConfigFile('no cfg file found for %s' % self.id)
if cfgfiles:
self.cfgfile = cfgfiles[0]
else:
@ -141,14 +148,16 @@ class MainIf:
return self.carrier, self.ip, self.hostnameresult[0], self.gateway
def unix_cmd(command, execute=None, stdout=PIPE):
if execute != False: # None or True
def unix_cmd(cmd, *args, execute=None, stdout=PIPE, sudo=True):
command = cmd.split() + list(args)
sudo = ['sudo'] if sudo else []
if execute is not False: # None or True
if execute:
print('$ %s' % command)
result = Popen(command.split(), stdout=stdout).communicate()[0]
print('$', *command)
result = Popen(sudo + command, stdout=stdout).communicate()[0]
return (result or b'').decode()
else:
print('> %s' % command)
print('>', *command)
def check_service(service, set_on=None, execute=None):
@ -185,15 +194,12 @@ def change_firewall(set_on, ports, execute=None):
ports.add(22) # always add ssh
active, enabled = check_service('nftables')
if not set_on:
if os.geteuid() == 0:
check_service('nftables', False, execute)
else:
print('need sudo rights to modify firewall')
return active or enabled
pattern = re.compile('(tcp dport ({.*}) ct state new accept)', re.MULTILINE | re.DOTALL)
for filename in FIREWALL_CONF, os.path.join(BoxInfo.TOOLS, 'nftables.conf'):
for filename in FIREWALL_CONF, BoxInfo.TOOLS / 'nftables.conf':
with open(filename) as f:
content = f.read()