2021年12月24日 星期五

Mini Raspberry Pi Compute Module 4 Cluster

 

標題跟圖片好像就夠解釋了,總之這台是一個迷你型叢集電腦,一共有四組Raspberry Pi Compute module 4 (CM4) + Raspberry Pi 4 作為控制器

先上架構圖:


架構圖是有點過度複雜,因為實際上就是 Gigabit ethernet Hub + USB hub + Serial port/GPIO control.

先來講講這片Cluster Hat:

Gigabit ethernet switch用的是RTL8367N,主要是因為Raspberry Pi 擴充板的面積不是很大,沒有什麼空間可以放大顆QFP封裝的IC,我也不想弄成複雜的雙面電路板,這顆是5-port的剛好四個Node + 一個聯外。 
這種高密度的Cluster有一個比較麻煩的是網路PHY-PHY直接對接,因為其實這麼短的線路沒有必要放隔離變壓器佔空間,但是PHY直接對接就要看PHY是怎麼設計推動變壓器的,幸好這個只需要中間放一個.1uF的電容既可.


板子上有預留SPI Flash不過不確定能不能弄成managed switch,不過現在用起來也沒有感覺非要managed switch的必要。

CM4有一些可以被控制的GPIO,EN pin控制板子PMIC開關,BOOT控制USB開機(透過USB燒錄CM4),RUN控制SoC的Reset,RST監控SoC狀態,我稍微算了一下其實買I2C GPIO expender其實不是很划算,相比RP2040其實差不了多少所以......


RP2040除了有一堆GPIO,還有四個ADC pins所以我拿來測量個別CM4的耗電量,而RP2040可以透過UART或者是USB跟Raspberry Pi 溝通,而Raspberry Pi可以透過兩個GPIO模擬SWD上傳程式。這應該算是滿廢的用法我覺得,但是 1 USD + 1 USD的零件真的想不到為啥要買I2C GPIO expender。

USB 2.0 Hub是USB2517,主要是用來燒錄CM4以及一條除了ethernet以外直接跟Raspberry Pi 4控制版對接的方式。另外因為我找不到一個組合可以透過GPIO config出我要的,所以還是放了一個I2C eeprom放config。

除此之外板子上也有一些周邊,這種高密度的Cluster一定是需要風扇,所以拿了EMC2301來控制PWM 4cm 5v的風扇,另外因為有一堆DC-DC是直接吃Input供電,剛上電的時候因為一堆分散的電容在各處,Inrush 電流會很高,所以我拿了一個eFuse IC當作保險絲兼緩啟動(Soft-Start)。最後順便塞一個INA226監控輸入電流跟電壓。

風扇的連接器也要小心放,因為很容易就會碰到RPI4的連接器的外殼。
喔對了電源輸入跟網路孔之間還有一個Qwiic連接器在中間XD

擴充板上大概就是這樣,接下來介紹的是Node版:

Node版很簡單,就是把CM4的IO拉到一個M.2的連接器加上5V DC-DC 供電。拉到M.2的只有gigabit ethernet跟USB還有些許的GPIO跟I2C,還有一個很有意思的PCIe不想浪費,所以直接在Node版放了一個M.2 Connector讓我能夠接上NVMe SSD,也為了這個M.2 Connector 的供電所以板子上又放了一組DC-DC降壓。

另外一個就是板子上有一個TMP117溫度計在CM4的SoC正下方,透過散熱墊(?)跟CM4接觸,這個溫度計的I2C是直接接上控制的Raspberry Pi 4,所以風扇溫度控制並不需要Node透過其他方式回報溫度而是RPI4直接測量後控制。


當然最後就是Input供電的保護以及eFUSE,為了熱插拔所以放了eFUSE作爲緩啟動,另外也放了一個IC型的TSV突波保護。Camera Connector只是因為M.2 Connector有這個空間就順便放上去了。
這顆eFUSE也能順便幫我測量電流,所以Node的電流測量不需要另外裝電流檢測電阻跟相關的電路了。
左邊那顆是TVS,右邊是eFUSE
只能說晶片缺的另一個理由就是越來越多傳統元件被IC取代,好久沒有在電源輸入用到傳統的保險絲跟TVS Diode。


第二版想說省一點空間所以把M.2放到背面,另外轉個方向朝內,缺點就是沒辦法支援2280長度的裝置但是我找到一批便宜的2242 Optane 16G SSD所以就沒差了.


