Enumerating LCD segments with OpenCV for reverse-engineering

An LCD with unknown segment mapping is controlled by a microcontroller to activate all segments sequentially.
This sequence is captured on video by a camera and then processed on a PC using the computer vision library OpenCV (used via Python).
Once the segments are identified they can be mapped to the seven-segment digits on the LCD glass.
This mapping then allows the external microcontroller to display symbols (numerals and some letters) on the LCD digit positions.
If it's possible to obtain the segment state by listening to the LCD control lines of the original electronics, this mapping also would allow decoding of the digits into numbers - the display contents could be read "electronically".
The following image shows the result of the OpenCV processing:

lcd-segmentation

125 LCD segments (3 of 128 are not used) as detected and labeled by OpenCV, overlayed on the "empty display" reference frame.
Some segments appear thin due to lower LCD contrast and thresholding.
Green numerals are the enumerated index, magenta shows the <segment>.<common> addressing scheme used by the LCD controller.
(the image is full resolution - right-click and "Save Image As ...".)

Image processing

Only very basic image processing is needed: blob-detection.
This is made very simple through the functions available in OpenCV.
Background subtraction is used to isolate the current segment (although not strictly neccessary, using the right ROI).
The processing steps used are:

  1. conversion to grayscale and blur
  2. difference (subtraction from empty display as reference)
  3. thresholding
  4. erosion (to eliminate small artifacts)
  5. segmentation and labeling
Additionally, some plausibility tests are implemented to avoid multiple detection of the same segment across several video frames, as well as the detection of unused segments (empty display).

Video demo

This video shows the process and visualises the intermediate processing steps with OpenCV.
Load and play video from or :

Results

The algorithm successfully detected and enumerated all LCD segments after some iterations of adjusting the binarization threshold value and accounting for multi-object segments by increasing the positional tolerance for detection.
Difficulties include

Possible improvements

Update

I implemented some of the improvements mentioned above:

The former simply compares the number of pixels in two consecutive frames to detect the maximum contrast (segments fully dark).
This depends a little on segments to switch off faster than they switch on, as otherwise a frame containing more or larger active segments would cause an error. Alternatively blank frames could easily be inserted.

