diff --git a/.github/workflows/build_pycoax.yml b/.github/workflows/build_pycoax.yml index 6fdd252..3e9053c 100644 --- a/.github/workflows/build_pycoax.yml +++ b/.github/workflows/build_pycoax.yml @@ -18,7 +18,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt --no-deps + pip install -r requirements.txt working-directory: ./pycoax - name: Run unit tests run: ./run_unit_tests.sh diff --git a/README.md b/README.md index 7bf7644..dad40ff 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # coax -> Hello mainframe! - Tools for interfacing with [IBM 3270](https://en.wikipedia.org/wiki/IBM_3270) type terminals. ## Contents -* [pycoax](pycoax) - Python IBM 3270 coaxial interface library +* [protocol](protocol/protocol.md) - Protocol documentation * [interface1](interface1) - A serial attached Arduino interface using the National Semiconductor DP8340 and DP8341 +* [pycoax](pycoax) - Python interface library ## See Also -* [oec](https://github.com/lowobservable/oec) - An open replacement for the IBM 3174 Establishment Controller +* [oec](https://github.com/lowobservable/oec) - IBM 3270 terminal controller (a replacement for the IBM 3174) diff --git a/interface1/README.md b/interface1/README.md index 48e850d..95ca69a 100644 --- a/interface1/README.md +++ b/interface1/README.md @@ -2,11 +2,9 @@ A serial attached Arduino interface using the National Semiconductor DP8340 and DP8341. -## Schematic +## Hardware -![Schematic](hardware/schematic.svg) - -## Bill of Materials +You can find the Gerber files for fabricating a PCB in the [fabrication](hardware/fabrication) directory. I have used JLCPCB to make the PCBs. This interface requires an [Arduino Mega 2560 R3](https://store.arduino.cc/usa/mega-2560-r3). @@ -31,3 +29,17 @@ This interface requires an [Arduino Mega 2560 R3](https://store.arduino.cc/usa/m ## Firmware The firmware currently provides the ability to send commands and receive responses - it is designed to implement a terminal controller, not a terminal. + +You will need [PlatformIO](https://platformio.org/) to build and upload the firmware, only Platform IO Core (the CLI) is required. + +To build and upload the firmware for an Arduino Mega 2560 R3: + +``` +platformio run -t upload -e mega2560 +``` + +For an original Arduino Mega: + +``` +platformio run -t upload -e mega1280 +``` diff --git a/interface1/firmware/platformio.ini b/interface1/firmware/platformio.ini index a6dd2ab..95622f9 100644 --- a/interface1/firmware/platformio.ini +++ b/interface1/firmware/platformio.ini @@ -1,6 +1,13 @@ +[platformio] +default_envs = mega2560 + [env] framework = arduino -[env:megaatmega2560] +[env:mega1280] +platform = atmelavr +board = megaatmega1280 + +[env:mega2560] platform = atmelavr board = megaatmega2560 diff --git a/interface1/firmware/src/main.cpp b/interface1/firmware/src/main.cpp index 260db2a..5d025b7 100644 --- a/interface1/firmware/src/main.cpp +++ b/interface1/firmware/src/main.cpp @@ -64,11 +64,18 @@ void sendMessage(uint8_t *buffer, int bufferCount) Serial.flush(); } -void sendErrorMessage(uint8_t code) +void sendErrorMessage(uint8_t code, const char *description) { - uint8_t message[] = { 0x02, code }; + uint8_t message[2 + 62 + 1] = { 0x02, code }; + int count = 2; - sendMessage(message, 2); + if (description != NULL) { + strncpy((char *) (message + 2), description, 62); + + count += strlen(description); + } + + sendMessage(message, count); } #define COMMAND_RESET 0x01 @@ -86,7 +93,7 @@ void handleResetCommand(uint8_t *buffer, int bufferCount) void handleTransmitReceiveCommand(uint8_t *buffer, int bufferCount) { if (bufferCount < 6) { - sendErrorMessage(ERROR_INVALID_MESSAGE); + sendErrorMessage(ERROR_INVALID_MESSAGE, "HANDLE_TXRX_BUFFER_COUNT_6"); return; } @@ -100,7 +107,7 @@ void handleTransmitReceiveCommand(uint8_t *buffer, int bufferCount) uint16_t receiveTimeout = (buffer[bufferCount - 2] << 8) | buffer[bufferCount - 1]; if (transmitBufferCount < 1) { - sendErrorMessage(ERROR_INVALID_MESSAGE); + sendErrorMessage(ERROR_INVALID_MESSAGE, "HANDLE_TXRX_TX_BUFFER_COUNT_1"); return; } @@ -127,7 +134,7 @@ void handleTransmitReceiveCommand(uint8_t *buffer, int bufferCount) bufferCount = CoaxTransceiver::transmitReceive(transmitBuffer, transmitBufferCount, receiveBuffer, receiveBufferSize, receiveTimeout); if (bufferCount < 0) { - sendErrorMessage(100 + ((-1) * bufferCount)); + sendErrorMessage(100 + ((-1) * bufferCount), NULL); return; } @@ -142,7 +149,7 @@ void handleTransmitReceiveCommand(uint8_t *buffer, int bufferCount) void handleMessage(uint8_t *buffer, int bufferCount) { if (bufferCount < 1) { - sendErrorMessage(ERROR_INVALID_MESSAGE); + sendErrorMessage(ERROR_INVALID_MESSAGE, "HANDLE_MESSAGE_BUFFER_COUNT_1"); return; } @@ -153,21 +160,21 @@ void handleMessage(uint8_t *buffer, int bufferCount) } else if (command == COMMAND_TRANSMIT_RECEIVE) { handleTransmitReceiveCommand(buffer + 1, bufferCount - 1); } else { - sendErrorMessage(ERROR_UNKNOWN_COMMAND); + sendErrorMessage(ERROR_UNKNOWN_COMMAND, NULL); } } void handleFrame(uint8_t *buffer, int bufferCount) { if (bufferCount < 4) { - sendErrorMessage(ERROR_INVALID_MESSAGE); + sendErrorMessage(ERROR_INVALID_MESSAGE, "HANDLE_FRAME_BUFFER_COUNT_4"); return; } int count = (buffer[0] << 8) | buffer[1]; if (bufferCount - 4 != count) { - sendErrorMessage(ERROR_INVALID_MESSAGE); + sendErrorMessage(ERROR_INVALID_MESSAGE, "HANDLE_FRAME_BUFFER_COUNT_MISMATCH"); return; } diff --git a/interface1/hardware/schematic.svg b/interface1/hardware/schematic.svg deleted file mode 100644 index 37312a5..0000000 --- a/interface1/hardware/schematic.svg +++ /dev/null @@ -1,4866 +0,0 @@ - - - - - - image/svg+xml - - SVG Picture created as hardware.svg date 2019/10/07 21:17:45 - - - - - SVG Picture created as hardware.svg date 2019/10/07 21:17:45 - Picture generated by Eeschema-SVG - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/protocol/character-map.jpg b/protocol/character-map.jpg new file mode 100644 index 0000000..b2eab60 Binary files /dev/null and b/protocol/character-map.jpg differ diff --git a/protocol/data.svg b/protocol/data.svg new file mode 100644 index 0000000..2c535af --- /dev/null +++ b/protocol/data.svg @@ -0,0 +1 @@ +SyncBitParityBitData (10-bits)SyncBitParityBitData (10-bits)000001100001100011000110 \ No newline at end of file diff --git a/protocol/frame.svg b/protocol/frame.svg new file mode 100644 index 0000000..a4d51f4 --- /dev/null +++ b/protocol/frame.svg @@ -0,0 +1 @@ +A frame containing a single 10-bit word, in this case a read status command sent from acontroller to a terminal:00000111Start Sequence11111110EndSequenceSyncBitParityBitData (10-bits)CodeViolationCodeViolation0.42 µs01 \ No newline at end of file diff --git a/protocol/protocol.md b/protocol/protocol.md new file mode 100644 index 0000000..5b0679d --- /dev/null +++ b/protocol/protocol.md @@ -0,0 +1,255 @@ +# IBM 3270 Protocol + +This document describes the protocol used to communicate between +[IBM 3270](https://en.m.wikipedia.org/wiki/IBM_3270) +type terminals and IBM 3174 and 3274 controllers. + +If you are looking for information on the 3270 data stream (as used by TN3270) +I'd recommend the following resources: + + * IBM [3270 Data Stream Programmer's Reference](https://bitsavers.computerhistory.org/pdf/ibm/3270/GA23-0059-4_3270_Data_Stream_Programmers_Reference_Dec88.pdf) (GA23-0059-4) + * Greg Price's [3270 Programming Overview](http://www.prycroft6.com.au/misc/3270.html) + * Tommy Sprinkles' [3270 Data Stream Programming](https://www.tommysprinkle.com/mvs/P3270/start.htm) + +For background to the IBM 3270 type terminals and why I am documenting the +protocol, see +_[Building an IBM 3270 terminal controller](https://ajk.me/building-an-ibm-3270-terminal-controller)_. + +This document is a work in progress - I appreciate any +[feedback, corrections, or contributions](mailto:projects+3270@ajk.me). + +## Devices + +The 3270 family consists of the following devices: + + * Controller + * Multiplexer + * Display Station - CUT and DFT type terminals + * Printer + +## Physical Layer + +Devices are connected point-to-point by RG-62 coaxial cable with a +characteristic impedance of 93 Ω. Converters and baluns can convert the coaxial +cable to other media: + + * Twisted pair + * Optical fiber + * IBM Cabling System + +Differential signaling is used with the coax shield acting as a return. + +## Data Link Layer + +Data is sent at a bit rate of 2.3587 Mb/s in serial fashion using Machester +encoding. Frames begin and end with unique start and end sequences, both +contain a deliberate violation of the Manchester encoding scheme. + +![Diagram showing a 3270 protocol frame containing a single 10-bit word](frame.svg) + +A frame contains one or more 10-bit words. Each 10-bit word within a frame +starts with a sync bit and ends with an even parity bit - the parity +calculation includes the sync bit. Words are transmitted most significant bit +(MSB) first. + +![Diagram showing two 10-bit words within a 3270 protocol frame](data.svg) + +All communication is initiated by the controller when it sends a frame +containing a command word and optional data words. The attached device responds +with a frame containing one or more data words. + +### Words + +Except for the `POLL` command response, words are either: + + * Command - encapsulates a single 8-bit command byte, sent from a controller + to an attached device + * Data - encapsulates a single 8-bit data byte + * Transmission Turnaround (TR/TA) - sent as an acknowledgment of a command + when there is no response data + +| Bit | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +| ----------------------- |:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Command | _C_ | _C_ | _C_ | _C_ | _C_ | _C_ | _C_ | _C_ | `0` | `1` | +| Data | _D_ | _D_ | _D_ | _D_ | _D_ | _D_ | _D_ | _D_ | _P_ | `0` | +| Transmission Turnaround | `0` | `0` | `0` | `0` | `0` | `0` | `0` | `0` | `0` | `0` | + +Later devices populate an additional odd parity bit in position 1 (`P`) of +data words; earlier devices leave this `0`. + +See the `POLL` command section for a description of the `POLL` response words. + +## CUT Terminals + +Control Unit Terminal (CUT) type terminals depend on the controller to handle +the 3270 data stream. The controller converts the 3270 data stream commands, +orders, and attributes to commands and data recognized by the terminal. +Distributed Function Terminal (DFT) type terminals, unlike the CUT type, can +handle the 3270 data stream natively. + +A CUT type terminal consists of the following components considered part of the +base feature: + + * Display + * Keyboard + * Registers + +Optional features include an Extended Attribute Buffer (EAB), a selector pen, +and a magnetic stripe reader. + +### Display + +The display contents, including the status line, are stored in the regen +buffer - named because it is used to regenerate the displayed image. + +The size of the buffer depends on the model: + +| Model | Rows | Columns | Buffer Size | +|-------|-----:|--------:|------------:| +| 2 | 24 | 80 | 2000 | +| 3 | 32 | 80 | 2640 | +| 4 | 43 | 80 | 3520 | +| 5 | 27 | 132 | 3696 | + +_The row count above does not include the status line row, the buffer size does +include the status line._ + +Each byte in the regen buffer represents a cell on the display - the byte may +be a character or an attribute byte. Attribute bytes control the formatting of +subsequent cells and appear as a blank cell. + +The following operations can be performed on the regen buffer: + + * Read + * Write - overwrite and insert + * Search + * Clear + +The address counter register controls the starting location for these +operations - and also the cursor location. Addresses start at `0`, which +represents the start of the status line. The top-left cell address is equal to +the display width, `80` in the case of 80-column mode. + +#### Character Encoding + +The character encoding used to represent characters is unique to 3270 +terminals; it is not EBCDIC or ASCII. Values below `0xc0` represent displayable +characters. + +![Photograph of character map shown on an IBM 3483-V terminal](character-map.jpg) + +#### Attribute Bytes + +Attribute bytes control the formatting and capabilities of the characters that +follow and store state, such as the modified bit (indicating that the following +field has been modified). The attribute byte formatting wraps-around at the end +of the regen buffer, meaning an attribute byte in the last regen buffer cell +controls the formatting of characters beginning in the top-left cell. + +| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +| --- |:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| | `1` | `1` | _P_ | _N_ | _D_ | _D_ | `0` | _M_ | + +Bits that control the formatting: + +| Bits | Description | +| --------- | -------------------------------------------------- | +| 5 (`P`) | `0` - unprotected
`1` - protected | +| 3-2 (`D`) | `00` - normal, not detectable by selector pen
`01` - normal, detectable by selector pen
`10` - intense, detectable by selector pen
`11` - hidden, not detectable by selector pen | + +Bits that store state: + +| Bits | Description | +| --------- | -------------------------------------------------- | +| 4 (`N`) | `0` - alphanumeric
`1` - numeric | +| 0 (`M`) | `0` - unmodified
`1` - modified by the operator | + +An attribute byte appears as a blank cell on the display. Because the attribute +byte takes up space on the display this imposes limitations on screen design. + +#### Status Line + +The status line is shown at the bottom of the display. During normal operation, +the status line is managed by the controller, except for the cursor location +area (displayed on the right). The status line begins at address `0`. + +The status line does not support formatting. The space in the character +encoding used for attribute bytes (`0xc0` and above) is used to represent +symbols that are unique to the status line. + +![Photograph of status line character map shown on an IBM 3483-V terminal](status-line-character-map.jpg) + +#### Extended Attribute Buffer (EAB) + +The Extended Attribute Buffer (EAB) is an optional feature introduced with the +IBM 3279 terminal that provides more advanced formatting capabilities. It is +implemented as a second buffer that shadows the regen buffer; this buffer only +contains extended attribute bytes that control the formatting of the characters +in the regen buffer - it does not include any characters. + +### Keyboard + +Keypresses are stored in a FIFO buffer. If there are any keypresses, the scan +code of the first keypress is returned in response to a `POLL` command. + +Modifier keys such as _shift_ or _alt_ are treated as regular keypresses but +trigger a keypress on key down and key up (releasing the key). Depending on the +terminal model, the terminal may insert a synthetic release keypress (a unique +scan code) before the modifier scan code when a modifier key is released. + +### Registers + +Aside from the display and keyboard buffers, the terminal has the following +registers: + + * Terminal ID + * Extended ID + * Status + * Address Counter - 16-bit address split into 8-bit high and low registers + * Control + * Secondary Control + * Mask + * Keyboard Clicker State + +Registers can be read-only, write-only, or read-write. + +### Features + +### Commands + +| Feature | Command | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | Value | +| ------- | ------- |:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-----:| +| Base | `POLL` | _X_ | _X_ | `0` | `0` | `0` | `0` | `0` | `1` | `0` | `1` | `0x01` | +| Base | `POLL_ACK` | `0` | `0` | `0` | `1` | `0` | `0` | `0` | `1` | `0` | `1` | `0x11` | +| Base | `READ_STATUS` | `0` | `0` | `0` | `0` | `1` | `1` | `0` | `1` | `0` | `1` | `0x0d` | +| Base | `READ_TERMINAL_ID` | `0` | `0` | `0` | `0` | `1` | `0` | `0` | `1` | `0` | `1` | `0x09` | +| Base | `READ_EXTENDED_ID` | `0` | `0` | `0` | `0` | `0` | `1` | `1` | `1` | `0` | `1` | `0x07` | +| Base | `READ_ADDRESS_COUNTER_HI` | `0` | `0` | `0` | `0` | `0` | `1` | `0` | `1` | `0` | `1` | `0x05` | +| Base | `READ_ADDRESS_COUNTER_LO` | `0` | `0` | `0` | `1` | `0` | `1` | `0` | `1` | `0` | `1` | `0x15` | +| Base | `READ_DATA` | `0` | `0` | `0` | `0` | `0` | `0` | `1` | `1` | `0` | `1` | `0x03` | +| Base | `READ_MULTIPLE` | `0` | `0` | `0` | `0` | `1` | `0` | `1` | `1` | `0` | `1` | `0x0b` | +| Base | `RESET` | `0` | `0` | `0` | `0` | `0` | `0` | `1` | `0` | `0` | `1` | `0x02` | +| Base | `LOAD_CONTROL_REGISTER` | `0` | `0` | `0` | `0` | `1` | `0` | `1` | `0` | `0` | `1` | `0x0a` | +| Base | `LOAD_SECONDARY_CONTROL` | `0` | `0` | `0` | `1` | `1` | `0` | `1` | `0` | `0` | `1` | `0x1a` | +| Base | `LOAD_MASK` | `0` | `0` | `0` | `1` | `0` | `1` | `1` | `0` | `0` | `1` | `0x16` | +| Base | `LOAD_ADDRESS_COUNTER_HI` | `0` | `0` | `0` | `0` | `0` | `1` | `0` | `0` | `0` | `1` | `0x04` | +| Base | `LOAD_ADDRESS_COUNTER_LO` | `0` | `0` | `0` | `1` | `0` | `1` | `0` | `0` | `0` | `1` | `0x14` | +| Base | `WRITE_DATA` | `0` | `0` | `0` | `0` | `1` | `1` | `0` | `0` | `0` | `1` | `0x0c` | +| Base | `CLEAR` | `0` | `0` | `0` | `0` | `0` | `1` | `1` | `0` | `0` | `1` | `0x06` | +| Base | `SEARCH_FORWARD` | `0` | `0` | `0` | `1` | `0` | `0` | `0` | `0` | `0` | `1` | `0x10` | +| Base | `SEARCH_BACKWARD` | `0` | `0` | `0` | `1` | `0` | `0` | `1` | `0` | `0` | `1` | `0x12` | +| Base | `INSERT_BYTE` | `0` | `0` | `0` | `0` | `1` | `1` | `1` | `0` | `0` | `1` | `0x0e` | +| Base | `START_OPERATION` | `0` | `0` | `0` | `0` | `1` | `0` | `0` | `0` | `0` | `1` | `0x08` | +| Base | `DIAGNOSTIC_RESET` | `0` | `0` | `0` | `1` | `1` | `1` | `0` | `0` | `0` | `1` | `0x1c` | + +_The hexadecimal value above represents the value of the 8-bit command byte; this is +bits 9-2 shifted two bits to the right._ + +## References + + * IBM [3270 Information Display System Introduction](https://bitsavers.computerhistory.org/pdf/ibm/3270/GA27-2739-22_3270_Information_Display_System_Introduction_Oct88.pdf) (GA27-2739-22) + * IBM [3270 Information Display System Component Description](https://bitsavers.computerhistory.org/pdf/ibm/3270/GA27-2749-10_3270_Information_Display_System_Component_Description_Feb80.pdf) (GA27-2749-10) + * CHIPS 82C570 + * NS DP8340 + * NS DP8341 + * NS DP8344 diff --git a/protocol/status-line-character-map.jpg b/protocol/status-line-character-map.jpg new file mode 100644 index 0000000..e843bbd Binary files /dev/null and b/protocol/status-line-character-map.jpg differ diff --git a/pycoax/README.md b/pycoax/README.md index 77e3649..877259c 100644 --- a/pycoax/README.md +++ b/pycoax/README.md @@ -2,8 +2,69 @@ Python IBM 3270 coaxial interface library. -## Use +## Usage -You will need to build a [interface](../interface1) and connect it to your computer. +You will need to build an [interface](../interface1) and connect it to your computer. + +Install using `pip`: + +``` +pip install pycoax +``` + +Assuming your interface is connected to `/dev/ttyACM0` and you have a CUT type terminal connected to the interface, you can do something like this: + +``` +import time +from serial import Serial +from coax import SerialInterface, poll, poll_ack, load_address_counter_hi, \ + load_address_counter_lo, write_data, ReceiveTimeout + +with Serial('/dev/ttyACM0', 115200) as serial: + # Give the interface time to wake up... + time.sleep(3) + + # Initialize and reset the interface. + interface = SerialInterface(serial) + + firmware_version = interface.reset() + + print(f'Firmware version is {firmware_version}') + + # Wait for a terminal to attach... + poll_response = None + attached = False + + while not attached: + try: + poll_response = poll(interface, receive_timeout=1) + + if poll_response: + print(poll_response) + + poll_ack(interface) + + attached = True + except ReceiveTimeout: + print('.') + + time.sleep(1) + + # Poll the terminal until status is empty. + while poll_response: + poll_response = poll(interface) + + if poll_response: + print(poll_response) + + poll_ack(interface) + + # Move the cursor to top-left cell of a 80 column display. + load_address_counter_hi(interface, 0) + load_address_counter_lo(interface, 80) + + # Write a secret message. + write_data(interface, bytes.fromhex('a1 84 00 92 94 91 84 00 93 8e 00 83 91 88 8d 8a 00 98 8e 94 91 00 ae 95 80 8b 93 88 8d 84')) +``` See [examples](examples) for complete examples. diff --git a/pycoax/coax/__about__.py b/pycoax/coax/__about__.py index e1424ed..f0ede3d 100644 --- a/pycoax/coax/__about__.py +++ b/pycoax/coax/__about__.py @@ -1 +1 @@ -__version__ = '0.3.1' +__version__ = '0.4.1' diff --git a/pycoax/coax/__init__.py b/pycoax/coax/__init__.py index efc9602..2fe13ba 100644 --- a/pycoax/coax/__init__.py +++ b/pycoax/coax/__init__.py @@ -7,6 +7,7 @@ from .protocol import ( PollResponse, PowerOnResetCompletePollResponse, KeystrokePollResponse, + TerminalType, Control, SecondaryControl, poll, diff --git a/pycoax/coax/protocol.py b/pycoax/coax/protocol.py index f9e1317..b817ac0 100644 --- a/pycoax/coax/protocol.py +++ b/pycoax/coax/protocol.py @@ -66,7 +66,7 @@ class PowerOnResetCompletePollResponse(PollResponse): def __init__(self, value): if not PollResponse.is_power_on_reset_complete(value): - raise ValueError('Invalid POR poll response') + raise ValueError(f'Invalid POR poll response: {value}') super().__init__(value) @@ -75,7 +75,7 @@ class KeystrokePollResponse(PollResponse): def __init__(self, value): if not PollResponse.is_keystroke(value): - raise ValueError('Invalid keystroke poll response') + raise ValueError(f'Invalid keystroke poll response: {value}') super().__init__(value) @@ -97,6 +97,12 @@ class Status: f'feature_error={self.feature_error}, ' f'operation_complete={self.operation_complete}>') +class TerminalType(Enum): + """Terminal type.""" + + CUT = 1 + DFT = 2 + class TerminalId: """Terminal model and keyboard.""" @@ -108,21 +114,28 @@ class TerminalId: } def __init__(self, value): - if (value & 0x1) != 0: - raise ValueError('Invalid terminal identifier') - self.value = value - model = (value & 0x0e) >> 1 + if (value & 0x1) == 0: + self.type = TerminalType.CUT - if model not in TerminalId._MODEL_MAP: - raise ValueError('Invalid model') + model = (value & 0x0e) >> 1 - self.model = TerminalId._MODEL_MAP[model] - self.keyboard = (value & 0xf0) >> 4 + if model not in TerminalId._MODEL_MAP: + raise ValueError(f'Invalid model: {model}') + + self.model = TerminalId._MODEL_MAP[model] + self.keyboard = (value & 0xf0) >> 4 + elif value == 1: + self.type = TerminalType.DFT + self.model = None + self.keyboard = None + else: + raise ValueError(f'Invalid terminal identifier: {value}') def __repr__(self): - return f'' + return (f'') class Control: """Terminal control register.""" @@ -324,7 +337,7 @@ def is_command_word(word): def unpack_command_word(word): """Unpack a 10-bit command word.""" if not is_command_word(word): - raise ProtocolError('Word does not have command bit set') + raise ProtocolError(f'Word does not have command bit set: {word}') command = (word >> 2) & 0x1f @@ -343,13 +356,13 @@ def is_data_word(word): def unpack_data_word(word, check_parity=False): """Unpack the data byte from a 10-bit data word.""" if not is_data_word(word): - raise ProtocolError('Word does not have data bit set') + raise ProtocolError(f'Word does not have data bit set: {word}') byte = (word >> 2) & 0xff parity = (word >> 1) & 0x1 if check_parity and parity != odd_parity(byte): - raise ProtocolError('Parity error') + raise ProtocolError(f'Parity error: {word}') return byte @@ -374,7 +387,8 @@ def _execute_read_command(interface, command_word, response_length=1, if validate_response_length and len(response) != response_length: command = unpack_command_word(command_word) - raise ProtocolError(f'Expected {response_length} word {command.name} response') + raise ProtocolError((f'Expected {response_length} word {command.name} ' + f'response: {response}')) return unpack_data_words(response) if unpack else response @@ -396,7 +410,7 @@ def _execute_write_command(interface, command_word, data=None, **kwargs): if len(response) != 1: command = unpack_command_word(command_word) - raise ProtocolError(f'Expected 1 word {command.name} response') + raise ProtocolError(f'Expected 1 word {command.name} response: {response}') if response[0] != 0: - raise ProtocolError('Expected TR/TA response') + raise ProtocolError(f'Expected TR/TA response: {response}') diff --git a/pycoax/coax/serial_interface.py b/pycoax/coax/serial_interface.py index 43602be..e6fb501 100644 --- a/pycoax/coax/serial_interface.py +++ b/pycoax/coax/serial_interface.py @@ -36,7 +36,7 @@ class SerialInterface(Interface): raise _convert_error(message) if len(message) != 4: - raise InterfaceError('Invalid reset response') + raise InterfaceError(f'Invalid reset response: {message}') (major, minor, patch) = struct.unpack('BBB', message[1:]) @@ -109,7 +109,7 @@ class SerialInterface(Interface): raise InterfaceError('SLIP protocol error') if len(message) < 4: - raise InterfaceError('Invalid response message') + raise InterfaceError(f'Invalid response message: {message}') (length,) = struct.unpack('>H', message[:2]) @@ -156,15 +156,26 @@ ERROR_MAP = { def _convert_error(message): if message[0] != 0x02: - return InterfaceError('Invalid response') + return InterfaceError(f'Invalid response: {message}') if len(message) < 2: - return InterfaceError('Invalid error response') + return InterfaceError(f'Invalid error response: {message}') if message[1] in ERROR_MAP: - return ERROR_MAP[message[1]] + error = ERROR_MAP[message[1]] - return InterfaceError('Unknown error') + # Append description if included. + if len(message) > 2: + description = message[2:].decode('ascii') + + if error.args: + error.args = (f'{error.args[0]}: {description}', *error.args[1:]) + else: + error.args = (description,) + + return error + + return InterfaceError(f'Unknown error: {message[1]}') class SlipSerial(SlipWrapper): """sliplib wrapper for pySerial.""" diff --git a/pycoax/examples/20_char_map.py b/pycoax/examples/20_char_map.py index 83bde17..cf435ca 100755 --- a/pycoax/examples/20_char_map.py +++ b/pycoax/examples/20_char_map.py @@ -2,40 +2,62 @@ from common import create_serial, create_interface -from coax import read_address_counter_hi, read_address_counter_lo, load_address_counter_hi, load_address_counter_lo, write_data +from coax import Control, read_address_counter_hi, read_address_counter_lo, load_address_counter_hi, load_address_counter_lo, write_data, load_control_register DIGIT_MAP = [0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85] with create_serial() as serial: interface = create_interface(serial) - print('LOAD_ADDRESS_COUNTER_HI...') + load_control_register(interface, Control(cursor_inhibit=True)) load_address_counter_hi(interface, 0) - - print('LOAD_ADDRESS_COUNTER_LO...') - load_address_counter_lo(interface, 80) - print('WRITE_DATA...') - - buffer = b'\x00' * 3 + buffer = b'\x00' * 4 # Header Row for lo in range(16): - buffer += bytes([DIGIT_MAP[lo]]) + (b'\x00' * 3) + buffer += bytes([0x2f, DIGIT_MAP[lo]]) + (b'\x00' * 2) - buffer += b'\x00' * 13 + buffer += b'\x00' * 12 # Rows for hi in range(16): - buffer += bytes([DIGIT_MAP[hi]]) + (b'\x00' * 2) + buffer += bytes([DIGIT_MAP[hi], 0x2f]) + (b'\x00' * 2) for lo in range(16): - buffer += bytes([(hi << 4) | lo]) + b'\x32\xc0\x00' + if hi < 12: + buffer += bytes([0x00, (hi << 4) | lo, 0xc0, 0x00]) + else: + buffer += bytes([(hi << 4) | lo, 0x32, 0xc0, 0x00]) - buffer += b'\x00' * 13 + buffer += b'\x00' * 12 buffer += b'\x00' * 560 write_data(interface, buffer) + + # Status Line + load_address_counter_hi(interface, 7) + load_address_counter_lo(interface, 48) + + buffer = b'' + + for hi in range(12, 16): + buffer += bytes([DIGIT_MAP[hi]]) + (b'\x00' * 15) + + buffer += b'\x00' * 16 + + for hi in range(12, 16): + for lo in range(0, 16): + buffer += bytes([DIGIT_MAP[lo]]) + + write_data(interface, buffer) + + load_address_counter_hi(interface, 0) + load_address_counter_lo(interface, 0) + + buffer = bytes(range(0xc0, 0xff + 1)) + + write_data(interface, buffer) diff --git a/pycoax/examples/common.py b/pycoax/examples/common.py index c4b6f40..68d4f6f 100644 --- a/pycoax/examples/common.py +++ b/pycoax/examples/common.py @@ -1,15 +1,16 @@ import sys import time +import os from serial import Serial sys.path.append('..') from coax import SerialInterface, poll, poll_ack -SERIAL_PORT = '/dev/ttyACM0' +DEFAULT_SERIAL_PORT = '/dev/ttyACM0' def create_serial(): - port = SERIAL_PORT + port = os.environ.get('COAX_PORT', DEFAULT_SERIAL_PORT) print(f'Opening {port}...') diff --git a/pycoax/requirements.txt b/pycoax/requirements.txt index 0409260..c343611 100644 --- a/pycoax/requirements.txt +++ b/pycoax/requirements.txt @@ -1,2 +1,2 @@ pyserial==3.4 -sliplib==0.3.0 +sliplib==0.5.0 diff --git a/pycoax/setup.py b/pycoax/setup.py index 5b4b9fc..1cc3438 100644 --- a/pycoax/setup.py +++ b/pycoax/setup.py @@ -21,7 +21,7 @@ setup( author='Andrew Kay', author_email='projects@ajk.me', packages=['coax'], - install_requires=['pyserial==3.4', 'sliplib==0.3.0'], + install_requires=['pyserial==3.4', 'sliplib==0.5.0'], long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', classifiers=[ diff --git a/pycoax/tests/test_protocol.py b/pycoax/tests/test_protocol.py index ee2088a..2cfe4c1 100644 --- a/pycoax/tests/test_protocol.py +++ b/pycoax/tests/test_protocol.py @@ -4,7 +4,7 @@ from unittest.mock import Mock import context from coax import PollResponse, KeystrokePollResponse, ProtocolError -from coax.protocol import Command, Status, TerminalId, Control, SecondaryControl, pack_command_word, unpack_command_word, pack_data_word, unpack_data_word, pack_data_words, unpack_data_words, _execute_read_command, _execute_write_command +from coax.protocol import Command, Status, TerminalType, TerminalId, Control, SecondaryControl, pack_command_word, unpack_command_word, pack_data_word, unpack_data_word, pack_data_words, unpack_data_words, _execute_read_command, _execute_write_command class PollResponseTestCase(unittest.TestCase): def test_is_power_on_reset_complete(self): @@ -35,31 +35,40 @@ class StatusTestCase(unittest.TestCase): self.assertTrue(status.operation_complete) class TerminalIdTestCase(unittest.TestCase): - def test_model_2(self): + def test_cut_model_2(self): terminal_id = TerminalId(0b00000100) + self.assertEqual(terminal_id.type, TerminalType.CUT) self.assertEqual(terminal_id.model, 2) - def test_model_3(self): + def test_cut_model_3(self): terminal_id = TerminalId(0b00000110) + self.assertEqual(terminal_id.type, TerminalType.CUT) self.assertEqual(terminal_id.model, 3) - def test_model_4(self): + def test_cut_model_4(self): terminal_id = TerminalId(0b00001110) + self.assertEqual(terminal_id.type, TerminalType.CUT) self.assertEqual(terminal_id.model, 4) - def test_model_5(self): + def test_cut_model_5(self): terminal_id = TerminalId(0b00001100) + self.assertEqual(terminal_id.type, TerminalType.CUT) self.assertEqual(terminal_id.model, 5) + def test_dft(self): + terminal_id = TerminalId(0b00000001) + + self.assertEqual(terminal_id.type, TerminalType.DFT) + def test_invalid_identifier(self): with self.assertRaisesRegex(ValueError, 'Invalid terminal identifier'): - terminal_id = TerminalId(0b00000001) + terminal_id = TerminalId(0b00000011) - def test_invalid_model(self): + def test_invalid_cut_model(self): with self.assertRaisesRegex(ValueError, 'Invalid model'): terminal_id = TerminalId(0b00000000) diff --git a/pycoax/tests/test_serial_interface.py b/pycoax/tests/test_serial_interface.py index 56ded69..dbb7a87 100644 --- a/pycoax/tests/test_serial_interface.py +++ b/pycoax/tests/test_serial_interface.py @@ -53,6 +53,14 @@ class SerialInterfaceResetTestCase(unittest.TestCase): with self.assertRaisesRegex(InterfaceError, 'Invalid request message'): self.interface.reset() + def test_error_with_description_is_handled_correctly(self): + # Arrange + self.interface._read_message = Mock(return_value=bytes.fromhex('02 01 45 72 72 6f 72 20 64 65 73 63 72 69 70 74 69 6f 6e')) + + # Act and assert + with self.assertRaisesRegex(InterfaceError, 'Invalid request message: Error description'): + self.interface.reset() + # TODO... class SerialInterfaceReadMessageTestCase(unittest.TestCase):