整組叢集電腦如果火力全開(CPU/跟網路壓力測試但是沒有接NVMe)的話大概可以飆到32W,放桌上冬天當手的暖氣還稍嫌不足就是。


以上。


2021年8月29日 星期日

Nixie Tube, Miniature Atomic Clock, Frequency measurement, Clock. - Part D (AC waveform measurement)

市電頻率一向是我很有興趣的一個觀測對象,理由是讀歷史資料的時候看到了兩種對於市電頻率衝突的描述。有一種說法是市電頻率非常的精確,很多工業用的計時基準跟同步裝置甚至是直接用了市電。另外一種則是指市電頻率可以觀測到當下供電端跟受電端平衡,當供給大於需求的時候頻率上升,反之下降。所以既然都入手了原子鐘要來做時間基準,當然是想辦法塞進監控AC頻率來看看到底頻率變化是長甚麼樣子。

測量AC頻率最基本的方式就是Zero-Crossing觸發,然後再透過外部的MCU/FPGA計算每次觸發之間的時間間隔來計算。Onsemi有一份App Note真的不錯,第一版的電路就是照這個做: https://www.onsemi.com/pub/Collateral/AND9282-D.PDF

之後把觸發訊號送到FPGA內的計數器這樣。不過第一版為了簡化供電跟安全考量,沒有直接市電而是透過一個AC變壓器轉成12VAC之後送進去,12VAC在內部轉DC之後降壓。所造成的問題也可想而知,因為原子鐘的耗電量不低,電流量大的狀況下AC整流那一塊的電容跟二極體發熱量不低。

這個是顯示AC的計數器,1/166421 * 10Mhz = 60.088Hz,殘影是因為頻率後面幾位數變化不低





第二版就把AC的訊號跟供電分開,簡化整流的部分,另外為了能夠在比較精確的方式監控Vrms,把第一版的LTC1966換成了直接用ADC測量波形後用軟體計算Vrms。ADC用的是ADS8681,因為SAR架構下可以直接吃正負電的訊號,我也不用另再用OPA處理進來的訊號,而且還有內部的PGA可以調放大倍率。ADC跟FPGA之間透過SPI通訊,採樣速度50Khz,觸發採樣的訊號也是原子鐘出來的,所以可以確保時間的精確度。資料透過RPI計算Vrms,頻率跟資料保存。

軟體很簡化的長這樣,其中比較重要的是Polling是先讀FPGA內FIFO的數量,然後把該數量的資料讀回來。 Polling thread特別讓他獨佔了一個Core,Linux的Schdeular只能用其他三個,然後寫入的部分開了一些Ram Disk的空間寫入,每分鐘定時再把資料搬到NVMe Drive上面。

Polling還有一個需要提到的是SPI在這種高密度的讀寫的時候,如果用的是spidev的介面,由於預設的狀況下系統很容易用到Interrupt Base的處理,這時候就會造成不可預期的時間delay。所以我在/boot/cmdline.txt加了這一行: spi_bcm2835.polling_limit_us=65535,這樣一來就可以確保系統在讀寫SPI的時候會是busy loop的方式等SPI結束。

ADC的校正則是用了一台Keysight 34410A,先校正ADC電壓測量,再來就是直接督插座之後兩邊比對AC Vrms資料


波形大概長這樣

另外多虧這次AC變成純訊號端,把供電接上UPS就可以抓到跳電/停電瞬間的波形:

其實真的很有趣,那時候跳電跳到停電之前就有感覺到燈光閃了一下,波形上就很明顯看到那一段,而且更有趣的是跳電瞬間之後電壓居然比之前高了一點,之後才整個停電。

拉近一點看第一次跳電的話,可以看到在100~200ms之內就幾乎沒電了。

不過直接用ADC採樣的話要抓精確的頻率就需要非常高的採樣頻率,這也是第二版最大的問題,ADC本身是可以跑到1M的,問題在於FPGA。即便是已經單獨給CPU/SPI用Polling,還是很容易掉幾筆資料。我用的FPGA是iCE5LP,算是滿小顆的FPGA,內部並沒有多少的ram block可以給我當FIFO用,而Linux下還是很常遇到數ms的中斷,導致FIFO overflow,加上為了標記每一筆資料的時間,每個Sample都是16bit的timestamp+16bit的ADC value,16bit的timestamp其實很尷尬,為了資料好處理,50Khz的話剛好每一秒timestamp可以歸0,這樣比較容易把資料跟真實時間擺在一起。

