Repository: pcdshub/pytmc

Issue:
Support EPICS PV "link" for string values
One additional record will be required, unfortunately, to link up the waveform PV which writes to the PLC with the source PV:

`source -> lso -> waveform -> PLC`

Where `lso` the newish long string out record, that is.

This looks like:

```
record(waveform, "TO_PLC") {
  field(SCAN, ".5 second")
  field(DTYP, "asynInt8ArrayOut")
  field(INP, "@asyn($(PORT),0,1)ADSPORT=851/MAIN.fbLinkedValue2.sPLCInternalValue=")
  field(FTVL, "CHAR")
  field(NELM, "80")
}

record(lso, "LSO_HELPER") {
  field(DOL, "(SOURCE_PV) CPP MS")
  field(OMSL, "closed_loop")
  field(OUT, "TO_PLC PP MS")
  field(TPRO, 1)
}
```

80, just as a reminder: `If no size is specified, TwinCAT assumes 80 characters by default.`
This could be up to 255, but given EPICS's string limitations I don't really think supporting beyond 80 is really necessary to start with.

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 0def5d3..e7a6f17 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -18,7 +18,7 @@ env:
 
 jobs:
   allow_failures:
-    - name: "Python 3.6 - PIP"
+    - name: "Python 3.8 - PIP"
 
 after_failure:
   - cat logs/run_tests_log.txt
diff --git a/pytmc/record.py b/pytmc/record.py
index 84500d7..5451552 100644
--- a/pytmc/record.py
+++ b/pytmc/record.py
@@ -357,6 +357,9 @@ class TwincatTypeRecordPackage(RecordPackage):
     output_only_fields = {'DOL', 'IVOA', 'IVOV', 'OMSL'}
     archive_fields = ['VAL']
 
+    # Is an auxiliary record required to support existing record linking?
+    link_requires_record = False
+
     def __init_subclass__(cls, **kwargs):
         """Magic to have field_defaults be the combination of hierarchy"""
         super().__init_subclass__(**kwargs)
@@ -465,6 +468,35 @@ class TwincatTypeRecordPackage(RecordPackage):
 
         return record
 
