2021年2月15日 星期一

BLE Dashboard - LS060S2UD01 Sharp Memory LCD

 
有次網路逛街看到了好便宜的6" Sharp Memory LCD,手癢就買來玩玩看。因為這次螢幕解析度和大小都滿大的,所以想說來做兩個東西: 顯示天氣圖還有如上圖的PC儀表板。

首先要決定的是要用哪個MCU跟架構,因為Memory LCD本來就低耗電,設計目標自然是帶電池的無線螢幕,所以挑選的項目自然只剩下有無線功能的MCU。

WiFi or BLE? 老實說我想了一陣子,因為800x600資料不少,我原本是很想要用WiFi因為資料下載比較快,但是ESP32的耗電量控制真的慘,不適合這種一直更新的用途,所以還是用BLE。

先來講這片螢幕 LS060S2UD01: 

6" 800x600的解析度,而和之前的Memory LCD差異最大的在於每個pixel是兩個單色sub-pixel組成,所以每個pixel實際上能組成四種黑/白色,資料可以寫4bit的資料,然後控制兩個單色的sub-pixel你就有4色,也可以讓螢幕幫你做dithering擴展成16色階,不過效果在複雜的圖文底下不佳(aka天氣圖)。

這是單色我老婆

這是16色階dithering的我老婆,其實效果還不錯

這是16色階dithering的天氣圖,文字和線條就很慘了

控制介面則是parallel bus,有16bit和8bit兩種寬度,資料刷新的步驟基本上是:

資料傳到螢幕的Frame Buffer => 讓Frame Buffer寫資料進到Panel => 等資料寫完

其中寫資料可以指定寫入部分的螢幕(類似E-ink的partial update),還可以從frame buffer把資料讀出來(為了支援小容量記憶體的MCU我猜)。然後把資料從frame buffer轉移完之後透過ACK pin通知MCU寫入完畢。另外也可以開Video mode讓LCD自己用30Hz的速度自動轉移Frame buffer到Panel,這時候SYNC pin會有Frame Sync通知MCU。

我為了方便,這邊用的是8bit bus + 單色 + Full Update + 自己下指令轉移buffer。可能之後換成4色。因為800x600單色已經是56K的資料量了,開成4bit=>224K,有點肥。而資料寫入的Bus則是透過把parallel bus安排到GPIO 0 ~ 7 + 直接register set/clr,速度可以拉到10Hz。


我的Driver基本上是為了這個MCU設計的,所以僅供參考: https://github.com/will127534/Adafruit_SHARP_Memory_Display

接下來就是MCU,基本上nRF系列也不錯,但是我這次想來試試 SparkFun Artemis Module,因為這個Module用的不是常見的BLE SoC,用的是Apollo3b from Ambiq,他們家的MCU非常暴力省電。大學修電子學的時候略有耳聞有些IC工作點是在課本沒有教的sub-threshold region,因為不是在飽和區,工作的電流可以壓到非常低,通常用在手錶這種非常低功耗的IC上面。
而Ambiq把它用在MCU上,整個模組我的測試功能全開的狀況下Active power只有1mA,非常狂。

不過缺點也很明顯,因為用的人少,軟體的Support其實不怎麼樣,Sparkfun有Arduino Support,但是它是基於Mbed OS,等於是他們包了Mbed OS bridge + Mbed OS去Support Arduino。老實說如果只有這樣的話那就算了,反正我可以直接寫MCU register,但是好死不死他們包的Mbed OS沒有Sleep Mode,也就是說模組會一直耗掉1mA,這我真的不能忍。只好改用Mbed OS來寫,結果發現把Sleep打開會一睡不醒...... 為了能讓我用BLE+Sleep,只好切回去原廠的SDK。
可是原廠的SDK BLE超難用,最後只好開一個BLE example然後硬改成我要的。附帶一提BLE功率不高。

模組的pinout也非常的糟糕,即便是parallel bus特別安排到GPIO0~7,實際上模組腳位可能東一個西一個,搞得Layout上複雜了不少。

模組附近Layout的近照,這三小pinout