總結來說錯估了RPI的SPI通訊的能力跟FIFO的需求,所以沒有設計外部的DRAM只能靠內部太淺的FIFO。還好就是跟好這個FPGA的封裝可以換一個有內建比較大容量BRAM的高效能版本,可能之後rework FPGA替換掉之後再來看速度可以拉到多少。

至於頻率精確度,我大致上能理解為什麼會有兩種說法,問題在於看的是哪個時間平均,如果單純用來推時鐘,那長期下來<4ppm的時間誤差其實也比很多石英震盪器來的精確,但是短時間的誤差會飄很大,如果直接拿這來當PLL的時間基準是不行的。另外很有趣的是市電電壓跟需求量還滿有關的,需求越高,電壓越低(線阻?)

總結來說算是回答了我原本的疑問,接下來就是看有甚麼有趣的市電異常的現象發生了。

2021年5月24日 星期一

Nixie Tube, Miniature Atomic Clock, Frequency measurement, Clock. - Part C (Nixie Clock Driving)

Nixie Tube的驅動方式這幾年來陸陸續續也是換了滿多種的做法,從剛開始用74141到74141 + 陽極掃描到灑一堆高壓電晶體,這次的設計先來上電路圖:

這次電路設計是因為空間的關係,直接用了HV5623的高壓位移暫存器,每一個HV5623可以接32個IO,所以八個真空管的10個位數x8 + 兩個小數點 x 8 剛好三個HV5623,然後每個HV5623都接在一個SPI Bus上。至於控制亮度則是有兩組,一個是高壓端透過PCA9685的PWM控制單獨每一個真空管,加上HV5623的全域開關控制亮度。

Nixie Tube有一個很有趣的特性是他啟動和關閉都需要一點時間,如果開啟的速度太短就會像上圖一樣來不及整個數字發光。下面這兩張圖都鎖定了曝光時間/光圈/ISO,第一張圖是在低PWM的時候會發生的狀況:

如果仔細看第二篇的High Level System Diagram的話就會發現有一個PDM的Block,而不是常見的PWM。因為晚上我不想要燈光太亮但是又需要看到時間,所以為了解決低亮度的時候斷線的問題,加上因為HV5623的控制線接上FPGA,硬體彈性很高,直接設計了一個16Bit的PDM去驅動全域的亮度。

這邊先來講PDM跟PWM的差異,PDM顧名思義就是脈波的密度,PWM是脈波的寬度。PDM透過一個固定寬度的脈波但是調整脈波之間的密度,PWM透過控制脈波的寬度(占比)。所以這就是為什麼PDM可以解決低亮度下的問題,因為我可以設計上讓PDM的單位脈波的寬度寬到讓每一個數字都能完整的點亮,然後透過脈波調整密度控制亮度。

PWM在驅動Nixie Tube的時候會遇到的就是假如PWM速度太快,當Duty Cycle太低導致脈波寬度低於Nixie Tube可以完整點亮的時候就會導致斷線,而且就算我降低PWM的速度,終究是會有Duty Cycle低於完整點亮的時候,而且PWM一旦降低就會造成閃爍。低亮度調整範圍就會被限制在會斷線的Duty Cycle。

這是兩個不同亮度的PDM波形,你可以看到上下兩張圖的脈波是一樣的,只是第二張的密度變高了。


PDM Block在FPGA裡面我是控制了兩個參數,一個是PDM的Value,另一個是Clock Divider(脈波寬度),所以我能同時控制密度跟脈波寬度。End Result就是在低亮度的狀況下還是可以保持完整的顯示:

當然,PDM不是萬靈丹,如果無限制的往下調亮度因為脈波密度太低,終究是會變成閃爍的燈光,但是PDM可以延伸到比PWM再更暗的亮度,在夜晚顯示的時候就比較好看而且不會刺眼。

最後的最後的就是因為是FPGA,我可以很隨意的拉高PDM的bit寬度,拉到16bit有非常大的調整空間可以讓我用log value亮度控制曲線。能夠設計使用PDM去控制亮度算是用FPGA的附加效應了,一般的MCU我也不確定要怎麼用硬體的Block去實作,也許可能用Raspberry Pi Pico的PIO Block?