+    def _get_omsl_fields(self):
+        """Get output mode select fields for the output record."""
+        if not self.linked_to_pv or self.linked_to_pv[-1] is None:
+            return {}
+
+        last_link = self.linked_to_pv[-1]
+        if last_link.startswith('*'):
+            # NOTE: A special, undocumented syntax for a lack of a better
+            # idea/more time:  need to allow pytmc to get access to a PV name
+            # it generates
+            # Consider this temporary API, only to be used in
+            # lcls-twincat-general for now.
+            pv_parts = list(self.config['pv'])
+            linked_to_pv = self.delimiter.join(
+                pv_parts[:-1] + [last_link.lstrip('*')]
+            )
+        else:
+            linked_to_pv = ''.join(
+                part for part in self.linked_to_pv
+                if part is not None
+            )
+
+        linked_to_pv = linked_to_pv.replace(self.macro_character, '$')
+        return {
+            'OMSL': 'closed_loop',
+            'DOL': linked_to_pv + ' CPP MS',
+            'SCAN': self.config.get('link_scan', '.5 second'),
+        }
+
     def generate_output_record(self):
         """
         Generate the record to write values back to the PLC
@@ -495,26 +527,11 @@ class TwincatTypeRecordPackage(RecordPackage):
         record.fields.pop('TSE', None)
         record.fields.pop('PINI', None)
 
-        if self.linked_to_pv and self.linked_to_pv[-1] is not None:
-            record.fields['OMSL'] = 'closed_loop'
-
-            last_link = self.linked_to_pv[-1]
-            if last_link.startswith('*'):
-                # NOTE: A special, undocumented syntax for a lack of a better
-                # idea/more time:  need to allow pytmc to get access to a PV
-                # name it generates
-                # Consider this temporary API, only to be used in
-                # lcls-twincat-general for now.
-                pv_parts = list(self.config['pv'])
-                linked_to_pv = ':'.join(pv_parts[:-1] +
-                                        [last_link.lstrip('*')])
-            else:
-                linked_to_pv = ''.join([part for part in self.linked_to_pv
-                                        if part is not None])
-
-            linked_to_pv = linked_to_pv.replace(self.macro_character, '$')
-            record.fields['DOL'] = linked_to_pv + ' CPP MS'
-            record.fields['SCAN'] = self.config.get('link_scan', '.5 second')
+        # Add on OMSL fields, if this is linked to an existing record.
+        # Some links (such as strings) may require auxiliary records, so
+        # don't repurpose the output record in that case.
+        if not self.link_requires_record:
+            record.fields.update(self._get_omsl_fields())
 
         # Update with given pragma fields - ignoring input-only fields:
         user_fields = self.config.get('field', {})
@@ -735,7 +752,15 @@ class StringRecordPackage(TwincatTypeRecordPackage):
     input_rtyp = 'waveform'
     output_rtyp = 'waveform'
     dtyp = 'asynInt8'
-    field_defaults = {'FTVL': 'CHAR'}
+    field_defaults = {
+        'FTVL': 'CHAR',
+        'APST': 'On Change',
+        'MPST': 'On Change',
+    }
+
+    # Links to string PVs require auxiliary 'lso' record.
+    link_requires_record = True
+    link_suffix = "LSO"
 
     @property
     def nelm(self):
@@ -756,6 +781,38 @@ class StringRecordPackage(TwincatTypeRecordPackage):
         record.fields['NELM'] = self.nelm
         return record
 
+    def generate_link_record(self):
+        """An auxiliary 'lso' link record to pass string PVs to the PLC."""
+        record = EPICSRecord(
+            self.delimiter.join((self.pvname, self.link_suffix)),
+            record_type="lso",
+            direction="output",
+            package=self,
+        )
+        record.fields.pop('TSE', None)
+        record.fields.pop('PINI', None)
+        record.fields["SIZV"] = self.nelm
+
+        # Add our port
+        record.fields.update(
+            self._get_omsl_fields()
+        )
+        record.fields['OUT'] = f"{self.pvname} PP MS"
+        _update_description(record, f"Aux link record for {self.chain.tcname}")
+        return record
+
+    @property
+    def records(self):
+        """All records that will be created in the package"""
+        records = [self.generate_input_record()]
+        link_fields = self._get_omsl_fields()
+        if self.io_direction == 'output' or link_fields:
+            records.append(self.generate_output_record())
+            if link_fields and self.link_requires_record:
+                records.append(self.generate_link_record())
+
+        return records
+
 
 DATA_TYPES = {
     'BOOL': BinaryRecordPackage,


Test patch:
diff --git a/pytmc/tests/test_xml_collector.py b/pytmc/tests/test_xml_collector.py
index d05f52a..ce532b8 100644
--- a/pytmc/tests/test_xml_collector.py
+++ b/pytmc/tests/test_xml_collector.py
@@ -521,6 +521,29 @@ def test_pv_linking():
     assert rec.fields['SCAN'] == '.5 second'
 
 
+def test_pv_linking_string():
+    item = make_mock_twincatitem(
+        name='Main.tcname', data_type=make_mock_type('STRING', length=70),
+        pragma='pv: PVNAME; link: OTHER:RECORD.VAL$')
+
+    pkg, = list(pragmas.record_packages_from_symbol(item))
+    assert pkg.pvname == 'PVNAME'
+    assert pkg.tcname == 'Main.tcname'
+    assert isinstance(pkg, StringRecordPackage)
+
+    in_rec, out_rec, lso_rec = pkg.records
+    assert "OMSL" not in out_rec.fields
+    assert "DOL" not in out_rec.fields
+
+    lso_pvname = pkg.delimiter.join((out_rec.pvname, pkg.link_suffix))
+    assert lso_rec.pvname == lso_pvname
+    assert lso_rec.record_type == "lso"
+    assert lso_rec.fields["OMSL"] == "closed_loop"
+    assert lso_rec.fields["DOL"] == "OTHER:RECORD.VAL$ CPP MS"
+    assert lso_rec.fields["SCAN"] == ".5 second"
+    assert lso_rec.fields["SIZV"] == 70
+
+
 def test_pv_linking_special():
     struct = make_mock_twincatitem(
         name='Main.array_base',