原本打算用兩個CR2032拚幾個月,結果因為螢幕本身就要吃掉500uA了其實做不到,所以還是回頭用Li-ion。電路板其他的基本上就是UART隔離+Sparkfun Bootloader的電路,還有鋰電池充電+降壓模組,最後就是幾個電流的測試點。降壓模組我這邊用的是TPS82740A,看了很久想說來試試看,這個在低耗電量(數百uA)的範圍轉換效率真的很高,Iq也耗很少電,BGA封裝但是很好Layout和焊接,最高頂多輸出200mA但是這種用途非常足夠了。
鋰電池電壓監測照慣例用隔離電路,平常斷開分壓電阻,只有需要的時候才打開一下。

EN pin低到高會打開這個MOS 5ms,這時候Sample VBAT。

Apollo3b 其實內建了類似的電路,而且還有一個500歐姆的load resistor,透過比較有load/無load的電壓去計算電池內阻來判定電池容量,滿狂的。只可惜螢幕只能跑3.3V,我也不想另外放電平轉換電路在8bit匯流排上面。

接下來就是畫圖軟體了,先來介紹第一種用途:畫天氣圖
天氣圖基本上就是RPI資料載下來-->轉檔-->BLE傳Frame Buffer到板子上。
剛剛提到一張Frame也將近56K,所以這裡勢必是需要一點壓縮,原本找了老半天看了老半天的黑白影像壓縮,看來大家都很愛RLE(running length encoding)就跟著.....沒有,用RLP壓完變得更肥。最後覺得乾脆gzip算了結果壓縮比還不錯,上面這張圖壓完只剩下21Kb。
MCU上面的解壓縮用的是這個Library: https://github.com/pfalcon/uzlib
解壓縮基本上照著example就好,只是我開了32K給他放dictionary: 
    uzlib_uncompress_init(&d,(void*)dict, 32768);

解壓縮直接解到Frame buffer然後寫進去Flash,因為天氣圖有未來兩天半的資料一共七張圖,所以我直接把Flash後面的512K都拿來寫,每次傳之前就把這512K一次清掉。

資料抓NOAA Short Range Forecasts: https://www.wpc.ncep.noaa.gov/basicwx/basicwx_ndfd.php
透過Python把時間點和圖片抓下來,另外多虧這個圖片是256色的gif,透過替換顏色的方式把地圖,等壓線,文字,鋒面給擷取出來。

這邊算是貼給我自己看得,這段Python包含爬蟲,下載,畫圖,壓縮,上傳BLE
from bluepy import btle
from bluepy.btle import Scanner, DefaultDelegate
import os
import numpy
from PIL import Image
import PIL.ImageOps as ImageOps
import binascii
from io import BytesIO
import gzip
import time
import requests
from PIL import ImageDraw
from PIL import ImageFont
import requests
from pyquery import PyQuery

os.system('rm *.gif')

class ScanDelegate(DefaultDelegate):
    def __init__(self):
        DefaultDelegate.__init__(self)

scanner = Scanner().withDelegate(ScanDelegate())
devices = scanner.scan(8.0)

nameToMacAddress = dict()
for dev in devices:
    #print("Device %s (%s), RSSI=%d dB" % (dev.addr, dev.addrType, dev.rssi))
    for (adtype, desc, value) in dev.getScanData():
        #print("  %s = %s" % (desc, value))
        if desc == "Complete Local Name":
            nameToMacAddress[value] = dev.addr;

print("Connecting...")

dev = btle.Peripheral(nameToMacAddress["SharpMeMoryLC"])
time.sleep(1)
dev.setMTU(2049)
lightSensor = btle.UUID("00002760-08c2-11e1-9073-0e8ac72e1011")
DataService = dev.getServiceByUUID(lightSensor)
uuidConfig = btle.UUID("00002760-08c2-11e1-9073-0e8ac72e0011")
BLE_data = DataService.getCharacteristics(uuidConfig)[0]