FPGA推HV5623的話就很簡單了,基本上就是SPI Controller + BCD−to−Decimal,設計上input是八個數字,input編碼是二進位的BCD,加上兩個Byte的小數點。
每一個Digit的BCD Code是4bit,八個剛好32Bit,然後每組Digit的BCD接上BCD−to−Decimal轉換邏輯。最後把這些bit串成一組96bit要送出去的資料打出去,trigger就是FPGA內部的Pulse Per Second訊號,或者是Raspberry Pi直接寫FPGA Register的訊號(這邏輯就是在其他Block實作了,這邊就單純當作收到pps)。
module NixieCounter (
 input clk,
 input rst,
 input wire [31:0] NixieBCD,
 input wire [15:0] digitpoint,
 output reg NIXIE_LE,
 output reg NIXIE_CLK,
 output NIXIE_DIN,
 input pps,
 output reg Done
);

    wire[95:0] nixiedata_96bit;

    binarytoNixiedigit conv8(.x(NixieBCD[3:0]),.z(nixiedata_96bit[96-2:84+1])); 
    binarytoNixiedigit conv7(.x(NixieBCD[7:4]),.z(nixiedata_96bit[84-2:72+1])); 
    binarytoNixiedigit conv6(.x(NixieBCD[11:8]),.z(nixiedata_96bit[72-2:60+1])); 
    binarytoNixiedigit conv5(.x(NixieBCD[15:12]),.z(nixiedata_96bit[60-2:48+1])); 
    binarytoNixiedigit conv4(.x(NixieBCD[19:16]),.z(nixiedata_96bit[48-2:36+1])); 
    binarytoNixiedigit conv3(.x(NixieBCD[23:20]),.z(nixiedata_96bit[36-2:24+1])); 
    binarytoNixiedigit conv2(.x(NixieBCD[27:24]),.z(nixiedata_96bit[24-2:12+1])); 
    binarytoNixiedigit conv1(.x(NixieBCD[31:28]),.z(nixiedata_96bit[12-2:0+1])); 

    assign {nixiedata_96bit[0] ,nixiedata_96bit[12],nixiedata_96bit[24],nixiedata_96bit[36],nixiedata_96bit[48],nixiedata_96bit[60],nixiedata_96bit[72],nixiedata_96bit[84],nixiedata_96bit[11+0] ,nixiedata_96bit[11+12],nixiedata_96bit[11+24],nixiedata_96bit[11+36],nixiedata_96bit[11+48],nixiedata_96bit[11+60],nixiedata_96bit[11+72],nixiedata_96bit[11+84]} = digitpoint;

    wire[95:0] nixiedata_32bit ;

          function [32-1:0] bitOrder (
              input [32-1:0] data
          );
          integer i;
          begin
              for (i=0; i < 32; i=i+1) begin : reverse
                  bitOrder[32-1-i] = data[i]; //Note how the vectors get swapped around here by the index. For i=0, i_out=15, and vice versa.
              end
          end
          endfunction
    //assign sample_rev = bitOrder(sample_in); //swap the bits.
    assign nixiedata_32bit[31:0] = bitOrder(nixiedata_96bit[96-1:64]);
    assign nixiedata_32bit[63:32] = bitOrder(nixiedata_96bit[64-1:32]);
    assign nixiedata_32bit[95:64] = bitOrder(nixiedata_96bit[32-1:0]);


    //assign nixiedata_32bit[31:0] = nixiedata_96bit[96-1:64];
    //assign nixiedata_32bit[63:32] = nixiedata_96bit[64-1:32];
    //assign nixiedata_32bit[95:64] = nixiedata_96bit[32-1:0];

    localparam IDLE = 3'b000;
    localparam SHIFT_0 = 3'b001;
    localparam SHIFT_1 = 3'b010;
    localparam START_0 = 3'b011;
    localparam START_1 = 3'b100;
    localparam START_2 = 3'b111;
    localparam END = 3'b101;
    localparam END_WAIT = 3'b110;


    reg [2:0] currentState;
    reg [2:0] nextState;

    reg [15:0] dataCounter;
    reg [95:0] nixie_data;
    reg [1:0] data_in_valid_d;

    assign NIXIE_DIN = nixie_data[95]; // send MSB first

    reg [1:0] fallingEdgePPS_d;
    wire fallingEdgePPS = (fallingEdgePPS_d == 2'b10);
    always @(posedge clk) begin
        fallingEdgePPS_d <= {fallingEdgePPS_d[0],pps};
        if (!rst) begin
            currentState <= IDLE;
            NIXIE_CLK <= 0;
            NIXIE_LE <= 1;
            dataCounter <= 0;
            nixie_data <= 0;
            Done <= 0;
            fallingEdgePPS_d <= 0;
        end
        else begin
            currentState <= nextState;
            case(currentState)
                IDLE: begin
                    if(fallingEdgePPS) begin
                        nixie_data <= nixiedata_32bit;
                        dataCounter <= 0;
                    end
                    NIXIE_LE <= 1;
                    NIXIE_CLK <= 0;
                    Done <= 0;
                end
                START_2: begin
                    NIXIE_LE <= 1;
                    NIXIE_CLK <= 0;
                end
                START_0: begin
                    NIXIE_LE <= 0;
                    NIXIE_CLK <= 0;
                end
                START_1: begin
                    NIXIE_LE <= 0;
                    NIXIE_CLK <= 1;
                end
                SHIFT_0: begin
                    NIXIE_LE <= 0;
                    NIXIE_CLK <= 1;
                    dataCounter <= dataCounter + 1;
                    nixie_data <= {nixie_data[94:0], 1'b0};
                end
                SHIFT_1: begin
                    NIXIE_LE <= 0;
                    NIXIE_CLK <= 0;
                end
                END_WAIT: begin
                    NIXIE_LE <= 0;
                    NIXIE_CLK <= 0;
                    Done <= 1;
                end
                END: begin
                    NIXIE_LE <= 1;
                    NIXIE_CLK <= 0;
                end
                default: begin
                    
                end
            endcase
        end
    end

    always @(*) begin
        nextState = currentState;
        case(currentState)
            IDLE: begin
                if(fallingEdgePPS) begin
                    nextState = START_2;
                end
            end
            START_2: begin
                nextState = START_0;
            end
            START_0: begin
                nextState = START_1;
            end
            START_1: begin
                nextState = SHIFT_0;
            end
            SHIFT_0: begin
                nextState = SHIFT_1;
            end
            SHIFT_1: begin
                if(dataCounter == 15'd95)begin
                    nextState = END_WAIT;
                end
                else begin
                    nextState = SHIFT_0;
                end
            end
            END_WAIT: begin
                if(pps)begin
                    nextState = END;
                end
                else begin
                    nextState = END_WAIT;
                end
            end
            END: begin
                nextState = IDLE;
            end
            default: begin
                nextState = IDLE;
            end
        endcase
    end

endmodule

module binarytoNixiedigit(
    input  [3:0]x,
    output [9:0]z 
    );
    reg [9:0] z /* synthesis syn_romstyle = "select_rom" */;
    always @* 
    case (x)
    4'b0000 :       //Hexadecimal 0
    z = 10'b1000000000;
    4'b0001 :       //Hexadecimal 1
    z = 10'b0000000001;
    4'b0010 :     // Hexadecimal 2
    z = 10'b0000000010;
    4'b0011 :     // Hexadecimal 3
    z = 10'b0000000100;
    4'b0100 :   // Hexadecimal 4
    z = 10'b0000001000;
    4'b0101 :   // Hexadecimal 5
    z = 10'b0000010000;
    4'b0110 :   // Hexadecimal 6
    z = 10'b0000100000;
    4'b0111 :   // Hexadecimal 7
    z = 10'b0001000000;
    4'b1000 :          //Hexadecimal 8
    z = 10'b0010000000;
    4'b1001 :       //Hexadecimal 9
    z = 10'b0100000000;
    default:     // Hexadecimal A
    z = 10'b0000000000;
    endcase
 
endmodule


其他的細節像是每小時random,半小時跑sequence掃一遍所有的數字就跟之前一樣了
為了避免Cathode Poisoning,需要掃一下每個Digit,我可能之後做成random最後結果是當時的市電頻率之類的XD

2021年5月17日 星期一

Nixie Tube, Miniature Atomic Clock, Frequency measurement, Clock. - Part B (High Level System Arch)

 


上次的Part. A過了兩年......終於有機會能夠重新Revisit這個Project,主要是因為第一版受限於一些限制(後續說明),軟體上有點卡住。直到去年推出的Raspberry Pi Compute Module 4 解除了一些軟體上的限制,所以這次重新設計了底板以及系統架構。 現在軟體跟硬體大致底定,有空可以來寫幾篇。

這篇主要會先講的是整體系統的架構,大致上就是講這張Block Diagram:
綠色是FPGA內建的Hard IP,紫色是FPGA的Verilog Block,藍色是IC/Sensor,紅色是Power,灰色是Connector。

以功能來說,核心功能主要有以下幾個:
  • GPS校正系統時鐘,以及監控誤差跟時鐘基準輸出
  • AC 頻率以及Vrms監控
  • Nixie Clock
  • 環境監測
因為前三點都需要非常精準的Timing,所以設計上交給了FPGA,其他部分則交給Raspberry Pi Compute Module來做。FPGA跟Raspberry Pi中間透過兩個SPI,主要是因為我想拉高SPI的Clock,所以多了一組由FPGA內建的48MHz震盪器驅動的SPI Peripheral,所以你也可以看到FPGA內部有兩個Clock Domain,高速SPI是為了收ADC的資料,所以跨Domain有一個FIFO。

FPGA另外還會輸出兩個Time Signal,其中一個PPS接上一般的IO,另外一個Clock輸出是接上Raspberry Pi Compute Module 4上面的IEEE 1588 Sync In,當然中間經過了一個3.3v->1.8v的Buffer,等到比較正式的IEEE 1588 Support出來之後我就可以來搭配有線網路來當作我的內網路PTP Clock Source,因為FPGA的彈性,所以Clock要怎麼樣打可以等Support出來再來弄,先暫時只用RPI的PPS GPIO當作NTP的校正源。

其他的周邊則是直接接上Raspberry Pi,GPS以及Miniture Atomic Clock都是透過UART,這邊要提到的是Raspberry Pi 4支援非常多的UART Port,一次就支援五組UART可以用。前一版本的設計因為被Raspberry Pi Zero受限,所以MAC的UART需要透過FPGA幫忙轉SPI,徒增複雜度。

I2C的狀況也是類似,Raspberry Pi 4支援了非常多組的I2C,由架構圖可以看到我掛了非常多的東西在I2C Bus上,I2C Bus也有兩條,其中一條是接上上層的Nixie Tube的板子,另一條則是底板用的。

需要設定I2C和SPI Bus的話,透過dtoverlay既可,以下的範例是設定I2C 0跟I2C1 還有 SPI的CS pin,
    dtoverlay=i2c-gpio,bus=0,i2c_gpio_sda=22,i2c_gpio_scl=23
    dtoverlay=i2c-gpio,bus=1,i2c_gpio_sda=2,i2c_gpio_scl=3
    dtoverlay=spi0-2cs,cs0_pin=8,cs1_pin=20

Raspberry Pi 的 Image已經預載了這些dtoverlay,到/boot/overlay看有哪些之後,用這個指令查看怎麼設定
    dtoverlay -h [overlay名稱]

Power 的架構則是稍微複雜一點,主要是因為MAC在預熱過程至少需要3A,保險起見我想要拉到4A,加上因為是供應原子鐘,我不想要直接透過DC-DC供電。所以從上圖的架構圖可以看到MAC的供電是降壓到5.5V之後再送到LDO穩壓成5V,先透過DC-DC降壓的話LDO的消耗可以大幅地降低。另外還是有一組5V LDO是直接供電ADC用,因為耗電量不大,只要Noise夠低就好。接下來是CM4用的 5V,直接用DC-DC降壓,這組5V同時供應USB-C (Host Mode的話),還有再經過3.3V LDO降壓給底板用的Sensor們。最後就是Nixie Tube的Power Supply,我想做一些比較Fancy的控制,所以有一組AD/DA去控制跟監控Nixie的Voltage。供電的部分都有LED燈顯示狀況,也能夠用一個MOS把這些Power LED關掉。監控的部分則是有四組INA219做電流/電壓的監控。

基本上是這樣,接下來會針對每一個功能來寫比較詳細的文章。

2021年4月19日 星期一

Raspberry Pi Sky Camera

 


總之來記錄一下我的戶外天空相機。

我一直很想要做的是一個能夠照天空的縮時攝影機,不過最大的問題一直都在怎麼做到防水,卡在我的機械結構嚴重欠缺正常的知識。所以當初第一版長的是這樣:

買了一個盜版但是有WiFi的運動攝影機SJ4000再加上防水盒,然後改裝加上降壓電路直接從5V降成鋰電池3.8V,電池空出來的地方再放一個MCU讓開機的時候直接打開WiFi。
沒錯,處理防水跟改電路我選後者。SJ4000的WiFi連上RPI,然後RPI控制照相跟抓檔案/刪檔案,只是問題在於這台的韌體非常不穩定,長期用下來他的WebServer會有機率的死當,再加上照出來效果真的還好,就沒繼續弄了。

基本上就是一個給RPI Camera V2用的防水盒
原廠也有出一個改裝魚眼的套件,只是我沒買。主要是因為買的到IMX219的M12可替換鏡頭模組,可以直接裝上RPI Camera V2的電路板上 (如上圖),所以只要找的到合適的M12鏡頭就可以用了。但是我當時沒想到1/4"的魚眼鏡頭有那麼難找,上圖的設計是給1/2",鏡頭大到裝不下不說,照出來用1/4"的也沒有到全景,只好退回原本的廣角鏡頭。


然後又因為照天空很無聊所以改照101,角度調完之後就丟出去了,排線想說是是看能撐多久所以沒做甚麼特殊的保護。

不過照片當然沒有那麼簡單,魚眼鏡頭很容易造成鏡頭色散,嚴重的話就需要手動去校正,什麼叫做色散? 讓我用這張照片解釋:
校正前
校正後



可以看到圖有很明顯的中心偏綠,外側偏紅的情形,這是因為這個色散超出了RPI ISP的校正範圍,所以這時候就需要自己生出校正的矩陣:
1.首先要來照一張全白的畫面:
2.用https://github.com/6by9/lens_shading 這個來生出ls_table.h
再重新compile raspistill之後就好了

校正完畢之後照出來效果應該就會好很多,接下來要做的就是考慮日夜變化。
因為晚上的時候我希望曝光時間可以拉到10s (Raspberry Pi Camera V2的上限),白天我希望能夠照幾張不同的曝光來合成HDR,所以這裡我先用了這個: https://github.com/risacher/sunwait
來判斷現在的Strategy是哪個,我設定的是太陽落到-7度之後變成晚上的照相模式。

之後照相把raw檔也記錄下來,但是之後處裡的時候把jpg和raw切開,這樣如果我之後懶得碰raw,至少我可以只下載jpg的部分。
head -c -10237440 /home/pi/photo/ramdisk/$FILEDATE.jpgraw > /home/pi/photo/ramdisk/$FILEDATE.jpg
大概長這樣,順帶一提我開了一個小的ramdisk來放暫存檔案,最後資料會直接傳到現在已經消失的無上限教育版Google Drive。

從去年回美國之前架設好到今年二月初掛掉,這是今年二月的照片:
排線斷得很慘,而且還進了一點水,所以是時候來升級這台,並且把之前的資料來整理一下 (加上Google把無上限空間收回,我也得要把資料重新載下來)。

因為半年的資料不少,為了我的硬碟著想(timelapse大概有8T),我沒有用raw檔來做處理,還是直接抓jpg檔案(1.8T)。用PIL寫上照片的時間,然後處理HDR,只是可能因為我只用了jpg來合成,HDR的效果其實還好:

當然也有可能是因為白天的曝光Strategy的級距太小,只有ev-3,0,+3這三個級距。

這時候剛好看到一個合成完畢的Frame長這樣:
還滿好看的,雖然我不知道為什麼這原始檔案就有那個奇怪的亮度階梯在那邊

最後附上兩種縮時,一個是有HDR另一個沒有:


如果我把同個時間點但是不同天的照片抓出來排縮時:


你可以很明顯地看到背景的星星,金星,月亮移動方式都不一樣


大概是這樣,希望接下來的版本換掉排線之後可以用久一點。

需要改進的大概就是白天曝光的級距加大,晚上的要縮小,raw檔案可能要先壓縮再來上傳,以及要處理raw轉dng的相機校正檔案。還有日落跟日出可能要有個階梯式的Strategy調整方式,尤其是固定awb。希望我有空來修改raspistill直接把不同的曝光frame抓出來。

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