diff --git a/README.md b/README.md index 6de3df4..837d8af 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ The purpose of Grott is to read, parse and forward the *raw metrics as they are ### New in Version 2.8 * Added first SPA support (2.8.1) * Added first MIN support (2.8.2) +* Merged with master (2.8.3) -### New in Version 2.7 -* Added first beta of **grottserver** to act as destination for inverter/datalogger data (remove need to cummunicate with internet). - - grottserver is able to sent read/write register commands to inverter and datalogger. - - see https://github.com/johanmeijer/grott/wiki/Grottserver and discussions https://github.com/johanmeijer/grott/discussions/98 for more information: +### New in Version 2.7.8 +* Added first beta of grottserver to act as destination for inverter/datalogger data (remove need to cummunicate with internet). + - grottserver version 0.0.5 is able to sent read/write register commands to inverter and datalogger. + - see discussions (#98) for more information: https://github.com/johanmeijer/grott/discussions/98 * Support for SDM630/Raillog connected (see issue #88) * Support for SDM630/Inverter (modbus) connected 3 phases support * Export to CSV file (see issue #79, pull request #91). @@ -40,12 +41,7 @@ The purpose of Grott is to read, parse and forward the *raw metrics as they are * Added option to add inverter serial to MQTT topic (thanks to @ebosveld) - Add mqttinverterintopic = True to MQTT section of grott.ini or use qmqttinverterintopic = "True" environmental (e.g. docker). -### planned in Version 2.7.x (not commited yet) -* Auto detect for SPF, SPH, TL3 inverters -* Improved / configurable PVOutput support -* MQTT Retain message support -* Enhanced record layout for SPH -* tbd +### For all changes see: https://github.com/johanmeijer/grott/blob/Master-(2.7.8)/Version_history.txt ### Two modes of metric data retrieval Grott can intercept the inverter metrics in two distinct modes: diff --git a/examples/Extensions/grottext.py b/examples/Extensions/grottext.py index 781f182..70b6a8d 100644 --- a/examples/Extensions/grottext.py +++ b/examples/Extensions/grottext.py @@ -13,7 +13,7 @@ def grottext(conf,data,jsonmsg) : print("\t - " + "Grott extension module entered ") ### - ### uncomment this print statements if you want to see the information that is availble. + ### uncomment this print statements if you want to see the information that is available. ### #print(jsonmsg) @@ -21,8 +21,11 @@ def grottext(conf,data,jsonmsg) : #print(dir(conf)) #print(conf.extvar) - url = "http://" + conf.extvar["ip"] + ":" + str(conf.extvar["port"]) - + if "url" in conf.extvar: + url = conf.extvar["url"] + else: + url = f"http://{conf.extvar['ip']}:{conf.extvar['port']}" + try: r = requests.post(url, json = jsonmsg) diff --git a/examples/Home Assistent/grott_ha.py b/examples/Home Assistent/grott_ha.py index 9186b9d..2a201e8 100644 --- a/examples/Home Assistent/grott_ha.py +++ b/examples/Home Assistent/grott_ha.py @@ -8,18 +8,25 @@ from grottconf import Conf -__version__ = "0.0.7-rc2" +__version__ = "0.0.7-rc6" """A pluging for grott This plugin allow to have autodiscovery of the device in HA Should be able to support multiples inverters +Version 0.0.7 + - Corrected a bug when creating the configuration + - Add QoS 1 to reduce the possibility of lost message. + - Updated Total work time unit. + - Add support for setting the retain flag + Config: - ha_mqtt_host (required): The host of the MQTT broker user by HA (often the IP of HA) - ha_mqtt_port (required): The port (the default is oftent 1883) - ha_mqtt_user (optional): The user use to connect to the broker (you can use your user) - ha_mqtt_password (optional): The password to connect to the mqtt broket (you can use your password) + - ha_mqtt_retain (optional): Set the retain flag for the data message (default: False) Return codes: - 0: Everything is OK @@ -241,7 +248,7 @@ "totworktime": { "name": "Working time", "device_class": "duration", - "unit_of_measurement": "hours", + "unit_of_measurement": "h", "value_template": "{{ value_json.totworktime| float / 7200 | round(2) }}", }, "pvtemperature": { @@ -491,6 +498,12 @@ }, } +MQTT_HOST_CONF_KEY = "ha_mqtt_host" +MQTT_PORT_CONF_KEY = "ha_mqtt_port" +MQTT_USERNAME_CONF_KEY = "ha_mqtt_user" +MQTT_PASSWORD_CONF_KEY = "ha_mqtt_password" +MQTT_RETAIN_CONF_KEY = "ha_mqtt_retain" + def make_payload(conf: Conf, device: str, name: str, key: str, unit: str = None): # Default configuration payload @@ -514,14 +527,13 @@ def make_payload(conf: Conf, device: str, name: str, key: str, unit: str = None) layout = conf.recorddict[conf.layout] if "value_template" not in payload and key in layout: # From grottdata:207, default type is num, also process numx - if layout[key].get("type", "num") in ("num", "numx") and layout[key].get( - "divide", "1" - ): + if layout[key].get("type", "num") in ("num", "numx"): + divider = layout[key].get("divide", "1") payload[ "value_template" ] = "{{{{ value_json.{key} | float / {divide} }}}}".format( key=key, - divide=layout[key].get("divide"), + divide=divider, ) if "value_template" not in payload: @@ -545,29 +557,29 @@ def set_configured(cls, serial: str): def process_conf(conf: Conf): required_params = [ - "ha_mqtt_host", - "ha_mqtt_port", + MQTT_HOST_CONF_KEY, + MQTT_PORT_CONF_KEY, ] if not all([param in conf.extvar for param in required_params]): print("Missing configuration for ha_mqtt") raise AttributeError - if "ha_mqtt_user" in conf.extvar: + if MQTT_USERNAME_CONF_KEY in conf.extvar: auth = { - "username": conf.extvar["ha_mqtt_user"], - "password": conf.extvar["ha_mqtt_password"], + "username": conf.extvar[MQTT_USERNAME_CONF_KEY], + "password": conf.extvar[MQTT_PASSWORD_CONF_KEY], } else: auth = None # Need to convert the port if passed as a string - port = conf.extvar["ha_mqtt_port"] + port = conf.extvar[MQTT_PORT_CONF_KEY] if isinstance(port, str): port = int(port) return { "client_id": MqttStateHandler.client_name, "auth": auth, - "hostname": conf.extvar["ha_mqtt_host"], + "hostname": conf.extvar[MQTT_HOST_CONF_KEY], "port": port, } @@ -586,8 +598,8 @@ def grottext(conf: Conf, data: str, jsonmsg: str): """Allow to push to HA MQTT bus, with auto discovery""" required_params = [ - "ha_mqtt_host", - "ha_mqtt_port", + MQTT_HOST_CONF_KEY, + MQTT_PORT_CONF_KEY, ] if not all([param in conf.extvar for param in required_params]): print("Missing configuration for ha_mqtt") @@ -615,7 +627,9 @@ def grottext(conf: Conf, data: str, jsonmsg: str): conf, "layout", None ): configs_payloads = [] - print(f"\tGrott HA {__version__} - creating {device_serial} config in HA") + print( + f"\tGrott HA {__version__} - creating {device_serial} config in HA, {len(values.keys())} to push" + ) for key in values.keys(): # Generate a configuration payload payload = make_payload(conf, device_serial, key, key) @@ -634,6 +648,7 @@ def grottext(conf: Conf, data: str, jsonmsg: str): "topic": topic, "payload": json.dumps(payload), "retain": True, + "qos": 1, } ) except Exception as e: @@ -657,6 +672,7 @@ def grottext(conf: Conf, data: str, jsonmsg: str): "topic": topic, "payload": json.dumps(payload), "retain": True, + "qos": 1, } ) except Exception as e: @@ -664,7 +680,9 @@ def grottext(conf: Conf, data: str, jsonmsg: str): f"\t - [grott HA] {__version__} Exception while creating new sensor last push: {e}" ) return 4 + print(f"\tPushing {len(configs_payloads)} configurations payload to HA") publish_multiple(conf, configs_payloads) + print(f"\tConfigurations pushed") # Now it's configured, no need to come back MqttStateHandler.set_configured(device_serial) @@ -672,13 +690,65 @@ def grottext(conf: Conf, data: str, jsonmsg: str): print(f"\t[Grott HA] {__version__} Can't configure device: {device_serial}") return 7 - # Push the vales to the topics + # Push the values to the topic + retain = conf.extvar.get(MQTT_RETAIN_CONF_KEY, False) try: publish_single( - conf, state_topic.format(device=device_serial), json.dumps(values) + conf, + state_topic.format(device=device_serial), + json.dumps(values), + retain=retain, ) except Exception as e: print("[HA ext] - Exception while publishing - {}".format(e)) # Reset connection state in case of problem return 2 return 0 + + +def test_generate_payload(): + "Test that an auto generated payload for MQTT configuration" + + class TestConf: + recorddict = { + "test": { + "pvpowerout": {"value": 122, "length": 4, "type": "num", "divide": 10} + } + } + layout = "test" + + payload = make_payload(TestConf(), "NCO7410", "pvpowerout", "pvpowerout") + print(payload) + # The default divider for pvpowerout is 10 + assert payload["value_template"] == "{{ value_json.pvpowerout | float / 10 }}" + assert payload["name"] == "NCO7410 PV Output (Actual)" + assert payload["unique_id"] == "grott_NCO7410_pvpowerout" + assert payload["state_class"] == "measurement" + assert payload["device_class"] == "power" + assert payload["unit_of_measurement"] == "W" + + +def test_generate_payload_without_divider(): + "Test that an auto generated payload for MQTT configuration" + + class TestConf: + recorddict = { + "test": { + "pvpowerout": { + "value": 122, + "length": 4, + "type": "num", + } + } + } + layout = "test" + + payload = make_payload(TestConf(), "NCO7410", "pvpowerout", "pvpowerout") + print(payload) + # The default divider for pvpowerout is 10 + assert payload["value_template"] == "{{ value_json.pvpowerout | float / 1 }}" + assert payload["name"] == "NCO7410 PV Output (Actual)" + assert payload["unique_id"] == "grott_NCO7410_pvpowerout" + assert payload["state_class"] == "measurement" + assert payload["device_class"] == "power" + assert payload["unit_of_measurement"] == "W" diff --git a/examples/Home Assistent/sensors_growatt_eng.yaml b/examples/Home Assistent/mqtt_growatt_eng.yaml similarity index 73% rename from examples/Home Assistent/sensors_growatt_eng.yaml rename to examples/Home Assistent/mqtt_growatt_eng.yaml index 5a3fa85..d9fe3be 100644 --- a/examples/Home Assistent/sensors_growatt_eng.yaml +++ b/examples/Home Assistent/mqtt_growatt_eng.yaml @@ -3,42 +3,13 @@ # This file exposes all sensors from Grott to HA, including dummy sensors for the type of the inverter and the type and serial number of the datalogger (Be aware, the dummy # sensors have to be set manually) -- platform: template - sensors: - growatt_inverter: - unique_id: growatt_invertertype - friendly_name: Growatt - Type - # Please set the type of your inverter - value_template: "MIN 4200TL-XE" - icon_template: mdi:select-inverse - -- platform: template - sensors: - growatt_datalogger_type: - unique_id: growatt_datloggertype - friendly_name: Growatt - Datalogger type - # Please set the type of your datalogger - value_template: "ShineLink X" - icon_template: mdi:select-inverse - -- platform: template - sensors: - growatt_datalogger_serial: - unique_id: growatt_datlogger_serial - friendly_name: Growatt - Datalogger serienr - # Please set the serial number of your datalogger - value_template: " XXX1X23456" - icon_template: mdi:select-inverse - -- platform: mqtt +- name: Growatt - Serial number state_topic: energy/growatt value_template: "{{ value_json['device'] }}" unique_id: growatt_serial - name: Growatt - Serial number icon: mdi:select-inverse -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt # If you like to have the date in another format, please change "timestamp_custom('%d-%m-%Y')" # For more information: https://docs.python.org/3/library/time.html#time.strftime value_template: "{{ as_timestamp(strptime(value_json['time'], '%Y-%m-%dT%H:%M:%S')) | timestamp_custom('%d-%m-%Y') }}" @@ -46,8 +17,7 @@ name: Growatt - Date icon: mdi:calendar -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt # If you like to have the date in another format, please change "timestamp_custom('%H:%M:%S')" # For more information: https://docs.python.org/3/library/time.html#time.strftime value_template: "{{ as_timestamp(strptime(value_json['time'], '%Y-%m-%dT%H:%M:%S')) | timestamp_custom('%H:%M:%S') }}" @@ -55,8 +25,7 @@ name: Growatt - Time icon: mdi:clock-digital -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: > {% if (value_json['values']['pvstatus'] | int == 0) %} Waiting @@ -71,88 +40,77 @@ name: Growatt - State icon: mdi:power-settings -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pv1watt'] | float / 10000 }}" unique_id: growatt_string1_watt device_class: power unit_of_measurement: "kW" name: Growatt - String 1 (kiloWatt) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pv1voltage'] | float / 10 }}" unique_id: growatt_string1_voltage device_class: voltage unit_of_measurement: "V" name: Growatt - String 1 (Voltage) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pv1current'] | float / 10 }}" unique_id: growatt_string1_current device_class: current unit_of_measurement: "A" name: Growatt - String 1 (Current) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pv2watt'] | float / 10000 }}" unique_id: growatt_string2_watt device_class: power unit_of_measurement: "kW" name: Growatt - String 2 (kiloWatt) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pv2voltage'] | float / 10 }}" unique_id: growatt_string2_voltage device_class: voltage unit_of_measurement: "V" name: Growatt - String 2 (Voltage) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pv2current'] | float / 10 }}" unique_id: growatt_string2_current device_class: current unit_of_measurement: "A" name: Growatt - String 2 (Current) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvpowerin'] | float / 10000 }}" unique_id: growatt_actual_input_power device_class: power unit_of_measurement: "kW" name: Growatt - Input kiloWatt (Actual) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvpowerout'] | float / 10000 }}" unique_id: growatt_actual_output_power device_class: power unit_of_measurement: "kW" name: Growatt - Output kiloWatt (Actual) -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvfrequentie'] | float / 100 }}" unique_id: growatt_grid_frequency unit_of_measurement: "Hz" name: Growatt - Grid frequency icon: mdi:waveform -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvgridvoltage'] | float / 10 }}" unique_id: growatt_phase_voltage device_class: voltage unit_of_measurement: "V" name: Growatt - Phase voltage -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvenergytoday'] | float / 10 }}" unique_id: growatt_generated_energy_today device_class: energy @@ -160,8 +118,7 @@ name: Growatt - Generated energy (Today) icon: mdi:solar-power -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvenergytotal'] | float / 10 }}" unique_id: growatt_generated_energy_total device_class: energy @@ -170,8 +127,7 @@ name: Growatt - Generated energy (Total) icon: mdi:solar-power -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvtemperature'] | float / 10 }}" unique_id: growatt_inverer_temperature device_class: temperature @@ -180,8 +136,7 @@ # The entity below is not available in all inverters. -- platform: mqtt - state_topic: energy/growatt +- state_topic: energy/growatt value_template: "{{ value_json['values']['pvipmtemperature'] | float / 10 }}" unique_id: growatt_ipm_temperature device_class: temperature diff --git a/examples/Home Assistent/template_growatt_eng.yaml b/examples/Home Assistent/template_growatt_eng.yaml new file mode 100644 index 0000000..9258ad9 --- /dev/null +++ b/examples/Home Assistent/template_growatt_eng.yaml @@ -0,0 +1,22 @@ +# Grott - Home Assistant Growatt sensors +# +# This file exposes all sensors from Grott to HA, including dummy sensors for the type of the inverter and the type and serial number of the datalogger (Be aware, the dummy +# sensors have to be set manually) + +- name: Growatt - Type + unique_id: growatt_invertertype + # Please set the type of your inverter + state: "SPH6000" + icon: mdi:select-inverse + +- name: Growatt - Datalogger type + unique_id: growatt_datloggertype + # Please set the type of your datalogger + state: "ShineWifi S" + icon: mdi:select-inverse + +- name: Growatt - Datalogger serial + unique_id: growatt_datlogger_serial + # Please set the serial number of your datalogger + state: "ABCDEFG" + icon: mdi:select-inverse diff --git a/examples/grott.ini b/examples/grott.ini index 632dcb7..d5e4ae0 100644 --- a/examples/grott.ini +++ b/examples/grott.ini @@ -19,9 +19,8 @@ #port = 5279 # To blocks commands from outside (to channge inverter and shine devices settings) specify blockcmd = True, -# specify noipf = True if you still want be able to dest ip addres from growatt server -# Specify noipf = True if you still want be able to dest ip addres from growatt server (advice only to use -# this for a short time) +# Specify noipf = True if you still want be able to set the destination ip addres from growatt server (advice +# only to use this for a short time) #blockcmd = True #noipf = True @@ -106,4 +105,4 @@ #extension = True #extname = grottext -#extvar = {"var1": "var1_content", "var2": "var2_content"} \ No newline at end of file +#extvar = {"var1": "var1_content", "var2": "var2_content"} diff --git a/grottconf.py b/grottconf.py index ade116f..27e3592 100644 --- a/grottconf.py +++ b/grottconf.py @@ -1,7 +1,7 @@ # # grottconf process command parameter and settings file -# Updated: 2023-03-17 -# Version 2.8.2 +# Updated: 2022-08-26 +# Version 2.7.6 import configparser, sys, argparse, os, json, io import ipaddress @@ -850,7 +850,7 @@ def set_reclayouts(self): "pdischarge1" : {"value" :702, "length" : 4, "type" : "num", "divide" : 10}, "p1charge1" : {"value" :710, "length" : 4, "type" : "num", "divide" : 10}, "vbat" : {"value" :718, "length" : 2, "type" : "num", "divide" : 10}, - "SOC" : {"value" :722, "length" : 2, "type" : "num", "divide" : 1}, + "SOC" : {"value" :722, "length" : 2, "type" : "num", "divide" : 1}, "pactouserr" : {"value" :726, "length" : 4, "type" : "num", "divide" : 10}, "#pactousers" : {"value" :734, "length" : 4, "type" : "num", "divide" : 10,"incl" : "no"}, "#pactousert" : {"value" :742, "length" : 4, "type" : "num", "divide" : 10,"incl" : "no"}, @@ -1230,8 +1230,8 @@ def set_reclayouts(self): "pdischarge1" : {"value" :622, "length" : 4, "type" : "num", "divide" : 10}, "p1charge1" : {"value" :630, "length" : 4, "type" : "num", "divide" : 10}, "vbat" : {"value" :738, "length" : 2, "type" : "num", "divide" : 10}, - "SOC" : {"value" :742, "length" : 2, "type" : "num", "divide" : 1}, - "pactouserr" : {"value" :746, "length" : 4, "type" : "num", "divide" : 10}, + "SOC" : {"value" :742, "length" : 2, "type" : "num", "divide" : 100}, + "pactouserr" : {"value" :746, "length" : 4, "type" : "num", "divide" : 10}, "#pactousers" : {"value" :654, "length" : 4, "type" : "num", "divide" : 10,"incl" : "no"}, "#pactousert" : {"value" :662, "length" : 4, "type" : "num", "divide" : 10,"incl" : "no"}, "pactousertot" : {"value" :670, "length" : 4, "type" : "num", "divide" : 10}, diff --git a/grottproxy.py b/grottproxy.py index 2b03290..7ed6a32 100644 --- a/grottproxy.py +++ b/grottproxy.py @@ -104,6 +104,11 @@ def __init__(self, conf): ## to resolve errno 32: broken pipe issue (Linux only) if sys.platform != 'win32': signal(SIGPIPE, SIG_DFL) + + self.create_proxy(conf) + + def create_proxy(self, conf): + print(f"Creating socket on {conf.grottport}") ## self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -119,29 +124,51 @@ def __init__(self, conf): print("IP and port information not available") self.server.listen(200) - self.forward_to = (conf.growattip, conf.growattport) + self.forward_to = (conf.growattip, conf.growattport) + self.socketState = 0 # Initialize socket state to 0 (no timeout recovery on startup) + + def restart_proxy(self, conf): + # Close the socket if it's in the list + if self.server in self.input_list: + self.input_list.remove(self.server) + self.server.close() + self.create_proxy(conf) + def main(self,conf): self.input_list.append(self.server) while 1: time.sleep(delay) - ss = select.select - inputready, outputready, exceptready = ss(self.input_list, [], []) - for self.s in inputready: - if self.s == self.server: - self.on_accept(conf) - break - try: - self.data, self.addr = self.s.recvfrom(buffer_size) - except: - if conf.verbose : print("\t - Grott connection error") - self.on_close(conf) - break - if len(self.data) == 0: - self.on_close(conf) - break - else: - self.on_recv(conf) + # 300 : timeout in seconds, since the default is 5 minutes, we can expect some data every 5 minutes, if not, retry later. + inputready, outputready, exceptready = select.select(self.input_list, [], [], 300) + + # Handle the socket exceptions + for self.s in exceptready: + try: + print("\t - Socket Exception received, closing it."), + restart_proxy(conf) + except socket.error as e: + print(f"\t Error while handling exception for Socket, exception: {e}") + + if not inputready: + if conf.verbose : self.handle_socket_timeout() + else: + if conf.verbose : self.handle_socket_reconnect() + for self.s in inputready: + if self.s == self.server: + self.on_accept(conf) + break + try: + self.data, self.addr = self.s.recvfrom(buffer_size) + except: + if conf.verbose : print("\t - Grott connection error") + self.on_close(conf) + break + if len(self.data) == 0: + self.on_close(conf) + break + else: + self.on_recv(conf) def on_accept(self,conf): forward = Forward().start(self.forward_to[0], self.forward_to[1]) @@ -238,4 +265,18 @@ def on_recv(self,conf): procdata(conf,data) else: if conf.verbose: print("\t - " + 'Data less then minimum record length, data not processed') - \ No newline at end of file + + def handle_socket_timeout(self): + if self.socketState == 0: + self.socketState = 1 + print("\t - Grott proxy socket timeout occurred") + elif self.socketState == 1: + self.socketState = 2 + print("\t - Grott inverter seems to be asleep, goodnight") + + def handle_socket_reconnect(self): + if self.socketState == 1: + print("\t - Grott proxy socket reconnected after timeout") + elif self.socketState == 2: + print("\t - Grott inverter woke up, good morning") + self.socketState = 0 \ No newline at end of file diff --git a/grottwrap.sh b/grottwrap.sh new file mode 100644 index 0000000..d92859f --- /dev/null +++ b/grottwrap.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# turn on bash's job control +#set -m + +#python3 -u grott.py -v & + +# Start the helper process +python3 -u grottserver.py -v & + +python3 -u grott.py -v + +# the my_helper_process might need to know how to wait on the +# primary process to start before it does its work and returns + + +# Wait for any process to exit +wait + +# Exit with status of process that exited first +exit $? + +# now we bring the primary process back into the foreground +# and leave it there +#fg %1