def binarize_array(numpy_array):
    RED = numpy.array([255, 0, 0])
    BLUE = numpy.array([0, 0, 255])
    BLACK = numpy.array([0, 0, 0])
    GRAY = numpy.array([220, 220, 220])
    RED2 = numpy.array([238, 44, 44])
    PURPLE = numpy.array([145, 44, 238])
    WHITE = numpy.array([255, 255, 255])
    red, green, blue = numpy_array.T
    redAreas = (red == 255) & (blue == 0) & (green == 0)
    blueAreas = (red == 0) & (blue == 255) & (green == 0)
    blackAreas = (red == 0) & (blue == 0) & (green == 0)
    grayreas = (red == blue) & (blue == green) & (green<255)
    red2Areas = (red == 238) & (blue == 44) & (green == 44)
    purpleAreas = (red == 145) & (blue == 44) & (green == 238)
    output = numpy.full(numpy_array.shape,255, dtype=numpy.uint8)
    output[redAreas.T] = (0, 0, 0)
    output[blueAreas.T] = (0, 0, 0)
    output[blackAreas.T] = (0, 0, 0)
    output[grayreas.T] = (0, 0, 0)
    output[red2Areas.T] = (0, 0, 0)
    output[purpleAreas.T] = (0, 0, 0)
    return output

def round_corner(radius):
    """Draw a round corner"""
    corner = Image.new('1', (radius, radius), (1))
    draw = ImageDraw.Draw(corner)
    draw.arc((0, 0, radius * 2, radius * 2), 180, 270, fill=(0),width=3)
    return corner

def round_rectangle(size, radius,timeText):
    """Draw a rounded rectangle"""
    width, height = size
    rectangle = Image.new('1', size, (1))
    corner = round_corner(radius)
    rectangle.paste(corner, (0, 0))
    rectangle.paste(corner.rotate(90), (0, height - radius - 3))  # Rotate the corner and paste it
    rectangle.paste(corner.rotate(180), (width - radius -3, height - radius -3))
    rectangle.paste(corner.rotate(270), (width - radius-3, 0))
    ImageDraw.Draw(rectangle).line((radius,3, width - radius, 3), fill=(0),width=3)
    ImageDraw.Draw(rectangle).line((radius,height - 3, width - radius, height - 3), fill=(0),width=3)
    ImageDraw.Draw(rectangle).line((3,radius, 3, height - radius), fill=(0),width=3)
    ImageDraw.Draw(rectangle).line((width - 3,radius, width - 3, height - radius), fill=(0),width=3)
    fnt = ImageFont.truetype('./FreeMonoBold.ttf', 22)
    x,y = ImageDraw.Draw(rectangle).textsize(timeText,font=fnt)
    ImageDraw.Draw(rectangle).text((int((width-x)/2), int((height-y)/2)),timeText,(0),font=fnt)
    return rectangle


baseURL = 'https://www.wpc.ncep.noaa.gov'
r = requests.get('https://www.wpc.ncep.noaa.gov/basicwx/basicwx_ndfd.php')
pq = PyQuery(r.text)
periodList = ['period1','period2','period3','period4','period5','period6','period7']
filelist = []
timeList = []
for period in periodList:
    tag = pq('div#'+period+' img')
    if len(tag) == 0:
        continue
    fileName = tag.attr['src'].split('/')[2]
    timeName = tag.attr['alt'].split(' ')[-1] + " " + tag.attr['alt'].split(' ')[-2][0:3]
    print('FileName: %s, Time: %s' % (fileName,timeName))
    os.system('wget ' + baseURL + tag.attr['src'])
    filelist.append(fileName)
    timeList.append(timeName)

count = 0
for filename in filelist:
    print(filename)
    image = Image.open(filename)
    #label = image.crop((393, 659, 393+250, 712))
    label = image.crop((437, 684, 437+55, 684+12))
    #print(pytesseract.image_to_string(label, lang='eng'))
    #print(pytesseract.image_to_data(label))
    label = label.resize((100,21))
    label = label.convert('L')
    label = label.convert('1')
    image = image.resize((800,558))
    image = image.convert('RGB')
    pix = numpy.array(image)
    pix = binarize_array(pix)
    image = Image.fromarray(pix)
    image = image.convert('L')
    image = image.convert('1')
    image2 = Image.new('1',(800,600),(1))
    image2.paste(image, (0, 41))
    rect = round_rectangle((110,42),10,timeList[count])
    image2.paste(rect, ((800/len(filelist))*count, 0))
    #image2.paste(label,(110*count + 5, 10))
    count = count + 1
    image2.save(filename + '.bmp')
    pix = numpy.array(image2)
    bitArray = bytearray()
    for x in pix:
        row_int = numpy.packbits(numpy.uint8(x))
        for y in row_int:
            bitArray.append(y)
            pass
    out = open(filename+'.gz','wb')#BytesIO()
    with gzip.GzipFile(fileobj=out, mode="wb") as f:
      f.write(bitArray)
    out.close()