The latter uses each segment as serial data channel, i.e. each segment flashes out a binary code (it's own index number) which means the number of frames required is reduced from 128 to log2(128)+1=8.
"+ 1" because the first segment (bit 0) otherwise would not be activated at all and as such could not be detected.
The sequence used to control the display looks like this:

   127                                                                                                                            0
   11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
0: 10101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010
1: 11001100110011001100110011001100110011001100110011001100110011001100110011001100110011001100110011001100110011001100110011001100
2: 11110000111100001111000011110000111100001111000011110000111100001111000011110000111100001111000011110000111100001111000011110000
3: 11111111000000001111111100000000111111110000000011111111000000001111111100000000111111110000000011111111000000001111111100000000
4: 11111111111111110000000000000000111111111111111100000000000000001111111111111111000000000000000011111111111111110000000000000000
5: 11111111111111111111111111111111000000000000000000000000000000001111111111111111111111111111111100000000000000000000000000000000
6: 11111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000000
7: 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
            
The decoder only needs to group the detected segments by their position and convert the flashing sequence into binary.
In addition, it groups the multi-part segments.
Load and play video from or :

Hardware setup

The device under test in this experiment is a "PZEM-021 v5.0" panel meter with CPU and a dedicated LCD controller IC.
(For devices where there is no dedicated LCD controller, one is probably integrated into the CPU or ASIC as peripheral.
However, depending on the complexity of the LCD glass, it may also be possible to drive it with general purpose IO.)
The onboard microcontroller is held in reset by an external controller that then can write to the LCD controller, an HT1621.

lcd-segmentation_schematic

Schematic overview of components and connections.

Source code

It's just a proof of concept and not a full-fledged, user-friendly application.
It's also available on GitHub at https://github.com/eleif/lcd-segmentation/ for you to fork and improve.

Python / OpenCV

This is the code to run on your PC. It requires Python and the module opencv (and numpy). See the comments for references.
I've added a small feature that prints the segment index to console on mouse click (right mouse button to end the line) - this speeds up mapping the segments to digits considerably.


    #!python3
    """
    https://eleif.net/lcd-segmentation.html (2018-10-23)

    Enumerating LCD segments with OpenCV

    Intended to help with reverse-engineering an undocumented LCD.
    The LCD needs to be controlled in order to activate all segments/commons sequentially.
    Each segment is detected and labeled from the video.
    The labels can be used to map segments to display elements like seven-segment digits.
    The mapping then allows writing (reading) display contents.

    This is a proof of concept, not an application for end users.

    Run and hit space to process at near live speed or hit right-arrow to step through frames.
    There are some hard-coded parameters (marked with #TODO) and also globals ...

    Requirements / Documentation
    - Python 3
      https://docs.python.org/3/
      https://www.python.org/downloads/
    - NumPy
      https://docs.scipy.org/doc/numpy/
      pip install numpy [--upgrade]
    - OpenCV
      https://docs.opencv.org/3.4/
      https://pypi.org/project/opencv-python/
      pip install opencv-python [--upgrade]
      [pip install opencv-contrib-python]

    """

    # modules
    import sys              # Python standard library
    import numpy as np      # required by OpenCV
    import cv2 as cv        # OpenCV 3


    # settings
    filename = "demo.mp4"   # encoding affects possible seek positions
    startframe = 0          # !!
    endframe =  71          # !!
    threshold = 45          # !!

    segments = []           # store detected segments
    counter = 0             # count consecutive frames without segments


    def main():
        """ top level """

        print(versions())
        print("filename", filename)

        # frame source and background subtraction frame
        init()

        # loop
        state = "pause"
        while state != "quit":
            # process frame?
            if state in ["play","next","fast"]:
                process()

            # user input
            key = cv.waitKeyEx( int(1000 / fps) )

            # handle user input
            state = state_machine(state, key)

        # close frame source
        deinit()



    def init():
        """ initialise video source and empty frame for background subtraction """
        global cap, out, frame_first_gray, sum, fc, fps # TODO
        cap = cv.VideoCapture(filename)

        fps = cap.get(cv.CAP_PROP_FPS)
        fw  = int(cap.get(cv.CAP_PROP_FRAME_WIDTH))
        fh  = int(cap.get(cv.CAP_PROP_FRAME_HEIGHT))
        fc  = int(cap.get(cv.CAP_PROP_FRAME_COUNT))
        print("width:", fw)
        print("height:", fh)
        print("fps:", fps)
        print("frames:", fc)

        # add all detected segments
        sum = np.zeros((fh, fw), np.uint8) # grey

        # seek
        res = cap.set(cv.CAP_PROP_POS_FRAMES, startframe)
        print("starting at frame:", startframe, "OK" if res else "ERROR")

        if True: # TODO
            # use first frame as background
            res, frame_first = cap.read()
            print("read frame:", startframe, "OK" if res else "ERROR (try another frame or file format)")
            if not res:
                cap.release()
                cv.destroyAllWindows()
                sys.exit()
        else:
            # use image as background
            frame_first = cv.imread('background.png')
            frame_first = cv.cvtColor(frame_first, cv.COLOR_BGRA2BGR)
            if not frame_first.shape[:2] == (fh,fw):
                print("frame size mismatch", (fh,fw), frame_first.shape[:2])
                cap.release()
                cv.destroyAllWindows()
                sys.exit()

        # display reference image
        cv.imshow("First frame", frame_first)

        # convert to gray and blur a little to remove noise
        frame_first_gray = cv.cvtColor(frame_first, cv.COLOR_BGR2GRAY)
        #frame_first_gray = cv.medianBlur(frame_first_gray,5)
        frame_first_gray = cv.GaussianBlur(frame_first_gray, (5, 5), 0)



    def deinit():
        """ deinitialise OpenCV  """
        cap.release()
        cv.destroyAllWindows()



    def process():
        """ get and process one video frame """
        global sum, counter # TODO

        # get frame number
        fn = int(cap.get(cv.CAP_PROP_POS_FRAMES))

        # end of video reached: don't process, stop (and wait for user)
        if fn > endframe:
            state = "end"
            return

        # get frame
        res, frame = cap.read()
        if not res:
            print("ERROR (try another frame or file format)")
            deinit()
            sys.exit()  # TODO

        # convert to gray
        frame_gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)

        # blur to reduce noise
        #frame_gray = cv.medianBlur(frame_gray,5)
        frame_gray = cv.GaussianBlur(frame_gray, (5, 5), 0)    # TODO parameter

        # subtract from reference frame
        difference = cv.absdiff(frame_first_gray, frame_gray)

        # threshold
        _, thresholded = cv.threshold(difference, threshold, 255, cv.THRESH_BINARY)

        # attempt to remove noise
        kernel = np.ones((3, 3), np.uint8)    # TODO parameter
        filtered = cv.erode(thresholded, kernel, iterations=1)

        # sum
        sum = cv.add(sum, filtered)

        # detect blobs
        img_contours, contours, hierarchy = cv.findContours(
            filtered, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE)

        count = len(contours)

        if count > 0:
            # reset frame counter with no segments detected
            counter = 0

            # determine combined center of mass for all contours
            # do it manually because "bad" contours can cause moments to have invalid centroid
            c = 0
            cx = 0
            cy = 0
            for i, contour in enumerate(contours):
                # center of mass
                for p in contour:
                    x = p[0][0]
                    y = p[0][1]
                    cx += x
                    cy += y
                    c += 1

            cx = int(cx/c)
            cy = int(cy/c)

            # "adaptive" positional limits
            if count > 1:
                # higher tolerance for labels (multiple blobs spread out)
                t = 50  # TODO parameter
            else:
                # normal tolerance for segments
                t = 25  # TODO parameter

            # prevent duplicates
            try:
                last = segments[-1]
                if last[0] in range(cx-t, cx+t) and last[1] in range(cy-t, cy+t):
                    # detected previously
                    pass
                else:
                    print("new          {:3}".format( len(segments) ),[cx, cy])
                    segments.append([cx,cy])

            except (IndexError, TypeError):
                # no previous
                print("first/missing   {:3}".format( len(segments) ), cx, cy)
                segments.append([cx,cy])

        else:
            # detect missing segments
            counter += 1
            if counter > 6: # TODO parameter - could be calculated from blink speed and video frame rate
                print("missing segment around", fn-counter, fn)
                segments.append(None)
                counter = 0


        # show detected segments in output
        sum_inv = cv.bitwise_not(sum)
        display = cv.bitwise_and(frame,frame,mask = sum_inv)

        # show info overlays
        cv.putText(display, 'frame: {}/{}'.format(fn, fc),
            ( 10,20), cv.FONT_HERSHEY_SIMPLEX, .5, (0,255,0), 1, cv.LINE_AA)

        cv.putText(display, 'segments: {}'.format( len(segments) ),
            (180,20), cv.FONT_HERSHEY_SIMPLEX, .5, (0,255,0), 1, cv.LINE_AA)

        if count:
            # green
            cv.putText(display, 'c: {}'.format(count),
                ( 10,40), cv.FONT_HERSHEY_SIMPLEX, .5, (0,255,0), 1, cv.LINE_AA)
        else:
            # red (nothing detected, could be missing segment)
            cv.putText(display, 'c: {}'.format(count),
                ( 10,40), cv.FONT_HERSHEY_SIMPLEX, .5, (0,0,255), 1, cv.LINE_AA)


        # label segments
        fontFace = cv.FONT_HERSHEY_SIMPLEX
        fontScale = .5
        thickness = 1
        linetype = cv.LINE_8 #cv.LINE_AA

        for i, s in enumerate(segments):
            try:
                # enumerate
                text = '{}'.format( i )
                retval, baseLine = cv.getTextSize( text, fontFace, fontScale, thickness )
                cv.putText(display, text,
                    (segments[i][0]-int(retval[0]/2), segments[i][1]), fontFace, fontScale, (0,255,0), thickness, linetype)
                # COM/SEG address
                text = '{}.{}'.format( i//4, i%4 )
                retval, baseLine = cv.getTextSize( text, fontFace, fontScale, thickness )
                cv.putText(display, text,
                    (segments[i][0]-int(retval[0]/2), segments[i][1]+int(retval[1])), fontFace, fontScale, (255,0,255), thickness, linetype)
            except TypeError as e:
                # None encountered
                pass

        # display processing steps
        cv.imshow("Current Frame", frame_gray)
        cv.imshow("difference", difference)
        cv.imshow("thresholded", thresholded)
        cv.imshow("filtered", filtered)
        cv.imshow("sum", sum)
        cv.imshow("display", display)

        # save
        if False:
            # lossless frames - use e.g. ffmpeg to create video
            cv.imwrite( "output/{:04}_g.png".format(fn), frame_gray)
            # cv.imwrite( "output/{:04}_d.png".format(fn), difference)
            # cv.imwrite( "output/{:04}_t.png".format(fn), thresholded)
            # cv.imwrite( "output/{:04}_f.png".format(fn), filtered)
            # cv.imwrite( "output/{:04}_s.png".format(fn), sum)
            # cv.imwrite( "output/{:04}.png".format(fn), display)

        # left-click on segment to print number to console; right-click for newline
        cv.setMouseCallback('display', mouse)

    # mouse callback function (left-click on segment to print number to console; right-click for newline)
    def mouse(event,x,y,flags,param):
        # find closest segment in range and print segment number to console
        if event == cv.EVENT_LBUTTONUP:
            # calculate distances between the mouse click and all segments
            ds = []
            for i,s in enumerate(segments):
                sx,sy = s[:2]
                dx = x-sx
                dy = y-sy
                d = (dx**2 + dy**2)**.5
                ds.append([i,d])
            # find the smallest distance, i.e. the closest segment
            m = min(ds, key=lambda _:_[1])
            # print index if clicked close enough
            if m[1] < 20:   # TODO parameter click distance in px
                print("{}, ".format(m[0]), end="" )

        # new line on RMB
        if event == cv.EVENT_RBUTTONUP:
            print()



    def state_machine(state, key):
        """ determine state change from key code """
        states = {
            "pause" : {"esc":"quit", "space":"play", "right":"next", "left":"previous" },
            "play"  : {"esc":"quit", "space":"pause"},
            "end"   : {"esc":"quit"                 },
            "quit"  : {},
        }
        keys = {
            27:"esc",
            13:"enter",     # not used
            32:"space",
            115:"s",        # not used
            2424832:"left", # not used
            2555904:"right",
        }

        if key > 0:
            # key pressed
            try:
                # print("key", key, "=", keys[key])
                # print("state", state, "->", states[state][keys[key]])
                state = states[state][keys[key]]
            except KeyError as e:
                print("unknown key", key)
        else:
            # no key pressed
            if state == "next":
                # just process one frame (pause automatically)
                state = "pause"

        return state



    def versions():
        """ return version strings """
        p = "Python " + sys.version
        n = "NumPy  " + np.version.version
        o = "OpenCV " + cv.__version__
        return "\n".join([p,n,o]) + "\n"



    if __name__ == "__main__":
        main()


C/C++ (Arduino IDE)

This is the code I ran on a microcontroller to "hijack" the display in order to show all segments in sequence. It's just a proof of concept.
It includes the complete digit mapping (as well as definitions for the other segments and symbols), seven-segment glyphs (digits and some letters) and test functions to write to the digits.
Additionally, it can receive the serial data coming from the original controller - but it does not decode the information displayed.
The code does not use any hardware features, it's just "bit-banging" 4 IO lines: RESn to keep the original control IC in reset, and #CS, #WR and DATA for the LCD controller.
I've used the Arduino IDE with a "Digispark" clone, which is a small board with an 8-pin AVR/Microchip ATtiny85 microcontroller that has the "micronucleus" bootloader for programming via USB.


    // V9821s controls HT1621 in PZEM-021 V5.0
    //
    // Arduino IDE/framework C/C++ code for an AVR microcontroller,
    // a "Digispark" clone (AVR/Microchip ATtiny85 with "micronucleus" USB bootloader)
    //
    // The ATtiny85 is running at 16.5 MHz
    // The bootloader takes a few seconds before the user program is executed!
    //

    // pins
    #define HT_RES  5 // (requires AVR fuse to disable reset to use as IO)
    //              4 // USB
    //              3 // USB
    #define HT_CS   2 //
    #define HT_WR   1 //
    #define HT_DATA 0 //


    // global variables used in "loop"
    int old = HIGH, edge = HIGH;
    bool s = false;
    unsigned long time;

    // display memory (only lower nibble is used)
    uint8_t ram[32] = {0};
    uint8_t buf[32] = {0};


    // seven segment display

    // naming convention
    // (clockwise from top)
    //   A
    // F   B
    //   G
    // E   C
    //   D

    // seven segment bits
    // (so a digit/letter can be encoded in a byte)
    #define S_A   1
    #define S_B   2
    #define S_C   4
    #define S_D   8
    #define S_E  16
    #define S_F  32
    #define S_G  64
    //          128


    // seven segment glyphs
    // (which segment is active in a digit/letter)

    // digits
    //      G_x  S_A   S_B   S_C   S_D   S_E   S_F   S_G
    #define G_0 (S_A + S_B + S_C + S_D + S_E + S_F +  0 )
    #define G_1 ( 0  + S_B + S_C +  0  +  0  +  0  +  0 )
    #define G_2 (S_A + S_B +  0  + S_D + S_E +  0  + S_G)
    #define G_3 (S_A + S_B + S_C + S_D +  0  +  0  + S_G)
    #define G_4 ( 0  + S_B + S_C +  0  +  0  + S_F + S_G)
    #define G_5 (S_A +  0  + S_C + S_D +  0  + S_F + S_G)
    #define G_6 (S_A +  0  + S_C + S_D + S_E + S_F + S_G)
    #define G_7 (S_A + S_B + S_C +  0  +  0  +  0  +  0 )
    #define G_8 (S_A + S_B + S_C + S_D + S_E + S_F + S_G)
    #define G_9 (S_A + S_B + S_C +  0  +  0  + S_F + S_G)
    //      G_x  S_A   S_B   S_C   S_D   S_E   S_F   S_G

    // letters
    //      G_x  S_A   S_B   S_C   S_D   S_E   S_F   S_G
    #define G_A (S_A + S_B + S_C +  0  + S_E + S_F + S_G) // A  0
    #define G_B ( 0  +  0  + S_C + S_D + S_E + S_F + S_G) // b  1
    #define G_C ( 0  +  0  +  0  + S_D + S_E +  0  + S_G) // c  2
    #define G_D ( 0  + S_B + S_C + S_D + S_E +  0  + S_G) // d  3
    #define G_E (S_A +  0  +  0  + S_D + S_E + S_F + S_G) // E  4
    #define G_F (S_A +  0  +  0  +  0  + S_E + S_F + S_G) // F  5
    #define G_G (S_A + S_B + S_C + S_D +  0  + S_F + S_G) // g  6
    #define G_H ( 0  +  0  + S_C +  0  + S_E + S_F + S_G) // h  7
    #define G_I ( 0  + S_B + S_C +  0  +  0  +  0  +  0 ) // I  8
    #define G_J ( 0  + S_B + S_C + S_D +  0  +  0  +  0 ) // J  9
    #define G_K ( 0  +  0  +  0  +  0  +  0  +  0  + S_G) // - 10
    #define G_L ( 0  +  0  +  0  + S_D + S_E + S_F +  0 ) // L 11
    #define G_M ( 0  +  0  +  0  +  0  +  0  +  0  + S_G) // - 12
    #define G_N ( 0  +  0  + S_C +  0  + S_E +  0  + S_G) // n 13
    #define G_O ( 0  +  0  + S_C + S_D + S_E +  0  + S_G) // o 14
    #define G_P (S_A + S_B +  0  +  0  + S_E + S_F + S_G) // P 15
    #define G_Q (S_A + S_B + S_C +  0  +  0  + S_F + S_G) // q 16
    #define G_R ( 0  +  0  +  0  +  0  + S_E +  0  + S_G) // r 17
    #define G_S (S_A +  0  + S_C + S_D +  0  + S_F + S_G) // S 18
    #define G_T ( 0  +  0  +  0  + S_D + S_E + S_F + S_G) // t 19
    #define G_U ( 0  + S_B + S_C + S_D + S_E + S_F +  0 ) // U 20
    #define G_V ( 0  +  0  + S_C + S_D + S_E +  0  +  0 ) // v 21
    #define G_W ( 0  +  0  +  0  +  0  +  0  +  0  + S_G) // - 22
    #define G_X ( 0  +  0  +  0  +  0  +  0  +  0  + S_G) // - 23
    #define G_Y ( 0  + S_B + S_C + S_D +  0  + S_F + S_G) // y 24
    #define G_Z (S_A + S_B +  0  + S_D + S_E +  0  + S_G) // Z 25
    //      G_x  S_A   S_B   S_C   S_D   S_E   S_F   S_G


    // arrays with digits and letters
    //
    // ASCII offset is '0' or 48 or 0x30
    const uint8_t digits[] = {
      G_0, G_1, G_2, G_3, G_4, G_5, G_6, G_7, G_8, G_9,
      };
    // ASCII offset is 'A' or 65 or 0x41
    const uint8_t letters[] = {
      G_A, G_B, G_C, G_D, G_E, G_F, G_G, G_H, G_I, G_J,
      G_K, G_L, G_M, G_N, G_O, G_P, G_Q, G_R, G_S, G_T,
      G_U, G_V, G_W, G_X, G_Y, G_Z,
    };



    // bit indexes for LCD segments in LCD controller RAM
    //
    //   88.88 V    88.88 A
    //    voltage    current
    //   888.8 kW   8888 kWh
    //    power      energy
    //
    // voltage
    //                          A   B   C   D   E   F   G
    const uint8_t v[4][7] = { {  3,  7,  5,  0,  1,  2,  6}, //
                             { 11, 15, 13,  8,  9, 10, 14}, //
                             { 19, 23, 21, 16, 17, 18, 22}, //
                             { 27, 31, 29, 24, 25, 26, 30}};//
    const uint8_t v_d =  12; // decimal 88.88
    const uint8_t v_u =  28; // unit (V)
    const uint8_t v_l =   4; // label

    // current
    const uint8_t c[4][7] = { { 35, 39, 37, 32, 33, 34, 38}, //
                             { 43, 47, 45, 40, 41, 42, 46}, //
                             { 51, 55, 53, 48, 49, 50, 54}, //
                             { 59, 63, 61, 56, 57, 58, 62}};//
    const uint8_t c_d =  44; // decimal 88.88
    const uint8_t c_u =  60; // unit (A)
    const uint8_t c_l =  36; // label

    // power
    const uint8_t p[4][7] = { { 64, 68, 70, 67, 66, 65, 69}, //
                             { 72, 76, 78, 75, 74, 73, 77}, //
                             { 80, 84, 86, 83, 82, 81, 85}, //
                             { 88, 92, 94, 91, 90, 89, 93}};//
    const uint8_t p_d =  87; // decimal 888.8
    const uint8_t p_k = 111; // kilo
    const uint8_t p_u = 103; // unit (W)
    const uint8_t p_l =  79; // label

    // energy
    const uint8_t e[4][7] = { { 96,100,102, 99, 98, 97,101}, //
                             {104,108,110,107,106,105,109}, //
                             {112,116,118,115,114,113,117}, //
                             {120,124,126,123,122,121,125}};//
    const uint8_t e_k = 127; // kilo
    const uint8_t e_u = 119; // unit (Wh)
    const uint8_t e_l =  95; // label



    // set single bit with index <bit> in <ram>
    void setbit(uint8_t bit){
      ram[bit/4] |= 1<<(bit%4);
    }//setbit

    // clear single bit with index <bit> in <ram>
    void clrbit(uint8_t bit){
      ram[bit/4] &= ~(1<<(bit%4));
    }//clrbit

    // clear <ram>
    void clear(){
      for (uint8_t i=0; i<32; i++) {
        ram[i] = 0;
      }
    }//clear

    // write <c> from <glyphs[]> to <digit[]>
    void write(uint8_t c, const uint8_t glyphs[], const uint8_t digit[7]){
      for (uint8_t i=0; i<7; i++) {
        if (glyphs[c] & (1<<i)) {
          setbit( digit[i] );
        }
        else {
          clrbit( digit[i] );
        }
      }
    }//write


    // show all bits/segments in <digit[]> sequentially
    void test(const uint8_t digit[7]){
      for (uint8_t i=0; i<7; i++) {
          setbit( digit[i] );
          writedisplay(ram);
          delay(300);
      }
    }//test

    // show all segments sequentially
    void segments(){
      for (uint8_t i=0; i<132; i++) {
          setbit( i );
          writedisplay(ram);
          clrbit( i );
          delay(300);
      }
    }//segments


    // array to store active segments/bits by index
    uint8_t bits[128] = {0};

    // get active bits/segments from <in> and store their index in <out>, return the count
    uint8_t getbits(uint8_t in[], uint8_t out[]){
      uint8_t n=0;
      for(uint8_t i=0; i<128; i++){
        if ( in[i/4] & (1<<(i%4)) ) {
          out[n++] = i;
        }
      }
      return n;
    }//getbits


    // bit-bang synchronous serial for HT1621
    // write all 32x4 bits from <data> successively
    // (this assumes the display has been initialised)
    void writedisplay(uint8_t data[]) {

      digitalWrite(HT_CS, LOW);

      // command
      digitalWrite(HT_WR, LOW);
      digitalWrite(HT_DATA, HIGH);  // 1
      digitalWrite(HT_WR, HIGH);

      digitalWrite(HT_WR, LOW);
      digitalWrite(HT_DATA, LOW);   // 0
      digitalWrite(HT_WR, HIGH);

      digitalWrite(HT_WR, LOW);
      digitalWrite(HT_DATA, HIGH);  // 1
      digitalWrite(HT_WR, HIGH);

      // 6 bits start address 0b000000
      digitalWrite(HT_DATA, LOW);
      for(uint8_t n=0; n<6; n++){
        digitalWrite(HT_WR, LOW);
        digitalWrite(HT_WR, HIGH);  // 0
      }

      // 32x4 bits, LSB first
      for(uint8_t a=0; a<32; a++){
        for(uint8_t d=0; d<4; d++){

          // clock
          digitalWrite(HT_WR, LOW);

          // data
          if ( (data[a]>>d)&1  ) {
            digitalWrite(HT_DATA, HIGH);
          }
          else {
            digitalWrite(HT_DATA, LOW);
          }

          // clock
          digitalWrite(HT_WR, HIGH);

        }
      }

      digitalWrite(HT_CS, HIGH);

    } // writedisplay


    // bit-bang synchronous serial for HT1621
    // receive a synchronous serial data packet for HT1621 and store bits in <data>
    // probably ONLY WORKS WITH panel meter "PZEM-021 v5.0" (timing, packets, line levels, ..)
    void readdisplay(uint8_t data[]) {

      uint8_t address = 0;

      // wait for end of an ongoing "packet" (rising edge)
      while( digitalRead(HT_CS) == LOW  );

      // wait for start of packet (falling edge)
      while( digitalRead(HT_CS) == HIGH  );

      // command
      // wait for rising edge
      while( digitalRead(HT_WR) == HIGH  );
      while( digitalRead(HT_WR) == LOW  );
      if( digitalRead(HT_DATA) != HIGH ) return;  // 1

      while( digitalRead(HT_WR) == HIGH  );
      while( digitalRead(HT_WR) == LOW  );
      if( digitalRead(HT_DATA) != LOW ) return;   // 0

      while( digitalRead(HT_WR) == HIGH  );
      while( digitalRead(HT_WR) == LOW  );
      if( digitalRead(HT_DATA) != HIGH ) return;  // 1

      // address (6 bits, 5 used)
      for(uint8_t i=0; i<6; i++){
        // wait for rising edge
        while( digitalRead(HT_WR) == HIGH  );
        while( digitalRead(HT_WR) == LOW  );

        // read bit
        if( digitalRead(HT_DATA) == HIGH ){
          // MSB first
          address |= 0b100000>>i;
        }
      }

      // data bits (multiples of 4)
      while(1){

        // try to read 4 bits
        for(uint8_t i=0; i<4; i++){

          // wait for falling edge or end of packet
          while( digitalRead(HT_WR) == HIGH && digitalRead(HT_CS) == LOW  );
          // end of packet?
          if( digitalRead(HT_CS) == HIGH ){
            return;
          }
          // wait for rising edge
          while( digitalRead(HT_WR) == LOW  );

          // read data bit
          if( digitalRead(HT_DATA) == HIGH ){
            //set
            data[address] |= 1<<i;
          }
          else {
            //clear
            data[address] &= ~(1<<i);
          }

        }//for

        // increment address for "successive address writing" mode
        address++;

      }//while

    }//readdisplay



    // "Arduino" framework init function
    void setup() {
      // remember this is run _after_ the bootloader has finished listening (about 6 seconds after power-on)
      // nothing to do
    }//setup


    // "Arduino" framework main loop
    void loop() {

      // detect rising edge
      edge = digitalRead(HT_CS);
      if( edge && edge!=old) {
        time = millis();
        s = true;
      }
      old = edge;

      // detect gap between display updates
      if( s && ( (millis() - time) >= 15) ){
        s = false;


        // listen to display data (16 packets x 2x4 bits = 128 bits)
        for(uint8_t n=0; n<16; n++){
          readdisplay(buf);
        }

        // reset V9821S and ...
        pinMode(HT_RES, OUTPUT);
        //digitalWrite(5,LOW);  // redundant
        delay(10);  // see V9821S datasheet

        // ... take control of serial interface
        pinMode(HT_CS,   OUTPUT);
        digitalWrite(HT_CS,HIGH); // prevent later V9821S glitch: it pulls #WR down once on CS falling edge in reset
        pinMode(HT_WR,   OUTPUT);
        pinMode(HT_DATA, OUTPUT);

        // get bit indexes from data
        uint8_t k = getbits(buf,bits);

        // shuffle the bits
        randomSeed(7331);
        for(uint8_t n=0; n<k; n++){
          uint8_t r = random(n,k);
          uint8_t tmp = bits[n];
          bits[n] = bits[r];
          bits[r] = tmp;
        }

        // clear the randomized segments
        uint8_t d = 1000/k;
        for(uint8_t n=0; n<k; n++){
          buf[bits[n]/4] &= ~(1<<(bits[n]%4));
          writedisplay(buf);
          delay(d);
        }

        // show all segments sequentially
        segments();

        // show seven-segments sequentially
        test(v[0]);
        test(v[1]);
        test(v[2]);
        test(v[3]);

        test(c[0]);
        test(c[1]);
        test(c[2]);
        test(c[3]);

        test(p[0]);
        test(p[1]);
        test(p[2]);
        test(p[3]);

        test(e[0]);
        test(e[1]);
        test(e[2]);
        test(e[3]);



        // show all digits
        for (uint8_t d=0; d<10; d++) {
          clear();

          write(d, digits, v[0]);
          write(d, digits, v[1]);
          write(d, digits, v[2]);
          write(d, digits, v[3]);

          write(d, digits, c[0]);
          write(d, digits, c[1]);
          write(d, digits, c[2]);
          write(d, digits, c[3]);

          write(d, digits, p[0]);
          write(d, digits, p[1]);
          write(d, digits, p[2]);
          write(d, digits, p[3]);

          write(d, digits, e[0]);
          write(d, digits, e[1]);
          write(d, digits, e[2]);
          write(d, digits, e[3]);

          writedisplay(ram);
          delay(400);
        }

        // "type" some text
        #define TYPEDELAY 300
        clear();
        writedisplay(ram);

        write('P'-'A', letters, v[0]);
        writedisplay(ram); delay(TYPEDELAY);
        write('Z'-'A', letters, v[1]);
        writedisplay(ram); delay(TYPEDELAY);
        write('E'-'A', letters, v[2]);
        writedisplay(ram); delay(TYPEDELAY);
        write('M'-'A', letters, v[3]);
        writedisplay(ram); delay(TYPEDELAY);

        write('X'-'A', letters, c[0]);
        writedisplay(ram); delay(TYPEDELAY);
        write(0, digits, c[1]);
        writedisplay(ram); delay(TYPEDELAY);
        write(2, digits, c[2]);
        writedisplay(ram); delay(TYPEDELAY);
        write(1, digits, c[3]);
        writedisplay(ram); delay(TYPEDELAY);

        // -
        writedisplay(ram); delay(TYPEDELAY);
        write('V'-'A', letters, p[1]);
        writedisplay(ram); delay(TYPEDELAY);
        write(5, digits, p[2]);
        writedisplay(ram); delay(TYPEDELAY);
        setbit(p_d);
        writedisplay(ram); delay(TYPEDELAY);
        write(0, digits, p[3]);
        writedisplay(ram); delay(TYPEDELAY);

        write('L'-'A', letters, e[0]);
        writedisplay(ram); delay(TYPEDELAY);
        write('C'-'A', letters, e[1]);
        writedisplay(ram); delay(TYPEDELAY);
        write('D'-'A', letters, e[2]);
        writedisplay(ram); delay(TYPEDELAY);
        // write('O'-'A', letters, e[3]);
        // writedisplay(ram); delay(TYPEDELAY);


        // release serial interface
        pinMode(HT_CS,   INPUT);
        pinMode(HT_WR,   INPUT);
        pinMode(HT_DATA, INPUT);

        // release V9821S
        pinMode(HT_RES,  INPUT);

        // STOP
        while(1);

      } // gap detected

    }//loop


    // EOF

Let me know if this was useful to you and if/how you improved it.