BLE_data.write(b'\xFE')

for filename in filelist:
    print("Sending %s" % filename)
    with open(filename + ".gz", "rb") as f:
        byte = f.read(240)
        print(len(byte))
        while byte != b"":
            BLE_data.write(len(byte).to_bytes(1,'big')+byte)
            byte = f.read(240)
            #time.sleep(0.1)
        BLE_data.write(b'\x00')
    time.sleep(10)



以上就是畫天氣圖的部分,接下來就是Dashboard:

畫圖基本上還是Adafruit GFX,只是因為SDK是純C所以改了一下把原本的C++變成C。
而Dashboard的Prototype是靠AIDA64大致上抓一下感覺。
不過最難的在於自己寫這一個widget:


大概噴了兩個多小時寫了這個簡單的widget,因為Arc不好搞。
struct arc_struct {
    char plotName[15];
    float value;

    char unit[2];

    int innerR;
    int outerR;

    float max;
    float min;

    float angle;

    int origin_x;
    int origin_y;

};  
//https://stackoverflow.com/questions/8887686/arc-subdivision-algorithm/8889666#8889666
void f(float cx, float cy, float px, float py, float theta, int N)
{
    float dx = px - cx;
    float dy = py - cy;
    float r2 = dx * dx + dy * dy;
    float r = sqrt(r2);
    float ctheta = cos(theta/(N-1));
    float stheta = sin(theta/(N-1));
    //std::cout << cx + dx << "," << cy + dy << std::endl;
    for(int i = 1; i != N; ++i)
    {
        float dxtemp = ctheta * dx - stheta * dy;
        dy = stheta * dx + ctheta * dy;
        dx = dxtemp;
        writePixel(cx + dx, cy + dy, BLACK);
        //std::cout << cx + dx << "," << cy + dy << std::endl;
    }
}
void plotArc(struct arc_struct *plot){

    plot->angle = plot->value / (plot->max - plot->min) * 360;

    for(float x=plot->innerR;x<plot->outerR;x+=0.5){
        f(plot->origin_x, plot->origin_y, plot->origin_x, plot->origin_y-x, plot->angle/180*3.14,350);
    }
    drawCircle(plot->origin_x, plot->origin_y,plot->innerR-2,BLACK);
    drawCircle(plot->origin_x, plot->origin_y,plot->outerR+1,BLACK);
    setTextColor(BLACK);
    setTextSize(3);
    char buf[20]={0};
    int n = sprintf(buf,"%.0f%s",plot->value,plot->unit);
    int16_t x1,y1,w,h;
    getTextBounds(buf,0,0,&x1, &y1,&w,&h);
    setCursor(plot->origin_x-w/2,plot->origin_y-h/2);
    for(int i=0;i<n;i++){
        LCDwrite(buf[i]);
    }

    setTextSize(2);
    n = sprintf(buf,"%s",plot->plotName);
    getTextBounds(buf,0,0,&x1, &y1,&w,&h);
    setCursor(plot->origin_x-w/2,plot->origin_y+plot->outerR+18-h/2);
    for(int i=0;i<n;i++){
        LCDwrite(buf[i]);
    }

}

其實效率非常差,我的畫法是一堆Arc填滿,但是應該要用Boundary + Fill algorithm,之後再來改進吧。其他Widget就還好了,都還算簡單,Code滿亂的改天整理完再Po。

耗電量這邊也可以看到畫圖花了非常久,大概400ms才畫完。
平均耗電量約莫1.3mA,不低但是搭配2500mAh的電池也是有兩個多月的使用時間。

資料靠HWiNFO + Python Script,HWiNFO 可以把資料導向一個UDP port,然後Python去收。
這邊有人寫好了一個範例: https://medium.com/swlh/reverse-engineering-a-tcp-protocol-455d248d68fa,我只是拿來改

只是BLE連線我目前暫時是用RPI Zero 來處理,然後透過USB Ethernet device直接連到我的PC。

大概就是這樣,順帶一提買了一台新的3DP MK3S+真的好好用,按鍵一按下去電腦的3D模型就生出來了呢,這次的外殼就是新3D印表機弄的,連螺絲都是組裝剩下的備料XD